diff --git a/.github/scripts/version-bump.sh b/.github/scripts/version-bump.sh index f84a7f89..ec078fcc 100755 --- a/.github/scripts/version-bump.sh +++ b/.github/scripts/version-bump.sh @@ -1,14 +1,18 @@ #!/bin/bash # Version Bump Script -# Usage: ./version-bump.sh [base_ref] +# Usage: ./version-bump.sh [--ci] [base_ref] +# --ci: CI mode - run bump, check for changes, exit 1 if changes needed # bump_type: patch, minor, or major # base_ref: base reference for diff (default: origin/main) set -euo pipefail +CI_MODE=false + usage() { - echo "Usage: $0 [base_ref]" + echo "Usage: $0 [--ci] [base_ref]" + echo " --ci: CI mode - validates versions are already bumped (exits 1 if not)" echo " bump_type: patch, minor, or major" echo " base_ref: base reference for diff (default: origin/main)" echo "" @@ -16,6 +20,7 @@ usage() { echo " $0 patch # Update versions with patch bump" echo " $0 minor # Update versions with minor bump" echo " $0 major # Update versions with major bump" + echo " $0 --ci patch # CI check: verify patch bump has been applied" exit 1 } @@ -85,7 +90,7 @@ update_readme_version() { in_module_block = 0 if (module_has_target_source) { num_lines = split(module_content, lines, "\n") - for (i = 1; i <= num_lines; i++) { + for (i = 1; i < num_lines; i++) { line = lines[i] if (line ~ /^[[:space:]]*version[[:space:]]*=/) { match(line, /^[[:space:]]*/) @@ -115,6 +120,11 @@ update_readme_version() { } main() { + if [ "${1:-}" = "--ci" ]; then + CI_MODE=true + shift + fi + if [ $# -lt 1 ] || [ $# -gt 2 ]; then usage fi @@ -152,6 +162,8 @@ main() { local untagged_modules="" local has_changes=false + declare -a modified_readme_files=() + while IFS= read -r module_path; do if [ -z "$module_path" ]; then continue; fi @@ -202,6 +214,7 @@ main() { if update_readme_version "$readme_path" "$namespace" "$module_name" "$new_version"; then updated_readmes="$updated_readmes\n- $namespace/$module_name" + modified_readme_files+=("$readme_path") has_changes=true fi @@ -210,19 +223,22 @@ main() { done <<< "$modules" - # Always run formatter to ensure consistent formatting - echo "🔧 Running formatter to ensure consistent formatting..." - if command -v bun > /dev/null 2>&1; then - bun fmt > /dev/null 2>&1 || echo "âš ī¸ Warning: bun fmt failed, but continuing..." - else - echo "âš ī¸ Warning: bun not found, skipping formatting" + if [ ${#modified_readme_files[@]} -gt 0 ]; then + echo "🔧 Formatting modified README files..." + if command -v bun > /dev/null 2>&1; then + for readme_file in "${modified_readme_files[@]}"; do + bun run prettier --write "$readme_file" 2> /dev/null || true + done + else + echo "âš ī¸ Warning: bun not found, skipping formatting" + fi + echo "" fi - echo "" echo "📋 Summary:" echo "Bump Type: $bump_type" echo "" - echo "Modules Updated:" + echo "Modules Processed:" echo -e "$bumped_modules" echo "" @@ -239,6 +255,19 @@ main() { echo "" fi + if [ "$CI_MODE" = true ]; then + echo "🔍 Comparing files to committed versions..." + if git diff --quiet; then + echo "✅ PASS: All versions match - no changes needed" + exit 0 + else + echo "❌ FAIL: Module versions need to be updated" + echo "" + echo "Run './.github/scripts/version-bump.sh $bump_type' locally and commit the changes" + exit 1 + fi + fi + if [ "$has_changes" = true ]; then echo "✅ Version bump completed successfully!" echo "📝 README files have been updated with new versions." diff --git a/.github/typos.toml b/.github/typos.toml index 7ebdacef..d0b1f286 100644 --- a/.github/typos.toml +++ b/.github/typos.toml @@ -3,6 +3,7 @@ muc = "muc" # For Munich location code tyo = "tyo" # For Tokyo location code Hashi = "Hashi" HashiCorp = "HashiCorp" +hel = "hel" # For Helsinki location code mavrickrishi = "mavrickrishi" # Username mavrick = "mavrick" # Username inh = "inh" # Option in setpriv command diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 5601ef6a..37f29dd3 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -93,7 +93,7 @@ jobs: - name: Validate formatting run: bun fmt:ci - name: Check for typos - uses: crate-ci/typos@v1.40.0 + uses: crate-ci/typos@v1.42.0 with: config: .github/typos.toml validate-readme-files: diff --git a/.github/workflows/version-bump.yaml b/.github/workflows/version-bump.yaml index 7c51d0ef..aff9e0a1 100644 --- a/.github/workflows/version-bump.yaml +++ b/.github/workflows/version-bump.yaml @@ -55,62 +55,35 @@ jobs: ;; 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 + - name: Check version bump + run: ./.github/scripts/version-bump.sh --ci "${{ steps.bump-type.outputs.type }}" origin/main - { - echo "output<> $GITHUB_OUTPUT - - cat "$output_file" - - if git diff --quiet; then - echo "versions_up_to_date=true" >> $GITHUB_OUTPUT - echo "✅ All module versions are already up to date" - else - echo "versions_up_to_date=false" >> $GITHUB_OUTPUT - echo "❌ Module versions need to be updated" - echo "Files that would be changed:" - git diff --name-only - echo "" - echo "Diff preview:" - git diff - - git checkout . - git clean -fd - - exit 1 - fi - - - name: Comment on PR - Failure - if: failure() && steps.version-check.outputs.versions_up_to_date == 'false' + - name: Comment on PR - Version bump required + if: failure() uses: actions/github-script@v8 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.`; + const comment = [ + '## Version Bump Required', + '', + 'One or more modules in this PR need their versions updated.', + '', + '**To fix this:**', + '1. Run the version bump script locally:', + ' ```bash', + ` ./.github/scripts/version-bump.sh ${bumpType}`, + ' ```', + '2. Commit the changes:', + ' ```bash', + ` git add . && git commit -m "chore: bump module versions (${bumpType})"`, + ' ```', + '3. Push your changes', + '', + 'The CI will automatically re-run once you push the updated versions.' + ].join('\n'); github.rest.issues.createComment({ issue_number: context.issue.number, diff --git a/.gitignore b/.gitignore index 55947fc5..8d1b05c9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +.DS_Store # Logs logs *.log diff --git a/.icons/aider.svg b/.icons/aider.svg index 44e064ff..bb54e1fb 100644 --- a/.icons/aider.svg +++ b/.icons/aider.svg @@ -1,3 +1 @@ - - - \ No newline at end of file + \ No newline at end of file diff --git a/.icons/airflow.svg b/.icons/airflow.svg index 06b18bee..29a32e08 100644 --- a/.icons/airflow.svg +++ b/.icons/airflow.svg @@ -1,18 +1 @@ - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/.icons/akamai.svg b/.icons/akamai.svg index 4af3fe08..d5b778bc 100644 --- a/.icons/akamai.svg +++ b/.icons/akamai.svg @@ -1,4 +1 @@ - - Akamai - - +Akamai \ No newline at end of file diff --git a/.icons/amazon-q.svg b/.icons/amazon-q.svg index 4a9b3262..1c217bdc 100644 --- a/.icons/amazon-q.svg +++ b/.icons/amazon-q.svg @@ -1,13 +1 @@ - - - - - - - - - - - - - + \ No newline at end of file diff --git a/.icons/antigravity.svg b/.icons/antigravity.svg index a046b6b6..54eec2c4 100644 --- a/.icons/antigravity.svg +++ b/.icons/antigravity.svg @@ -1,2 +1 @@ - -Google_Antigravity-logo - brandlogos.net \ No newline at end of file +Google_Antigravity-logo - brandlogos.net \ No newline at end of file diff --git a/.icons/auggie.svg b/.icons/auggie.svg index 590bd5aa..180e1dee 100644 --- a/.icons/auggie.svg +++ b/.icons/auggie.svg @@ -1,8 +1 @@ - - - - - - - - + \ No newline at end of file diff --git a/.icons/auto-dev-server.svg b/.icons/auto-dev-server.svg index f043b56d..aae4a1a5 100644 --- a/.icons/auto-dev-server.svg +++ b/.icons/auto-dev-server.svg @@ -1,4 +1 @@ - - - - + \ No newline at end of file diff --git a/.icons/aws.svg b/.icons/aws.svg index 3244c974..828a8223 100644 --- a/.icons/aws.svg +++ b/.icons/aws.svg @@ -1,13 +1 @@ - - - - - - - - - - \ No newline at end of file + \ No newline at end of file diff --git a/.icons/azure.svg b/.icons/azure.svg index 645ac663..5e96a718 100644 --- a/.icons/azure.svg +++ b/.icons/azure.svg @@ -1,23 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + \ No newline at end of file diff --git a/.icons/box-emoji.svg b/.icons/box-emoji.svg index a2595599..dc8ed58c 100644 --- a/.icons/box-emoji.svg +++ b/.icons/box-emoji.svg @@ -1,27 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/.icons/claude.svg b/.icons/claude.svg index 998fb0d5..22dcf464 100644 --- a/.icons/claude.svg +++ b/.icons/claude.svg @@ -1,4 +1 @@ - - - - + \ No newline at end of file diff --git a/.icons/cloud-devops.svg b/.icons/cloud-devops.svg new file mode 100644 index 00000000..659dcc52 --- /dev/null +++ b/.icons/cloud-devops.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/.icons/cmux.svg b/.icons/cmux.svg index 95b56bb0..012bff21 100644 --- a/.icons/cmux.svg +++ b/.icons/cmux.svg @@ -1,47 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/.icons/coder.svg b/.icons/coder.svg index 60d7eff6..5db19df6 100644 --- a/.icons/coder.svg +++ b/.icons/coder.svg @@ -1,4 +1 @@ - - - - + \ No newline at end of file diff --git a/.icons/copyparty.svg b/.icons/copyparty.svg index 2c4f0d04..bb8cf89e 100644 --- a/.icons/copyparty.svg +++ b/.icons/copyparty.svg @@ -1,210 +1 @@ - - - copyparty_logo - - - - - - - - - - - - image/svg+xml - - copyparty_logo - github.com/9001/copyparty - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +copyparty_logo \ No newline at end of file diff --git a/.icons/desktop.svg b/.icons/desktop.svg index 77d231ce..2dee883a 100644 --- a/.icons/desktop.svg +++ b/.icons/desktop.svg @@ -1,5 +1 @@ - - - - - + \ No newline at end of file diff --git a/.icons/devcontainers.svg b/.icons/devcontainers.svg index fb0443bd..72c2fc32 100644 --- a/.icons/devcontainers.svg +++ b/.icons/devcontainers.svg @@ -1,2 +1 @@ - -file_type_devcontainer \ No newline at end of file +file_type_devcontainer \ No newline at end of file diff --git a/.icons/digital-ocean.svg b/.icons/digital-ocean.svg index 6f10b237..7a3e8462 100644 --- a/.icons/digital-ocean.svg +++ b/.icons/digital-ocean.svg @@ -1,10 +1 @@ - - - - - - - - - - + \ No newline at end of file diff --git a/.icons/docker.svg b/.icons/docker.svg index 78e549ef..2a174a89 100644 --- a/.icons/docker.svg +++ b/.icons/docker.svg @@ -1,3 +1 @@ - - - + \ No newline at end of file diff --git a/.icons/dotfiles.svg b/.icons/dotfiles.svg index c57ef859..bc582549 100644 --- a/.icons/dotfiles.svg +++ b/.icons/dotfiles.svg @@ -1,10 +1 @@ - - - - - - - - + \ No newline at end of file diff --git a/.icons/electric-plug-emoji.svg b/.icons/electric-plug-emoji.svg index 15743822..d8b03f26 100644 --- a/.icons/electric-plug-emoji.svg +++ b/.icons/electric-plug-emoji.svg @@ -1 +1 @@ -🔌 \ No newline at end of file +🔌 \ No newline at end of file diff --git a/.icons/exoscale.svg b/.icons/exoscale.svg index c56a6154..dc4f8462 100644 --- a/.icons/exoscale.svg +++ b/.icons/exoscale.svg @@ -1 +1 @@ -Artboard 1 \ No newline at end of file +Artboard 1 \ No newline at end of file diff --git a/.icons/filebrowser.svg b/.icons/filebrowser.svg index 5e78eccf..42bcc8db 100644 --- a/.icons/filebrowser.svg +++ b/.icons/filebrowser.svg @@ -1,147 +1 @@ - -image/svg+xml - - - - - \ No newline at end of file + \ No newline at end of file diff --git a/.icons/fleet.svg b/.icons/fleet.svg index ba910eb9..494c2406 100644 --- a/.icons/fleet.svg +++ b/.icons/fleet.svg @@ -1,60 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/.icons/folder.svg b/.icons/folder.svg index b718dea5..1759bfc4 100644 --- a/.icons/folder.svg +++ b/.icons/folder.svg @@ -1 +1 @@ - + \ No newline at end of file diff --git a/.icons/gateway.svg b/.icons/gateway.svg index b68e9490..238b4dda 100644 --- a/.icons/gateway.svg +++ b/.icons/gateway.svg @@ -1,64 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/.icons/gemini.svg b/.icons/gemini.svg index f1cf3575..33a088c1 100644 --- a/.icons/gemini.svg +++ b/.icons/gemini.svg @@ -1 +1 @@ -Gemini \ No newline at end of file +Gemini \ No newline at end of file diff --git a/.icons/git.svg b/.icons/git.svg index ceef1163..831d3100 100644 --- a/.icons/git.svg +++ b/.icons/git.svg @@ -1,3 +1 @@ - - - \ No newline at end of file + \ No newline at end of file diff --git a/.icons/goose.svg b/.icons/goose.svg index cbbe8419..e73ebc35 100644 --- a/.icons/goose.svg +++ b/.icons/goose.svg @@ -1,4 +1 @@ - - - - + \ No newline at end of file diff --git a/.icons/hetzner.svg b/.icons/hetzner.svg new file mode 100644 index 00000000..24ac242e --- /dev/null +++ b/.icons/hetzner.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/.icons/jetbrains.svg b/.icons/jetbrains.svg index b281f962..8a0d73e9 100644 --- a/.icons/jetbrains.svg +++ b/.icons/jetbrains.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/.icons/jfrog.svg b/.icons/jfrog.svg index e137700c..ad0050a3 100644 --- a/.icons/jfrog.svg +++ b/.icons/jfrog.svg @@ -1,3 +1 @@ - - - + \ No newline at end of file diff --git a/.icons/jupyter.svg b/.icons/jupyter.svg index 38350dfe..9beddb14 100644 --- a/.icons/jupyter.svg +++ b/.icons/jupyter.svg @@ -1,14 +1 @@ - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/.icons/kasmvnc.svg b/.icons/kasmvnc.svg index 958f2832..33d25197 100644 --- a/.icons/kasmvnc.svg +++ b/.icons/kasmvnc.svg @@ -1,7 +1 @@ - - - - - - - \ No newline at end of file + \ No newline at end of file diff --git a/.icons/kiro.svg b/.icons/kiro.svg index e132ead1..823400d2 100644 --- a/.icons/kiro.svg +++ b/.icons/kiro.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/.icons/kubernetes.svg b/.icons/kubernetes.svg index 42bb9229..7f07a7f5 100644 --- a/.icons/kubernetes.svg +++ b/.icons/kubernetes.svg @@ -1,4 +1 @@ - - - - + \ No newline at end of file diff --git a/.icons/lxc.svg b/.icons/lxc.svg index 0e8e118f..13cd5c59 100644 --- a/.icons/lxc.svg +++ b/.icons/lxc.svg @@ -1,21 +1 @@ - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + \ No newline at end of file diff --git a/.icons/mux.svg b/.icons/mux.svg index 95b56bb0..3ee3276d 100644 --- a/.icons/mux.svg +++ b/.icons/mux.svg @@ -1,47 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/.icons/nextflow.svg b/.icons/nextflow.svg index bcc10553..e9a38072 100644 --- a/.icons/nextflow.svg +++ b/.icons/nextflow.svg @@ -1,6 +1 @@ - - - - - - + \ No newline at end of file diff --git a/.icons/nexus-repository.svg b/.icons/nexus-repository.svg index ca135cd5..45a2bf60 100644 --- a/.icons/nexus-repository.svg +++ b/.icons/nexus-repository.svg @@ -1,5 +1 @@ - - - - \ No newline at end of file + \ No newline at end of file diff --git a/.icons/nomad.svg b/.icons/nomad.svg index b4dc91c7..ff7d00d0 100644 --- a/.icons/nomad.svg +++ b/.icons/nomad.svg @@ -1,2 +1 @@ - - + \ No newline at end of file diff --git a/.icons/openai.svg b/.icons/openai.svg index ba36fc2a..ffa802de 100644 --- a/.icons/openai.svg +++ b/.icons/openai.svg @@ -1,15 +1 @@ - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/.icons/opencode.svg b/.icons/opencode.svg index b79c7332..790fbc49 100644 --- a/.icons/opencode.svg +++ b/.icons/opencode.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/.icons/openwebui.svg b/.icons/openwebui.svg index 06f67645..b4ac79b0 100644 --- a/.icons/openwebui.svg +++ b/.icons/openwebui.svg @@ -1,5 +1 @@ - - - - - + \ No newline at end of file diff --git a/.icons/perplexica.svg b/.icons/perplexica.svg new file mode 100644 index 00000000..e1e0226f --- /dev/null +++ b/.icons/perplexica.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/.icons/personalize.svg b/.icons/personalize.svg index 76bc6780..3e402bc1 100644 --- a/.icons/personalize.svg +++ b/.icons/personalize.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/.icons/pgadmin.svg b/.icons/pgadmin.svg index 9fa5c4d5..f75dee65 100644 --- a/.icons/pgadmin.svg +++ b/.icons/pgadmin.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/.icons/positron.svg b/.icons/positron.svg index 590372e4..6d1282f5 100644 --- a/.icons/positron.svg +++ b/.icons/positron.svg @@ -1,12 +1 @@ - - - - - - - - - - - - + \ No newline at end of file diff --git a/.icons/proxmox.svg b/.icons/proxmox.svg index c18256e2..5758f8c5 100755 --- a/.icons/proxmox.svg +++ b/.icons/proxmox.svg @@ -1,137 +1 @@ - - - - - - - - image/svg+xml - - - - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/.icons/rdp.svg b/.icons/rdp.svg index a6722326..1e83ccdf 100644 --- a/.icons/rdp.svg +++ b/.icons/rdp.svg @@ -1,35 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/.icons/rustdesk.svg b/.icons/rustdesk.svg index 6c801233..fdc4e04c 100644 --- a/.icons/rustdesk.svg +++ b/.icons/rustdesk.svg @@ -1,5 +1 @@ - - - - - + \ No newline at end of file diff --git a/.icons/scaleway.svg b/.icons/scaleway.svg new file mode 100644 index 00000000..488c90c1 --- /dev/null +++ b/.icons/scaleway.svg @@ -0,0 +1 @@ +Scaleway icon \ No newline at end of file diff --git a/.icons/slack.svg b/.icons/slack.svg index fb55f724..1f9731dd 100644 --- a/.icons/slack.svg +++ b/.icons/slack.svg @@ -1,6 +1 @@ - - - - - - + \ No newline at end of file diff --git a/.icons/sourcegraph-amp.svg b/.icons/sourcegraph-amp.svg index 83777bd2..5466803a 100644 --- a/.icons/sourcegraph-amp.svg +++ b/.icons/sourcegraph-amp.svg @@ -1,5 +1 @@ - - - - - + \ No newline at end of file diff --git a/.icons/tasks.svg b/.icons/tasks.svg index 67088c42..0ab7ea7f 100644 --- a/.icons/tasks.svg +++ b/.icons/tasks.svg @@ -1,5 +1 @@ - - - - - + \ No newline at end of file diff --git a/.icons/vault.svg b/.icons/vault.svg index c90525cd..d9c882a1 100644 --- a/.icons/vault.svg +++ b/.icons/vault.svg @@ -1,2 +1 @@ - - \ No newline at end of file + \ No newline at end of file diff --git a/.icons/vsphere.svg b/.icons/vsphere.svg new file mode 100644 index 00000000..dd94f13d --- /dev/null +++ b/.icons/vsphere.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/.icons/windsurf.svg b/.icons/windsurf.svg index 2e4e4e49..f8164466 100644 --- a/.icons/windsurf.svg +++ b/.icons/windsurf.svg @@ -1,3 +1 @@ - - - + \ No newline at end of file diff --git a/.icons/zed.svg b/.icons/zed.svg index 06b5c183..d5910ecc 100644 --- a/.icons/zed.svg +++ b/.icons/zed.svg @@ -1,3 +1 @@ - - - + \ No newline at end of file diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 00000000..9881ee77 --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1,2 @@ +# GitHub Actions Workflow Owners +.github/ @jdomeracki-coder diff --git a/cmd/.icons/docker.svg b/cmd/.icons/docker.svg new file mode 100644 index 00000000..2a174a89 --- /dev/null +++ b/cmd/.icons/docker.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/cmd/.icons/goose.svg b/cmd/.icons/goose.svg new file mode 100644 index 00000000..e73ebc35 --- /dev/null +++ b/cmd/.icons/goose.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/cmd/readmevalidation/codermodules_test.go b/cmd/readmevalidation/codermodules_test.go index 194a861e..e9e88bfc 100644 --- a/cmd/readmevalidation/codermodules_test.go +++ b/cmd/readmevalidation/codermodules_test.go @@ -1,22 +1,117 @@ package main import ( - _ "embed" + "os" + "path/filepath" "testing" ) -//go:embed testSamples/sampleReadmeBody.md -var testBody string +type readmeTestCase struct { + filePath string + shouldPass bool +} -func TestValidateCoderResourceReadmeBody(t *testing.T) { +func loadTestCases(t *testing.T, dir string, shouldPass bool) []readmeTestCase { + t.Helper() + files, err := os.ReadDir(dir) + if err != nil { + t.Fatalf("Failed to read directory %s: %v", dir, err) + } + + var testCases []readmeTestCase + for _, file := range files { + testCases = append(testCases, readmeTestCase{ + filePath: filepath.Join(dir, file.Name()), + shouldPass: shouldPass, + }) + } + return testCases +} + +func TestValidateModuleReadmes(t *testing.T) { t.Parallel() - t.Run("Parses a valid README body with zero issues", func(t *testing.T) { - t.Parallel() + testCases := append( + loadTestCases(t, "testSamples/modules/pass", true), + loadTestCases(t, "testSamples/modules/fail", false)..., + ) - errs := validateCoderModuleReadmeBody(testBody) - for _, e := range errs { - t.Error(e) - } - }) + for _, tc := range testCases { + t.Run(tc.filePath, func(t *testing.T) { + t.Parallel() + + content, err := os.ReadFile(tc.filePath) + if err != nil { + t.Fatalf("Failed to read file: %v", err) + } + + rm := readme{ + filePath: tc.filePath, + rawText: string(content), + } + + resource, errs := parseCoderResourceReadme("modules", rm) + if len(errs) != 0 { + if tc.shouldPass { + for _, e := range errs { + t.Errorf("Unexpected parsing error: %v", e) + } + } + return + } + + validationErrs := validateCoderModuleReadme(resource) + if tc.shouldPass && len(validationErrs) != 0 { + for _, e := range validationErrs { + t.Errorf("Unexpected validation error: %v", e) + } + } else if !tc.shouldPass && len(validationErrs) == 0 { + t.Error("Expected validation errors but got none") + } + }) + } +} + +func TestValidateTemplateReadmes(t *testing.T) { + t.Parallel() + + testCases := append( + loadTestCases(t, "testSamples/templates/pass", true), + loadTestCases(t, "testSamples/templates/fail", false)..., + ) + + for _, tc := range testCases { + t.Run(tc.filePath, func(t *testing.T) { + t.Parallel() + + content, err := os.ReadFile(tc.filePath) + if err != nil { + t.Fatalf("Failed to read file: %v", err) + } + + rm := readme{ + filePath: tc.filePath, + rawText: string(content), + } + + resource, errs := parseCoderResourceReadme("templates", rm) + if len(errs) != 0 { + if tc.shouldPass { + for _, e := range errs { + t.Errorf("Unexpected parsing error: %v", e) + } + } + return + } + + validationErrs := validateCoderModuleReadme(resource) + if tc.shouldPass && len(validationErrs) != 0 { + for _, e := range validationErrs { + t.Errorf("Unexpected validation error: %v", e) + } + } else if !tc.shouldPass && len(validationErrs) == 0 { + t.Error("Expected validation errors but got none") + } + }) + } } diff --git a/cmd/readmevalidation/coderresources.go b/cmd/readmevalidation/coderresources.go index 818d73c8..ee723265 100644 --- a/cmd/readmevalidation/coderresources.go +++ b/cmd/readmevalidation/coderresources.go @@ -82,33 +82,43 @@ func validateCoderResourceDescription(description string) error { return nil } -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 isPermittedRelativeURL(checkURL string, readmeFilePath string) error { + // Icon URLs must reference the top-level .icons directory + expectedPrefix := "../../../../.icons/" + if !strings.HasPrefix(checkURL, expectedPrefix) { + return xerrors.Errorf("icon URL %q must reference the top-level .icons directory using %q", checkURL, expectedPrefix) + } + + // Resolve the path relative to the README file and check if it exists + readmeDir := path.Dir(readmeFilePath) + resolvedPath := path.Join(readmeDir, checkURL) + + if _, err := os.Stat(resolvedPath); err != nil { + if os.IsNotExist(err) { + return xerrors.Errorf("icon file does not exist at resolved path %q (referenced as %q)", resolvedPath, checkURL) + } + return xerrors.Errorf("error checking icon file at %q: %v", resolvedPath, err) + } + + return nil } -func validateCoderResourceIconURL(iconURL string) []error { +func validateCoderResourceIconURL(iconURL string, filePath string) []error { if iconURL == "" { return []error{xerrors.New("icon URL cannot be empty")} } var 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 { - errs = append(errs, xerrors.New("absolute icon URL is not correctly formatted")) - } - if strings.Contains(iconURL, "?") { - errs = append(errs, xerrors.New("icon URLs cannot contain query parameters")) - } + // Reject absolute HTTP/HTTPS URLs - all icons must be local to the repository + if strings.HasPrefix(iconURL, "http://") || strings.HasPrefix(iconURL, "https://") { + errs = append(errs, xerrors.Errorf("icon URL must reference the top-level .icons directory, not an absolute URL %q", iconURL)) return errs } - // 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)) + // Validate that the icon references ../../../../.icons/ and exists + if err := isPermittedRelativeURL(iconURL, filePath); err != nil { + errs = append(errs, err) } return errs @@ -153,7 +163,7 @@ func validateCoderResourceFrontmatter(resourceType string, filePath string, fm c errs = append(errs, addFilePathToError(filePath, err)) } - for _, err := range validateCoderResourceIconURL(fm.IconURL) { + for _, err := range validateCoderResourceIconURL(fm.IconURL, filePath) { errs = append(errs, addFilePathToError(filePath, err)) } for _, err := range validateSupportedOperatingSystems(fm.OperatingSystems) { diff --git a/cmd/readmevalidation/testSamples/modules/fail/absoluteIconPath.md b/cmd/readmevalidation/testSamples/modules/fail/absoluteIconPath.md new file mode 100644 index 00000000..d68264b0 --- /dev/null +++ b/cmd/readmevalidation/testSamples/modules/fail/absoluteIconPath.md @@ -0,0 +1,22 @@ +--- +display_name: "Goose" +description: "Run the Goose agent in your workspace to generate code and perform tasks" +icon: "https://github.com/coder/registry/pull/599.svg" +verified: false +tags: ["ai", "agent"] +--- + +# Goose + +Run the [Goose](https://block.github.io/goose/) agent in your workspace to generate code and perform tasks. + +```tf +module "goose" { + source = "registry.coder.com/coder/goose/coder" + version = "1.0.31" + agent_id = coder_agent.main.id + folder = "/home/coder" + install_goose = true + goose_version = "v1.0.16" +} +``` diff --git a/cmd/readmevalidation/testSamples/modules/fail/wrongPathFormat.md b/cmd/readmevalidation/testSamples/modules/fail/wrongPathFormat.md new file mode 100644 index 00000000..e9c671c4 --- /dev/null +++ b/cmd/readmevalidation/testSamples/modules/fail/wrongPathFormat.md @@ -0,0 +1,19 @@ +--- +display_name: "Wrong Path" +description: "Test module with wrong icon path format" +icon: "../../../../.icons/invalid.svg" +verified: false +tags: ["test"] +--- + +# Wrong Path + +This should fail validation. + +```tf +module "test" { + source = "registry.coder.com/coder/test/coder" + version = "1.0.0" + agent_id = coder_agent.main.id +} +``` diff --git a/cmd/readmevalidation/testSamples/modules/pass/sampleModuleReadme.md b/cmd/readmevalidation/testSamples/modules/pass/sampleModuleReadme.md new file mode 100644 index 00000000..b0db1f26 --- /dev/null +++ b/cmd/readmevalidation/testSamples/modules/pass/sampleModuleReadme.md @@ -0,0 +1,56 @@ +--- +display_name: "Docker Container" +description: "Develop in a container on a Docker host" +icon: "../../../../.icons/docker.svg" +verified: true +tags: ["docker", "container"] +supported_os: ["linux", "macos"] +--- + +# Docker Container + +Develop in a Docker container on a remote Docker host. + +```tf +terraform { + required_providers { + coder = { + source = "coder/coder" + version = "~> 1.0" + } + docker = { + source = "kreuzwerker/docker" + version = "~> 3.0" + } + } +} + +provider "docker" {} + +provider "coder" {} + +data "coder_workspace" "me" {} + +resource "coder_agent" "main" { + os = "linux" + arch = "amd64" +} + +resource "docker_container" "workspace" { + image = "codercom/enterprise-base:ubuntu" + name = "coder-${data.coder_workspace.me.owner}-${data.coder_workspace.me.name}" + + env = ["CODER_AGENT_TOKEN=${coder_agent.main.token}"] +} +``` + +## Getting Started + +This template creates a Docker container on your Docker host. You'll need: + +- A Docker host accessible from your Coder deployment +- The Docker provider configured with appropriate credentials + +## Customization + +You can customize the container image, resources, and configuration to match your needs. diff --git a/cmd/readmevalidation/testSamples/sampleReadmeBody.md b/cmd/readmevalidation/testSamples/sampleReadmeBody.md deleted file mode 100644 index b96662af..00000000 --- a/cmd/readmevalidation/testSamples/sampleReadmeBody.md +++ /dev/null @@ -1,121 +0,0 @@ -# Goose - -Run the [Goose](https://block.github.io/goose/) agent in your workspace to generate code and perform tasks. - -```tf -module "goose" { - source = "registry.coder.com/coder/goose/coder" - version = "1.0.31" - agent_id = coder_agent.main.id - folder = "/home/coder" - install_goose = true - goose_version = "v1.0.16" -} -``` - -## Prerequisites - -- `screen` must be installed in your workspace to run Goose in the background -- You must add the [Coder Login](https://registry.coder.com/modules/coder-login) module to your template - -The `codercom/oss-dogfood:latest` container image can be used for testing on container-based workspaces. - -## Examples - -Your workspace must have `screen` installed to use this. - -### Run in the background and report tasks (Experimental) - -> This functionality is in early access as of Coder v2.21 and is still evolving. -> For now, we recommend testing it in a demo or staging environment, -> rather than deploying to production -> -> Learn more in [the Coder documentation](https://coder.com/docs/tutorials/ai-agents) -> -> Join our [Discord channel](https://discord.gg/coder) or -> [contact us](https://coder.com/contact) to get help or share feedback. - -```tf -module "coder-login" { - count = data.coder_workspace.me.start_count - source = "registry.coder.com/coder/coder-login/coder" - version = "1.0.15" - agent_id = coder_agent.main.id -} - -variable "anthropic_api_key" { - type = string - description = "The Anthropic API key" - sensitive = true -} - -data "coder_parameter" "ai_prompt" { - type = "string" - name = "AI Prompt" - default = "" - description = "Write a prompt for Goose" - mutable = true -} - -# Set the prompt and system prompt for Goose via environment variables -resource "coder_agent" "main" { - # ... - env = { - GOOSE_SYSTEM_PROMPT = <<-EOT - You are a helpful assistant that can help write code. - - Run all long running tasks (e.g. npm run dev) in the background and not in the foreground. - - Periodically check in on background tasks. - - Notify Coder of the status of the task before and after your steps. - EOT - GOOSE_TASK_PROMPT = data.coder_parameter.ai_prompt.value - - # An API key is required for experiment_auto_configure - # See https://block.github.io/goose/docs/getting-started/providers - ANTHROPIC_API_KEY = var.anthropic_api_key # or use a coder_parameter - } -} - -module "goose" { - count = data.coder_workspace.me.start_count - source = "registry.coder.com/coder/goose/coder" - version = "1.0.31" - agent_id = coder_agent.main.id - folder = "/home/coder" - install_goose = true - goose_version = "v1.0.16" - - # Enable experimental features - experiment_report_tasks = true - - # Run Goose in the background - experiment_use_screen = true - - # Avoid configuring Goose manually - experiment_auto_configure = true - - # Required for experiment_auto_configure - experiment_goose_provider = "anthropic" - experiment_goose_model = "claude-3-5-sonnet-latest" -} -``` - -## Run standalone - -Run Goose as a standalone app in your workspace. This will install Goose and run it directly without using screen or any task reporting to the Coder UI. - -```tf -module "goose" { - source = "registry.coder.com/coder/goose/coder" - version = "1.0.31" - agent_id = coder_agent.main.id - folder = "/home/coder" - install_goose = true - goose_version = "v1.0.16" - - # Icon is not available in Coder v2.20 and below, so we'll use a custom icon URL - icon = "https://raw.githubusercontent.com/block/goose/refs/heads/main/ui/desktop/src/images/icon.svg" -} -``` diff --git a/cmd/readmevalidation/testSamples/templates/fail/absoluteIconPath.md b/cmd/readmevalidation/testSamples/templates/fail/absoluteIconPath.md new file mode 100644 index 00000000..879a2a83 --- /dev/null +++ b/cmd/readmevalidation/testSamples/templates/fail/absoluteIconPath.md @@ -0,0 +1,27 @@ +--- +display_name: "Docker Container" +description: "Develop in a container on a Docker host" +icon: "https://github.com/coder/registry/pull/599.jpeg" +verified: true +tags: ["docker", "container"] +supported_os: ["linux", "macos"] +--- + +# Docker Container + +Develop in a Docker container on a remote Docker host. + +```tf +terraform { + required_providers { + coder = { + source = "coder/coder" + version = "~> 1.0" + } + docker = { + source = "kreuzwerker/docker" + version = "~> 3.0" + } + } +} +``` diff --git a/cmd/readmevalidation/testSamples/templates/fail/wrongPathFormat.md b/cmd/readmevalidation/testSamples/templates/fail/wrongPathFormat.md new file mode 100644 index 00000000..7078cfb8 --- /dev/null +++ b/cmd/readmevalidation/testSamples/templates/fail/wrongPathFormat.md @@ -0,0 +1,20 @@ +--- +display_name: "Docker Container" +description: "Develop in a container on a Docker host" +icon: "../../../../.icons/invalid.svg" +verified: true +tags: ["docker", "container"] +supported_os: ["linux", "macos"] +--- + +# Wrong Path + +This should fail validation. + +```tf +module "test" { + source = "registry.coder.com/coder/test/coder" + version = "1.0.0" + agent_id = coder_agent.main.id +} +``` diff --git a/cmd/readmevalidation/testSamples/templates/pass/sampleTemplateReadme.md b/cmd/readmevalidation/testSamples/templates/pass/sampleTemplateReadme.md new file mode 100644 index 00000000..b0db1f26 --- /dev/null +++ b/cmd/readmevalidation/testSamples/templates/pass/sampleTemplateReadme.md @@ -0,0 +1,56 @@ +--- +display_name: "Docker Container" +description: "Develop in a container on a Docker host" +icon: "../../../../.icons/docker.svg" +verified: true +tags: ["docker", "container"] +supported_os: ["linux", "macos"] +--- + +# Docker Container + +Develop in a Docker container on a remote Docker host. + +```tf +terraform { + required_providers { + coder = { + source = "coder/coder" + version = "~> 1.0" + } + docker = { + source = "kreuzwerker/docker" + version = "~> 3.0" + } + } +} + +provider "docker" {} + +provider "coder" {} + +data "coder_workspace" "me" {} + +resource "coder_agent" "main" { + os = "linux" + arch = "amd64" +} + +resource "docker_container" "workspace" { + image = "codercom/enterprise-base:ubuntu" + name = "coder-${data.coder_workspace.me.owner}-${data.coder_workspace.me.name}" + + env = ["CODER_AGENT_TOKEN=${coder_agent.main.token}"] +} +``` + +## Getting Started + +This template creates a Docker container on your Docker host. You'll need: + +- A Docker host accessible from your Coder deployment +- The Docker provider configured with appropriate credentials + +## Customization + +You can customize the container image, resources, and configuration to match your needs. diff --git a/registry/AJ0070/.images/avatar.jpeg b/registry/AJ0070/.images/avatar.jpeg index d47bfb80..568decd2 100644 Binary files a/registry/AJ0070/.images/avatar.jpeg and b/registry/AJ0070/.images/avatar.jpeg differ diff --git a/registry/AJ0070/.images/avatar.png b/registry/AJ0070/.images/avatar.png index 8f0ff917..c62ffc9d 100644 Binary files a/registry/AJ0070/.images/avatar.png and b/registry/AJ0070/.images/avatar.png differ diff --git a/registry/Excellencedev/.images/avatar.png b/registry/Excellencedev/.images/avatar.png new file mode 100644 index 00000000..e883c2cf Binary files /dev/null and b/registry/Excellencedev/.images/avatar.png differ diff --git a/registry/Excellencedev/README.md b/registry/Excellencedev/README.md new file mode 100644 index 00000000..13200875 --- /dev/null +++ b/registry/Excellencedev/README.md @@ -0,0 +1,7 @@ +--- +display_name: "Excellencedev" +bio: "Love to contribute" +avatar: "./.images/avatar.png" +support_email: "ademiluyisuccessandexcellence@gmail.com" +status: "community" +--- diff --git a/registry/Excellencedev/templates/hetzner-linux/README.md b/registry/Excellencedev/templates/hetzner-linux/README.md new file mode 100644 index 00000000..60575e67 --- /dev/null +++ b/registry/Excellencedev/templates/hetzner-linux/README.md @@ -0,0 +1,32 @@ +--- +display_name: Hetzner Cloud Server +description: Provision Hetzner Cloud servers as Coder workspaces +icon: ../../../../.icons/hetzner.svg +tags: [vm, linux, hetzner] +--- + +# Remote Development on Hetzner Cloud (Linux) + +Provision Hetzner Cloud servers as [Coder workspaces](https://coder.com/docs/workspaces) with this example template. + +> [!WARNING] +> **Workspace Storage Persistence:** When a workspace is stopped, the Hetzner Cloud server instance is stopped but your home volume and stored data persist. This means your files and data remain intact when you resume the workspace. + +> [!IMPORTANT] +> **Volume Management & Costs:** Hetzner Cloud volumes persist even when workspaces are stopped and will continue to incur storage costs (â‚Ŧ0.0476/GB/month). Volumes are only automatically deleted when the workspace is completely deleted. Monitor your volumes in the [Hetzner Cloud Console](https://console.hetzner.cloud/) to manage costs effectively. + +## Prerequisites + +To deploy workspaces as Hetzner Cloud servers, you'll need: + +- Hetzner Cloud [API token](https://console.hetzner.cloud/projects) (create under Security > API Tokens) + +### Authentication + +This template assumes that the Coder Provisioner is run in an environment that is authenticated with Hetzner Cloud. + +Obtain a Hetzner Cloud API token from your [Hetzner Cloud Console](https://console.hetzner.cloud/projects) and provide it as the `hcloud_token` variable when creating a workspace. +For more authentication options, see the [Terraform provider documentation](https://registry.terraform.io/providers/hetznercloud/hcloud/latest/docs#authentication). + +> [!NOTE] +> This template is designed to be a starting point. Edit the Terraform to extend the template to support your use case. diff --git a/registry/Excellencedev/templates/hetzner-linux/cloud-config.yaml.tftpl b/registry/Excellencedev/templates/hetzner-linux/cloud-config.yaml.tftpl new file mode 100644 index 00000000..73d4897a --- /dev/null +++ b/registry/Excellencedev/templates/hetzner-linux/cloud-config.yaml.tftpl @@ -0,0 +1,62 @@ +#cloud-config +users: + - name: ${username} + sudo: ["ALL=(ALL) NOPASSWD:ALL"] + groups: sudo + shell: /bin/bash +packages: + - git +%{ if home_volume_label != "" ~} +fs_setup: + - device: /dev/disk/by-id/scsi-0HC_Volume_${volume_id} + filesystem: ext4 + label: ${home_volume_label} + overwrite: false # This prevents reformatting the disk on every boot + +mounts: + - [ + "/dev/disk/by-id/scsi-0HC_Volume_${volume_id}", + "/home/${username}", + ext4, + "defaults,uid=1000,gid=1000", + ] +%{ endif ~} +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: +%{ if home_volume_label != "" ~} + - | + until [ -e /dev/disk/by-id/scsi-0HC_Volume_${volume_id} ]; do + echo "Waiting for volume device..." + sleep 2 + done +%{ endif ~} + - mount -a + - chown ${username}:${username} /home/${username} + - systemctl enable coder-agent + - systemctl start coder-agent \ No newline at end of file diff --git a/registry/Excellencedev/templates/hetzner-linux/main.tf b/registry/Excellencedev/templates/hetzner-linux/main.tf new file mode 100644 index 00000000..03e01a10 --- /dev/null +++ b/registry/Excellencedev/templates/hetzner-linux/main.tf @@ -0,0 +1,224 @@ +terraform { + required_providers { + hcloud = { + source = "hetznercloud/hcloud" + } + coder = { + source = "coder/coder" + } + http = { + source = "hashicorp/http" + version = "~> 3.0" + } + } +} + +variable "hcloud_token" { + sensitive = true +} + +provider "hcloud" { + token = var.hcloud_token +} + +data "http" "hcloud_locations" { + url = "https://api.hetzner.cloud/v1/locations" + + request_headers = { + Authorization = "Bearer ${var.hcloud_token}" + Accept = "application/json" + } +} + +data "http" "hcloud_server_types" { + url = "https://api.hetzner.cloud/v1/server_types" + + request_headers = { + Authorization = "Bearer ${var.hcloud_token}" + Accept = "application/json" + } +} + +# Available locations: https://docs.hetzner.com/cloud/general/locations/ +data "coder_parameter" "hcloud_location" { + name = "hcloud_location" + display_name = "Hetzner Location" + description = "Select the Hetzner Cloud location for your workspace." + type = "string" + default = "fsn1" + + dynamic "option" { + for_each = local.hcloud_locations + content { + name = format( + "%s (%s, %s)", + upper(option.value.name), + option.value.city, + option.value.country + ) + value = option.value.name + } + } +} + +# Available server types: https://docs.hetzner.com/cloud/servers/overview/ +data "coder_parameter" "hcloud_server_type" { + name = "hcloud_server_type" + display_name = "Hetzner Server Type" + description = "Select the Hetzner Cloud server type for your workspace." + type = "string" + + dynamic "option" { + for_each = local.hcloud_server_type_options_for_selected_location + content { + name = option.value.name + value = option.value.value + } + } +} + +resource "hcloud_server" "dev" { + count = data.coder_workspace.me.start_count + name = "coder-${data.coder_workspace.me.name}-dev" + image = "ubuntu-24.04" + server_type = data.coder_parameter.hcloud_server_type.value + location = data.coder_parameter.hcloud_location.value + public_net { + ipv4_enabled = true + ipv6_enabled = true + } + user_data = templatefile("cloud-config.yaml.tftpl", { + username = lower(data.coder_workspace_owner.me.name) + home_volume_label = "coder-${data.coder_workspace.me.id}-home" + volume_id = hcloud_volume.home_volume.id + init_script = base64encode(coder_agent.main.init_script) + coder_agent_token = coder_agent.main.token + }) + labels = { + "coder_workspace_name" = data.coder_workspace.me.name, + "coder_workspace_owner" = data.coder_workspace_owner.me.name, + } +} + +resource "hcloud_volume" "home_volume" { + name = "coder-${data.coder_workspace.me.id}-home" + size = data.coder_parameter.home_volume_size.value + location = data.coder_parameter.hcloud_location.value + labels = { + "coder_workspace_name" = data.coder_workspace.me.name, + "coder_workspace_owner" = data.coder_workspace_owner.me.name, + } +} + +resource "hcloud_volume_attachment" "home_volume_attachment" { + count = data.coder_workspace.me.start_count + volume_id = hcloud_volume.home_volume.id + server_id = hcloud_server.dev[count.index].id + automount = false +} + +locals { + username = lower(data.coder_workspace_owner.me.name) + + # -------------------- + # Locations + # -------------------- + hcloud_locations = [ + for loc in jsondecode(data.http.hcloud_locations.response_body).locations : { + name = loc.name + city = loc.city + country = loc.country + } + ] + + # -------------------- + # Server Types + # -------------------- + hcloud_server_types = { + for st in jsondecode(data.http.hcloud_server_types.response_body).server_types : + st.name => { + cores = st.cores + memory_gb = st.memory + disk_gb = st.disk + locations = [for l in st.locations : l.name] + deprecated = st.deprecated + } + if st.deprecated == false + } + + hcloud_server_type_options_for_selected_location = [ + for name, meta in local.hcloud_server_types : { + name = format( + "%s (%d vCPU, %dGB RAM, %dGB)", + upper(name), + meta.cores, + meta.memory_gb, + meta.disk_gb + ) + value = name + } + if contains( + meta.locations, + data.coder_parameter.hcloud_location.value + ) + ] +} + +data "coder_provisioner" "me" {} + +provider "coder" {} + +data "coder_workspace" "me" {} + +data "coder_workspace_owner" "me" {} + +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 # Adjust the max size as needed + } +} + +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/${local.username}" + } +} + +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 +} diff --git a/registry/anis/.images/avatar.png b/registry/anis/.images/avatar.png new file mode 100644 index 00000000..3fb22a10 Binary files /dev/null and b/registry/anis/.images/avatar.png differ diff --git a/registry/anis/README.md b/registry/anis/README.md new file mode 100644 index 00000000..20d5e708 --- /dev/null +++ b/registry/anis/README.md @@ -0,0 +1,12 @@ +--- +display_name: "Anis Khalfallah" +bio: "DevOps Engineer" +github: "aniskhalfallah" +avatar: "./.images/avatar.png" +linkedin: "https://www.linkedin.com/in/khalfallah-anis/" +status: "community" +--- + +# Anis KHALFALLAH + +DevOps Engineer diff --git a/registry/anis/templates/vmware-linux/README.md b/registry/anis/templates/vmware-linux/README.md new file mode 100644 index 00000000..e56957fc --- /dev/null +++ b/registry/anis/templates/vmware-linux/README.md @@ -0,0 +1,81 @@ +--- +display_name: VMware vSphere VM (Linux) +description: Provision VMware vSphere virtual machines as Coder workspaces +icon: ../../../../.icons/vsphere.svg +verified: false +tags: [vm, linux, vmware, vsphere] +--- + +# Summary + +Provision VMware vSphere virtual machines as [Coder workspaces](https://coder.com/docs/workspaces) using this Terraform template. + +## Prerequisites + +To deploy Coder workspaces on VMware vSphere, you'll need the following: + +### vSphere Resources + +Before deploying, ensure your vSphere environment has: + +- A **vSphere Datacenter** already created +- A **Compute Cluster** within that datacenter +- A **Datastore** with sufficient storage capacity +- A **Network** (port group) accessible by VMs +- A **VM Template** with Ubuntu and cloud-init configured + +### VM Template Requirements + +Your VM template must have + +- **cloud-init** installed and configured for VMware datasource + +### vSphere Authentication + +You'll need the following credentials: + +- **vSphere Server** (hostname or IP) +- **Username** +- **Password** +- **Datacenter Name** +- **Cluster Name** +- **Datastore Name** +- **Network Name** +- **VM Template Name** + +[VMware Provider Documentation](https://registry.terraform.io/providers/hashicorp/vsphere/latest/docs) + +--- + +## Example `.tfvars` File + +```hcl +vsphere_server = "vcenter.example.com" +vsphere_username = "administrator@vsphere.local" +vsphere_password = "YourSecurePassword123!" +vsphere_datacenter = "DC01" +cluster_name = "Cluster01" +vsphere_datastore = "datastore1" +vsphere_network = "VM Network" +vm_template = "ubuntu-22.04-cloud-init-template" +``` + +--- + +## Architecture + +This template creates: + +- A **vSphere Virtual Machine** per workspace +- **Dynamic resource allocation** (CPU, memory configurable by users) +- **Two disks**: root disk (from template) and separate home volume +- **Coder agent** installed via cloud-init +- **code-server** for browser-based VS Code access + +## Workspace Parameters + +Users can customize their workspace with: + +- **VCPUs**: 1, 2, 4, or 8 virtual CPUs +- **Memory**: 1, 2, 4, 8, 16, or 32 GB RAM +- **Home Volume Size**: 10-1024 GB (default: 20 GB) diff --git a/registry/anis/templates/vmware-linux/cloud-init/cloud-config.yaml.tftpl b/registry/anis/templates/vmware-linux/cloud-init/cloud-config.yaml.tftpl new file mode 100644 index 00000000..5faeab3d --- /dev/null +++ b/registry/anis/templates/vmware-linux/cloud-init/cloud-config.yaml.tftpl @@ -0,0 +1,56 @@ +#cloud-config +hostname: ${hostname} +users: + - name: ${username} + sudo: ["ALL=(ALL) NOPASSWD:ALL"] + groups: sudo + shell: /bin/bash +packages: + - git + - curl + - wget + - unzip +disk_setup: + /dev/sdb: + table_type: "gpt" + layout: true + overwrite: false +fs_setup: + - label: ${home_volume_label} + filesystem: ext4 + device: /dev/sdb + partition: auto +mounts: + - ["/dev/sdb", "/home/${username}", "ext4", "defaults", "0", "2"] +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=-1000 + SyslogIdentifier=coder-agent + + [Install] + WantedBy=multi-user.target +runcmd: + - mkdir -p /home/${username} + - chown ${username}:${username} /home/${username} + - systemctl enable coder-agent + - systemctl start coder-agent diff --git a/registry/anis/templates/vmware-linux/main.tf b/registry/anis/templates/vmware-linux/main.tf new file mode 100644 index 00000000..13385493 --- /dev/null +++ b/registry/anis/templates/vmware-linux/main.tf @@ -0,0 +1,245 @@ +terraform { + required_providers { + coder = { + source = "coder/coder" + } + vsphere = { + source = "vmware/vsphere" + } + } +} + + +provider "vsphere" { + user = var.vsphere_username + password = var.vsphere_password + vsphere_server = var.vsphere_server + + allow_unverified_ssl = var.unverified_ssl +} + +variable "vsphere_username" { + type = string + default = "" +} +variable "vsphere_password" { + type = string + default = "" + sensitive = true +} +variable "vsphere_server" { + type = string + default = "" +} +variable "datacenter_name" { + type = string + default = "" +} +variable "cluster_name" { + type = string + default = "" +} +variable "datastore_name" { + type = string + default = "" + sensitive = true +} +variable "network_name" { + type = string + default = "" +} +variable "vm_template" { + type = string + default = "" +} +variable "unverified_ssl" { + type = bool + default = true +} + +locals { + vm_name = "coder-${lower(data.coder_workspace_owner.me.name)}-${lower(data.coder_workspace.me.name)}" + root_disk_label = substr("${local.vm_name}-root", 0, 32) + home_volume_label = substr("${local.vm_name}-home", 0, 32) +} + +data "coder_parameter" "instance_vcpus" { + name = "instance_vcpus" + display_name = "vCPUs" + description = "Number of vCPUs " + type = "number" + default = 1 + mutable = true + option { + name = "1 vCPU" + value = 1 + } + option { + name = "2 vCPUs" + value = 2 + } + option { + name = "4 vCPUs" + value = 4 + } + option { + name = "8 vCPUs" + value = 8 + } +} + +data "coder_parameter" "instance_memory" { + name = "instance_memory" + display_name = "Memory (GB)" + description = "Amount of RAM" + type = "number" + default = 2048 + mutable = true + option { + name = "1 GB" + value = 1024 + } + option { + name = "2 GB" + value = 2048 + } + option { + name = "4 GB" + value = 4096 + } + option { + name = "8 GB" + value = 8192 + } + option { + name = "16 GB" + value = 16384 + } + option { + name = "32 GB" + value = 32768 + } +} + +data "coder_parameter" "home_volume_size" { + name = "home_volume_size" + display_name = "Home Volume Size (GB)" + description = "How large would you like your home volume to be (in GB)?" + type = "number" + default = 20 + mutable = true + + validation { + min = 10 + max = 1024 + monotonic = "increasing" + } +} + +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)}" + } +} + +data "vsphere_datacenter" "dc" { + name = var.datacenter_name +} + +data "vsphere_datastore" "datastore" { + name = var.datastore_name + datacenter_id = data.vsphere_datacenter.dc.id +} + +data "vsphere_compute_cluster" "cluster" { + name = var.cluster_name + datacenter_id = data.vsphere_datacenter.dc.id +} + +data "vsphere_network" "network" { + name = var.network_name + datacenter_id = data.vsphere_datacenter.dc.id +} + +data "vsphere_virtual_machine" "template" { + name = var.vm_template + datacenter_id = data.vsphere_datacenter.dc.id +} + +locals { + cloud_init_config = templatefile("cloud-init/cloud-config.yaml.tftpl", { + hostname = local.vm_name + username = lower(data.coder_workspace_owner.me.name) + home_volume_label = local.home_volume_label + init_script = base64encode(coder_agent.main.init_script) + coder_agent_token = coder_agent.main.token + }) +} + +resource "vsphere_virtual_machine" "workspace" { + name = local.vm_name + firmware = data.vsphere_virtual_machine.template.firmware + resource_pool_id = data.vsphere_compute_cluster.cluster.resource_pool_id + datastore_id = data.vsphere_datastore.datastore.id + + num_cpus = data.coder_parameter.instance_vcpus.value + memory = data.coder_parameter.instance_memory.value + guest_id = data.vsphere_virtual_machine.template.guest_id + + scsi_type = data.vsphere_virtual_machine.template.scsi_type + + network_interface { + network_id = data.vsphere_network.network.id + adapter_type = data.vsphere_virtual_machine.template.network_interface_types[0] + } + + disk { + label = "disk0" + size = data.vsphere_virtual_machine.template.disks.0.size + } + disk { + label = local.home_volume_label + size = data.coder_parameter.home_volume_size.value + unit_number = 1 + } + extra_config = { + "guestinfo.userdata" = base64encode(local.cloud_init_config) + "guestinfo.userdata.encoding" = "base64" + } + clone { + template_uuid = data.vsphere_virtual_machine.template.id + } +} + +module "code-server" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/code-server/coder" + version = "~> 1.0" + + agent_id = coder_agent.main.id + order = 1 +} \ No newline at end of file diff --git a/registry/anomaly/.images/avatar.jpeg b/registry/anomaly/.images/avatar.jpeg index ca1072d8..f823960a 100644 Binary files a/registry/anomaly/.images/avatar.jpeg and b/registry/anomaly/.images/avatar.jpeg differ diff --git a/registry/anomaly/modules/tmux/README.md b/registry/anomaly/modules/tmux/README.md index d5f22ff5..7c7af00e 100644 --- a/registry/anomaly/modules/tmux/README.md +++ b/registry/anomaly/modules/tmux/README.md @@ -15,7 +15,7 @@ up a default or custom tmux configuration with session save/restore capabilities ```tf module "tmux" { source = "registry.coder.com/anomaly/tmux/coder" - version = "1.0.3" + version = "1.0.4" agent_id = coder_agent.example.id } ``` @@ -39,7 +39,7 @@ module "tmux" { ```tf module "tmux" { source = "registry.coder.com/anomaly/tmux/coder" - version = "1.0.3" + version = "1.0.4" agent_id = coder_agent.example.id tmux_config = "" # Optional: custom tmux.conf content save_interval = 1 # Optional: save interval in minutes @@ -78,7 +78,7 @@ This module can provision multiple tmux sessions, each as a separate app in the ```tf module "tmux" { source = "registry.coder.com/anomaly/tmux/coder" - version = "1.0.3" + version = "1.0.4" agent_id = var.agent_id sessions = ["default", "dev", "anomaly"] tmux_config = <<-EOT diff --git a/registry/anomaly/modules/tmux/main.tf b/registry/anomaly/modules/tmux/main.tf index 36f8471f..ef58f391 100644 --- a/registry/anomaly/modules/tmux/main.tf +++ b/registry/anomaly/modules/tmux/main.tf @@ -55,7 +55,7 @@ resource "coder_script" "tmux" { display_name = "tmux" icon = "/icon/terminal.svg" script = templatefile("${path.module}/scripts/run.sh", { - TMUX_CONFIG = var.tmux_config + TMUX_CONFIG = base64encode(var.tmux_config) SAVE_INTERVAL = var.save_interval }) run_on_start = true diff --git a/registry/anomaly/modules/tmux/scripts/run.sh b/registry/anomaly/modules/tmux/scripts/run.sh index b3c518c5..06c114d2 100755 --- a/registry/anomaly/modules/tmux/scripts/run.sh +++ b/registry/anomaly/modules/tmux/scripts/run.sh @@ -4,7 +4,7 @@ BOLD='\033[0;1m' # Convert templated variables to shell variables SAVE_INTERVAL="${SAVE_INTERVAL}" -TMUX_CONFIG="${TMUX_CONFIG}" +TMUX_CONFIG=$(echo -n "${TMUX_CONFIG}" | base64 -d) # Function to install tmux install_tmux() { @@ -73,7 +73,7 @@ setup_tmux_config() { mkdir -p "$config_dir" if [ -n "$TMUX_CONFIG" ]; then - printf "$TMUX_CONFIG" > "$config_file" + printf "%s" "$TMUX_CONFIG" > "$config_file" printf "$${BOLD}Custom tmux configuration applied at {$config_file} \n\n" else cat > "$config_file" << EOF diff --git a/registry/coder-labs/.images/avatar.svg b/registry/coder-labs/.images/avatar.svg index 8040fb06..676ccbf4 100644 --- a/registry/coder-labs/.images/avatar.svg +++ b/registry/coder-labs/.images/avatar.svg @@ -1,5 +1 @@ - - - - - \ No newline at end of file + \ No newline at end of file diff --git a/registry/coder-labs/.images/openwebui.png b/registry/coder-labs/.images/openwebui.png index 709d3e5f..5f2c3fce 100644 Binary files a/registry/coder-labs/.images/openwebui.png and b/registry/coder-labs/.images/openwebui.png differ diff --git a/registry/coder-labs/.images/perplexica.png b/registry/coder-labs/.images/perplexica.png new file mode 100644 index 00000000..9bb6ea28 Binary files /dev/null and b/registry/coder-labs/.images/perplexica.png differ diff --git a/registry/coder-labs/modules/auggie/README.md b/registry/coder-labs/modules/auggie/README.md index fd1632fe..562bcbd4 100644 --- a/registry/coder-labs/modules/auggie/README.md +++ b/registry/coder-labs/modules/auggie/README.md @@ -13,7 +13,7 @@ Run Auggie CLI in your workspace to access Augment's AI coding assistant with ad ```tf module "auggie" { source = "registry.coder.com/coder-labs/auggie/coder" - version = "0.2.2" + version = "0.3.0" agent_id = coder_agent.example.id folder = "/home/coder/project" } @@ -47,7 +47,7 @@ module "coder-login" { module "auggie" { source = "registry.coder.com/coder-labs/auggie/coder" - version = "0.2.2" + version = "0.3.0" agent_id = coder_agent.example.id folder = "/home/coder/project" @@ -103,7 +103,7 @@ EOF ```tf module "auggie" { source = "registry.coder.com/coder-labs/auggie/coder" - version = "0.2.2" + version = "0.3.0" agent_id = coder_agent.example.id folder = "/home/coder/project" diff --git a/registry/coder-labs/modules/auggie/main.tf b/registry/coder-labs/modules/auggie/main.tf index d75b1229..8ecb3ba0 100644 --- a/registry/coder-labs/modules/auggie/main.tf +++ b/registry/coder-labs/modules/auggie/main.tf @@ -4,7 +4,7 @@ terraform { required_providers { coder = { source = "coder/coder" - version = ">= 2.7" + version = ">= 2.12" } } } @@ -179,7 +179,7 @@ locals { module "agentapi" { source = "registry.coder.com/coder/agentapi/coder" - version = "1.2.0" + version = "2.0.0" agent_id = var.agent_id folder = local.folder @@ -229,4 +229,8 @@ module "agentapi" { ARG_MCP_CONFIG='${var.mcp != null ? base64encode(replace(var.mcp, "'", "'\\''")) : ""}' \ /tmp/install.sh EOT -} \ No newline at end of file +} + +output "task_app_id" { + value = module.agentapi.task_app_id +} diff --git a/registry/coder-labs/modules/codex/README.md b/registry/coder-labs/modules/codex/README.md index 1d778240..f98f9882 100644 --- a/registry/coder-labs/modules/codex/README.md +++ b/registry/coder-labs/modules/codex/README.md @@ -13,7 +13,7 @@ Run Codex CLI in your workspace to access OpenAI's models through the Codex inte ```tf module "codex" { source = "registry.coder.com/coder-labs/codex/coder" - version = "3.1.1" + version = "4.0.0" agent_id = coder_agent.example.id openai_api_key = var.openai_api_key workdir = "/home/coder/project" @@ -22,7 +22,6 @@ module "codex" { ## Prerequisites -- You must add the [Coder Login](https://registry.coder.com/modules/coder/coder-login) module to your template - OpenAI API key for Codex access ## Examples @@ -33,7 +32,7 @@ module "codex" { module "codex" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder-labs/codex/coder" - version = "3.1.1" + version = "4.0.0" agent_id = coder_agent.example.id openai_api_key = "..." workdir = "/home/coder/project" @@ -44,27 +43,19 @@ module "codex" { ### Tasks integration ```tf -data "coder_parameter" "ai_prompt" { - type = "string" - name = "AI Prompt" - default = "" - description = "Initial prompt for the Codex CLI" - mutable = true +resource "coder_ai_task" "task" { + count = data.coder_workspace.me.start_count + app_id = module.codex.task_app_id } -module "coder-login" { - count = data.coder_workspace.me.start_count - source = "registry.coder.com/coder/coder-login/coder" - version = "3.1.1" - agent_id = coder_agent.example.id -} +data "coder_task" "me" {} module "codex" { source = "registry.coder.com/coder-labs/codex/coder" - version = "3.1.1" + version = "4.0.0" agent_id = coder_agent.example.id openai_api_key = "..." - ai_prompt = data.coder_parameter.ai_prompt.value + ai_prompt = data.coder_task.me.prompt workdir = "/home/coder/project" # Custom configuration for full auto mode @@ -108,7 +99,7 @@ For custom Codex configuration, use `base_config_toml` and/or `additional_mcp_se ```tf module "codex" { source = "registry.coder.com/coder-labs/codex/coder" - version = "3.1.1" + version = "4.0.0" # ... other variables ... # Override default configuration @@ -137,7 +128,7 @@ module "codex" { - Ensure your OpenAI API key has access to the specified model > [!IMPORTANT] -> To use tasks with Codex CLI, ensure you have the `openai_api_key` variable set, and **you create a `coder_parameter` named `"AI Prompt"` and pass its value to the codex module's `ai_prompt` variable**. [Tasks Template Example](https://registry.coder.com/templates/coder-labs/tasks-docker). +> To use tasks with Codex CLI, ensure you have the `openai_api_key` variable set. [Tasks Template Example](https://registry.coder.com/templates/coder-labs/tasks-docker). > The module automatically configures Codex with your API key and model preferences. > workdir is a required variable for the module to function correctly. diff --git a/registry/coder-labs/modules/codex/main.tf b/registry/coder-labs/modules/codex/main.tf index a68cd79f..20351839 100644 --- a/registry/coder-labs/modules/codex/main.tf +++ b/registry/coder-labs/modules/codex/main.tf @@ -4,7 +4,7 @@ terraform { required_providers { coder = { source = "coder/coder" - version = ">= 2.7" + version = ">= 2.12" } } } @@ -110,12 +110,12 @@ variable "install_agentapi" { variable "agentapi_version" { type = string description = "The version of AgentAPI to install." - default = "v0.10.0" + default = "v0.11.6" } variable "codex_model" { type = string - description = "The model for Codex to use. Defaults to gpt-5." + description = "The model for Codex to use. Defaults to gpt-5.1-codex-max." default = "" } @@ -165,7 +165,7 @@ locals { module "agentapi" { source = "registry.coder.com/coder/agentapi/coder" - version = "1.2.0" + version = "2.0.0" agent_id = var.agent_id folder = local.workdir @@ -217,4 +217,8 @@ module "agentapi" { ARG_CODEX_INSTRUCTION_PROMPT='${base64encode(var.codex_system_prompt)}' \ /tmp/install.sh EOT -} \ No newline at end of file +} + +output "task_app_id" { + value = module.agentapi.task_app_id +} diff --git a/registry/coder-labs/modules/codex/scripts/install.sh b/registry/coder-labs/modules/codex/scripts/install.sh index 33991ef4..62842165 100644 --- a/registry/coder-labs/modules/codex/scripts/install.sh +++ b/registry/coder-labs/modules/codex/scripts/install.sh @@ -115,7 +115,7 @@ append_mcp_servers_section() { [mcp_servers.Coder] command = "coder" args = ["exp", "mcp", "server"] -env = { "CODER_MCP_APP_STATUS_SLUG" = "${ARG_CODER_MCP_APP_STATUS_SLUG}", "CODER_MCP_AI_AGENTAPI_URL" = "${CODER_MCP_AI_AGENTAPI_URL}" , "CODER_AGENT_URL" = "${CODER_AGENT_URL}", "CODER_AGENT_TOKEN" = "${CODER_AGENT_TOKEN}" } +env = { "CODER_MCP_APP_STATUS_SLUG" = "${ARG_CODER_MCP_APP_STATUS_SLUG}", "CODER_MCP_AI_AGENTAPI_URL" = "${CODER_MCP_AI_AGENTAPI_URL}" , "CODER_AGENT_URL" = "${CODER_AGENT_URL}", "CODER_AGENT_TOKEN" = "${CODER_AGENT_TOKEN}", "CODER_MCP_ALLOWED_TOOLS" = "coder_report_task" } description = "Report ALL tasks and statuses (in progress, done, failed) you are working on." type = "stdio" diff --git a/registry/coder-labs/modules/codex/scripts/start.sh b/registry/coder-labs/modules/codex/scripts/start.sh index 38510fd0..e77436f1 100644 --- a/registry/coder-labs/modules/codex/scripts/start.sh +++ b/registry/coder-labs/modules/codex/scripts/start.sh @@ -182,7 +182,7 @@ build_codex_args() { if [ -n "$ARG_CODEX_TASK_PROMPT" ]; then if [ "${ARG_REPORT_TASKS}" == "true" ]; then - PROMPT="Complete the task at hand in one go. Every step of the way, report your progress using coder_report_task tool with proper summary and statuses. Your task at hand: $ARG_CODEX_TASK_PROMPT" + PROMPT="Complete the task at hand in one go. Every step of the way, report your progress using Coder.coder_report_task tool with proper summary and statuses. Your task at hand: $ARG_CODEX_TASK_PROMPT" else PROMPT="Your task at hand: $ARG_CODEX_TASK_PROMPT" fi diff --git a/registry/coder-labs/modules/copilot/README.md b/registry/coder-labs/modules/copilot/README.md index 4ef2f6bc..76b8f025 100644 --- a/registry/coder-labs/modules/copilot/README.md +++ b/registry/coder-labs/modules/copilot/README.md @@ -13,7 +13,7 @@ Run [GitHub Copilot CLI](https://docs.github.com/copilot/concepts/agents/about-c ```tf module "copilot" { source = "registry.coder.com/coder-labs/copilot/coder" - version = "0.2.3" + version = "0.3.0" agent_id = coder_agent.example.id workdir = "/home/coder/projects" } @@ -51,7 +51,7 @@ data "coder_parameter" "ai_prompt" { module "copilot" { source = "registry.coder.com/coder-labs/copilot/coder" - version = "0.2.3" + version = "0.3.0" agent_id = coder_agent.example.id workdir = "/home/coder/projects" @@ -71,7 +71,7 @@ Customize tool permissions, MCP servers, and Copilot settings: ```tf module "copilot" { source = "registry.coder.com/coder-labs/copilot/coder" - version = "0.2.3" + version = "0.3.0" agent_id = coder_agent.example.id workdir = "/home/coder/projects" @@ -142,7 +142,7 @@ variable "github_token" { module "copilot" { source = "registry.coder.com/coder-labs/copilot/coder" - version = "0.2.3" + version = "0.3.0" agent_id = coder_agent.example.id workdir = "/home/coder/projects" github_token = var.github_token @@ -156,7 +156,7 @@ Run Copilot as a command-line tool without task reporting or web interface. This ```tf module "copilot" { source = "registry.coder.com/coder-labs/copilot/coder" - version = "0.2.3" + version = "0.3.0" agent_id = coder_agent.example.id workdir = "/home/coder" report_tasks = false diff --git a/registry/coder-labs/modules/copilot/main.tf b/registry/coder-labs/modules/copilot/main.tf index 41a83d53..218184d7 100644 --- a/registry/coder-labs/modules/copilot/main.tf +++ b/registry/coder-labs/modules/copilot/main.tf @@ -3,7 +3,7 @@ terraform { required_providers { coder = { source = "coder/coder" - version = ">= 2.7" + version = ">= 2.12" } } } @@ -242,7 +242,7 @@ resource "coder_env" "github_token" { module "agentapi" { source = "registry.coder.com/coder/agentapi/coder" - version = "1.2.0" + version = "2.0.0" agent_id = var.agent_id folder = local.workdir @@ -268,7 +268,7 @@ module "agentapi" { set -o pipefail echo -n '${base64encode(local.start_script)}' | base64 -d > /tmp/start.sh chmod +x /tmp/start.sh - + ARG_WORKDIR='${local.workdir}' \ ARG_AI_PROMPT='${base64encode(var.ai_prompt)}' \ ARG_SYSTEM_PROMPT='${base64encode(local.final_system_prompt)}' \ @@ -288,7 +288,7 @@ module "agentapi" { set -o pipefail echo -n '${base64encode(local.install_script)}' | base64 -d > /tmp/install.sh chmod +x /tmp/install.sh - + ARG_MCP_APP_STATUS_SLUG='${local.app_slug}' \ ARG_REPORT_TASKS='${var.report_tasks}' \ ARG_WORKDIR='${local.workdir}' \ @@ -299,4 +299,8 @@ module "agentapi" { ARG_COPILOT_MODEL='${var.copilot_model}' \ /tmp/install.sh EOT -} \ No newline at end of file +} + +output "task_app_id" { + value = module.agentapi.task_app_id +} diff --git a/registry/coder-labs/modules/cursor-cli/README.md b/registry/coder-labs/modules/cursor-cli/README.md index 6aa5ada3..6b710530 100644 --- a/registry/coder-labs/modules/cursor-cli/README.md +++ b/registry/coder-labs/modules/cursor-cli/README.md @@ -13,7 +13,7 @@ Run the Cursor Agent CLI in your workspace for interactive coding assistance and ```tf module "cursor_cli" { source = "registry.coder.com/coder-labs/cursor-cli/coder" - version = "0.2.2" + version = "0.3.0" agent_id = coder_agent.main.id folder = "/home/coder/project" } @@ -42,7 +42,7 @@ module "coder-login" { module "cursor_cli" { source = "registry.coder.com/coder-labs/cursor-cli/coder" - version = "0.2.2" + version = "0.3.0" agent_id = coder_agent.main.id folder = "/home/coder/project" diff --git a/registry/coder-labs/modules/cursor-cli/main.test.ts b/registry/coder-labs/modules/cursor-cli/main.test.ts index 4b37b71c..b7f9947c 100644 --- a/registry/coder-labs/modules/cursor-cli/main.test.ts +++ b/registry/coder-labs/modules/cursor-cli/main.test.ts @@ -159,7 +159,7 @@ describe("cursor-cli", async () => { "-c", "cat /home/coder/.cursor-cli-module/agentapi-start.log || cat /home/coder/.cursor-cli-module/start.log || true", ]); - expect(startLog.stdout).toContain(`-m ${model}`); + expect(startLog.stdout).toContain(`--model ${model}`); expect(startLog.stdout).toContain("-f"); expect(startLog.stdout).toContain("test prompt"); }); diff --git a/registry/coder-labs/modules/cursor-cli/main.tf b/registry/coder-labs/modules/cursor-cli/main.tf index a57ad65c..4363f358 100644 --- a/registry/coder-labs/modules/cursor-cli/main.tf +++ b/registry/coder-labs/modules/cursor-cli/main.tf @@ -4,7 +4,7 @@ terraform { required_providers { coder = { source = "coder/coder" - version = ">= 2.7" + version = ">= 2.12" } } } @@ -132,7 +132,7 @@ resource "coder_env" "cursor_api_key" { module "agentapi" { source = "registry.coder.com/coder/agentapi/coder" - version = "1.2.0" + version = "2.0.0" agent_id = var.agent_id folder = local.folder @@ -179,3 +179,7 @@ module "agentapi" { /tmp/install.sh EOT } + +output "task_app_id" { + value = module.agentapi.task_app_id +} diff --git a/registry/coder-labs/modules/cursor-cli/scripts/start.sh b/registry/coder-labs/modules/cursor-cli/scripts/start.sh index 8a200a2f..1e9ab59d 100644 --- a/registry/coder-labs/modules/cursor-cli/scripts/start.sh +++ b/registry/coder-labs/modules/cursor-cli/scripts/start.sh @@ -50,7 +50,7 @@ ARGS=() # global flags if [ -n "$ARG_MODEL" ]; then - ARGS+=("-m" "$ARG_MODEL") + ARGS+=("--model" "$ARG_MODEL") fi if [ "$ARG_FORCE" = "true" ]; then ARGS+=("-f") diff --git a/registry/coder-labs/modules/gemini/README.md b/registry/coder-labs/modules/gemini/README.md index 8e44c89b..d0a113a0 100644 --- a/registry/coder-labs/modules/gemini/README.md +++ b/registry/coder-labs/modules/gemini/README.md @@ -13,7 +13,7 @@ Run [Gemini CLI](https://github.com/google-gemini/gemini-cli) in your workspace ```tf module "gemini" { source = "registry.coder.com/coder-labs/gemini/coder" - version = "2.1.2" + version = "3.0.0" agent_id = coder_agent.main.id folder = "/home/coder/project" } @@ -46,7 +46,7 @@ variable "gemini_api_key" { module "gemini" { source = "registry.coder.com/coder-labs/gemini/coder" - version = "2.1.2" + version = "3.0.0" agent_id = coder_agent.main.id gemini_api_key = var.gemini_api_key folder = "/home/coder/project" @@ -94,7 +94,7 @@ data "coder_parameter" "ai_prompt" { module "gemini" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder-labs/gemini/coder" - version = "2.1.2" + version = "3.0.0" agent_id = coder_agent.main.id gemini_api_key = var.gemini_api_key gemini_model = "gemini-2.5-flash" @@ -118,7 +118,7 @@ For enterprise users who prefer Google's Vertex AI platform: ```tf module "gemini" { source = "registry.coder.com/coder-labs/gemini/coder" - version = "2.1.2" + version = "3.0.0" agent_id = coder_agent.main.id gemini_api_key = var.gemini_api_key folder = "/home/coder/project" diff --git a/registry/coder-labs/modules/gemini/main.tf b/registry/coder-labs/modules/gemini/main.tf index 7cc8ac04..dbc81bc7 100644 --- a/registry/coder-labs/modules/gemini/main.tf +++ b/registry/coder-labs/modules/gemini/main.tf @@ -4,7 +4,7 @@ terraform { required_providers { coder = { source = "coder/coder" - version = ">= 2.7" + version = ">= 2.12" } } } @@ -177,7 +177,7 @@ EOT module "agentapi" { source = "registry.coder.com/coder/agentapi/coder" - version = "1.2.0" + version = "2.0.0" agent_id = var.agent_id folder = local.folder @@ -225,4 +225,8 @@ module "agentapi" { GEMINI_TASK_PROMPT='${var.task_prompt}' \ /tmp/start.sh EOT -} \ No newline at end of file +} + +output "task_app_id" { + value = module.agentapi.task_app_id +} diff --git a/registry/coder-labs/modules/perplexica/README.md b/registry/coder-labs/modules/perplexica/README.md new file mode 100644 index 00000000..d1e37299 --- /dev/null +++ b/registry/coder-labs/modules/perplexica/README.md @@ -0,0 +1,55 @@ +--- +display_name: Perplexica +description: Run Perplexica AI search engine in your workspace via Docker +icon: ../../../../.icons/perplexica.svg +verified: false +tags: [ai, search, docker] +--- + +# Perplexica + +Run [Perplexica](https://github.com/ItzCrazyKns/Perplexica), a privacy-focused AI search engine, in your Coder workspace. Supports cloud providers (OpenAI, Anthropic Claude) and local LLMs via Ollama. + +```tf +module "perplexica" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder-labs/perplexica/coder" + version = "1.0.0" + agent_id = coder_agent.main.id +} +``` + +This module uses the full Perplexica image with embedded SearXNG for simpler setup with no external dependencies. + +![Perplexica](../../.images/perplexica.png) + +## Prerequisites + +This module requires Docker to be available on the host. + +## Examples + +### With API Keys + +```tf +module "perplexica" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder-labs/perplexica/coder" + version = "1.0.0" + agent_id = coder_agent.main.id + openai_api_key = var.openai_api_key + anthropic_api_key = var.anthropic_api_key +} +``` + +### With Local Ollama + +```tf +module "perplexica" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder-labs/perplexica/coder" + version = "1.0.0" + agent_id = coder_agent.main.id + ollama_api_url = "http://ollama-external-endpoint:11434" +} +``` diff --git a/registry/coder-labs/modules/perplexica/main.tf b/registry/coder-labs/modules/perplexica/main.tf new file mode 100644 index 00000000..4a7aa5c8 --- /dev/null +++ b/registry/coder-labs/modules/perplexica/main.tf @@ -0,0 +1,108 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 2.5" + } + } +} + +variable "agent_id" { + type = string + description = "The ID of a Coder agent." +} + +variable "docker_socket" { + type = string + description = "(Optional) Docker socket URI" + default = "" +} + +variable "port" { + type = number + description = "The port to run Perplexica on." + default = 3000 +} + +variable "data_path" { + type = string + description = "Host path to mount for Perplexica data persistence." + default = "./perplexica-data" +} + +variable "uploads_path" { + type = string + description = "Host path to mount for Perplexica file uploads." + default = "./perplexica-uploads" +} + +variable "openai_api_key" { + type = string + description = "OpenAI API key." + default = "" + sensitive = true +} + +variable "anthropic_api_key" { + type = string + description = "Anthropic API key for Claude models." + default = "" + sensitive = true +} + +variable "ollama_api_url" { + type = string + description = "Ollama API URL for local LLM support." + default = "" +} + +variable "share" { + type = string + default = "owner" + validation { + condition = var.share == "owner" || var.share == "authenticated" || var.share == "public" + error_message = "Incorrect value. Please set either 'owner', 'authenticated', or 'public'." + } +} + +variable "order" { + type = number + description = "The order determines the position of app in the UI presentation. The lowest order is shown first and apps with equal order are sorted by name (ascending order)." + default = null +} + +variable "group" { + type = string + description = "The name of a group that this app belongs to." + default = null +} + +resource "coder_script" "perplexica" { + agent_id = var.agent_id + display_name = "Perplexica" + icon = "/icon/perplexica.svg" + script = templatefile("${path.module}/run.sh", { + DOCKER_HOST : var.docker_socket, + PORT : var.port, + DATA_PATH : var.data_path, + UPLOADS_PATH : var.uploads_path, + OPENAI_API_KEY : var.openai_api_key, + ANTHROPIC_API_KEY : var.anthropic_api_key, + OLLAMA_API_URL : var.ollama_api_url, + }) + run_on_start = true +} + +resource "coder_app" "perplexica" { + agent_id = var.agent_id + slug = "perplexica" + display_name = "Perplexica" + url = "http://localhost:${var.port}" + icon = "/icon/perplexica.svg" + subdomain = true + share = var.share + order = var.order + group = var.group +} diff --git a/registry/coder-labs/modules/perplexica/perplexica.tftest.hcl b/registry/coder-labs/modules/perplexica/perplexica.tftest.hcl new file mode 100644 index 00000000..3e572cdf --- /dev/null +++ b/registry/coder-labs/modules/perplexica/perplexica.tftest.hcl @@ -0,0 +1,26 @@ +run "plan_basic" { + command = plan + + variables { + agent_id = "test-agent" + } + + assert { + condition = resource.coder_app.perplexica.url == "http://localhost:3000" + error_message = "Default port should be 3000" + } +} + +run "plan_custom_port" { + command = plan + + variables { + agent_id = "test-agent" + port = 8080 + } + + assert { + condition = resource.coder_app.perplexica.url == "http://localhost:8080" + error_message = "Should use custom port" + } +} diff --git a/registry/coder-labs/modules/perplexica/run.sh b/registry/coder-labs/modules/perplexica/run.sh new file mode 100755 index 00000000..ba12bbd1 --- /dev/null +++ b/registry/coder-labs/modules/perplexica/run.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env sh + +set -eu + +BOLD='\033[0;1m' +RESET='\033[0m' + +printf "$${BOLD}Starting Perplexica...$${RESET}\n" + +# Set Docker host if provided +if [ -n "${DOCKER_HOST}" ]; then + export DOCKER_HOST="${DOCKER_HOST}" +fi + +# Wait for docker to become ready +max_attempts=10 +delay=2 +attempt=1 + +while ! docker ps; do + if [ $attempt -ge $max_attempts ]; then + echo "Failed to list containers after $${max_attempts} attempts." + exit 1 + fi + echo "Attempt $${attempt} failed, retrying in $${delay}s..." + sleep $delay + attempt=$(expr "$attempt" + 1) + delay=$(expr "$delay" \* 2) +done + +# Pull the image +IMAGE="itzcrazykns1337/perplexica:latest" +docker pull "$${IMAGE}" + +# Build docker run command +DOCKER_ARGS="-d --rm --name perplexica -p ${PORT}:3000" + +# Add mounts - convert relative paths to absolute +DATA_PATH="${DATA_PATH}" +UPLOADS_PATH="${UPLOADS_PATH}" + +mkdir -p "$${DATA_PATH}" +mkdir -p "$${UPLOADS_PATH}" + +DATA_PATH_ABS=$(cd "$${DATA_PATH}" && pwd) +UPLOADS_PATH_ABS=$(cd "$${UPLOADS_PATH}" && pwd) + +DOCKER_ARGS="$${DOCKER_ARGS} -v $${DATA_PATH_ABS}:/home/perplexica/data" +DOCKER_ARGS="$${DOCKER_ARGS} -v $${UPLOADS_PATH_ABS}:/home/perplexica/uploads" + +# Add environment variables if provided +if [ -n "${OPENAI_API_KEY}" ]; then + DOCKER_ARGS="$${DOCKER_ARGS} -e OPENAI_API_KEY=${OPENAI_API_KEY}" +fi + +if [ -n "${ANTHROPIC_API_KEY}" ]; then + DOCKER_ARGS="$${DOCKER_ARGS} -e ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}" +fi + +if [ -n "${OLLAMA_API_URL}" ]; then + DOCKER_ARGS="$${DOCKER_ARGS} -e OLLAMA_API_URL=${OLLAMA_API_URL}" +fi + +# Run container +docker run $${DOCKER_ARGS} "$${IMAGE}" + +printf "\n$${BOLD}Perplexica is running on port ${PORT}$${RESET}\n" diff --git a/registry/coder-labs/modules/sourcegraph-amp/README.md b/registry/coder-labs/modules/sourcegraph-amp/README.md index 6ba0576f..0fabafe8 100644 --- a/registry/coder-labs/modules/sourcegraph-amp/README.md +++ b/registry/coder-labs/modules/sourcegraph-amp/README.md @@ -13,7 +13,7 @@ Run [Amp CLI](https://ampcode.com/) in your workspace to access Sourcegraph's AI ```tf module "amp-cli" { source = "registry.coder.com/coder-labs/sourcegraph-amp/coder" - version = "2.1.0" + version = "3.0.0" agent_id = coder_agent.example.id amp_api_key = var.amp_api_key install_amp = true @@ -48,7 +48,7 @@ variable "amp_api_key" { module "amp-cli" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder-labs/sourcegraph-amp/coder" - amp_version = "2.1.0" + amp_version = "3.0.0" agent_id = coder_agent.example.id amp_api_key = var.amp_api_key # recommended for tasks usage workdir = "/home/coder/project" diff --git a/registry/coder-labs/modules/sourcegraph-amp/main.test.ts b/registry/coder-labs/modules/sourcegraph-amp/main.test.ts index dc5328a3..105f7386 100644 --- a/registry/coder-labs/modules/sourcegraph-amp/main.test.ts +++ b/registry/coder-labs/modules/sourcegraph-amp/main.test.ts @@ -110,6 +110,7 @@ describe("amp", async () => { const { id } = await setup({ skipAmpMock: true, moduleVariables: { + install_via_npm: "true", amp_version: "0.0.1755964909-g31e083", }, }); diff --git a/registry/coder-labs/modules/sourcegraph-amp/main.tf b/registry/coder-labs/modules/sourcegraph-amp/main.tf index 31433795..13b25ec0 100644 --- a/registry/coder-labs/modules/sourcegraph-amp/main.tf +++ b/registry/coder-labs/modules/sourcegraph-amp/main.tf @@ -4,7 +4,7 @@ terraform { required_providers { coder = { source = "coder/coder" - version = ">= 2.7" + version = ">= 2.12" } external = { source = "hashicorp/external" @@ -140,7 +140,7 @@ variable "base_amp_config" { type = string description = <<-EOT Base AMP configuration in JSON format. Can be overridden to customize AMP settings. - + If empty, defaults enable thinking and todos for autonomous operation. Additional options include: - "amp.permissions": [] (tool permissions) - "amp.tools.stopTimeout": 600 (extend timeout for long operations) @@ -148,7 +148,7 @@ variable "base_amp_config" { - "amp.tools.disable": ["builtin:open"] (disable tools for containers) - "amp.git.commit.ampThread.enabled": true (link commits to threads) - "amp.git.commit.coauthor.enabled": true (add Amp as co-author) - + Reference: https://ampcode.com/manual EOT default = "" @@ -220,7 +220,7 @@ locals { module "agentapi" { source = "registry.coder.com/coder/agentapi/coder" - version = "1.2.0" + version = "2.0.0" agent_id = var.agent_id folder = local.workdir @@ -268,4 +268,6 @@ module "agentapi" { EOT } - +output "task_app_id" { + value = module.agentapi.task_app_id +} diff --git a/registry/coder/.images/amazon-q.png b/registry/coder/.images/amazon-q.png index 1221cc8b..eae79efc 100644 Binary files a/registry/coder/.images/amazon-q.png and b/registry/coder/.images/amazon-q.png differ diff --git a/registry/coder/.images/avatar.svg b/registry/coder/.images/avatar.svg index 60d7eff6..5db19df6 100644 --- a/registry/coder/.images/avatar.svg +++ b/registry/coder/.images/avatar.svg @@ -1,4 +1 @@ - - - - + \ No newline at end of file diff --git a/registry/coder/.images/aws-devcontainer-architecture.svg b/registry/coder/.images/aws-devcontainer-architecture.svg index be66c3f1..c9673a37 100644 --- a/registry/coder/.images/aws-devcontainer-architecture.svg +++ b/registry/coder/.images/aws-devcontainer-architecture.svg @@ -1,8 +1 @@ -AWSAWSHostingHostingVirtual MachineVirtual MachineLinux HardwareLinux HardwareCoder WorkspaceCoder WorkspaceDevcontainerDevcontainerenvbuilder created filesystemenvbuilder created filesystemA Clone of your repoA Clone of your repoSource codeSource codeLanguagesLanguagesPython. Go, etcPython. Go, etcToolingToolingExtensions, linting, formatting, etcExtensions, linting, formatting, etcCPUsCPUsDisk StorageDisk StorageCode EditorCode EditorVS Code DesktopVS Code DesktopLocal InstallationLocal InstallationVS Code DesktopVS Code DesktopLocal InstallationLocal Installationcode-servercode-serverA web IDEA web IDEJetBrains GatewayJetBrains GatewayLocal InstallationLocal InstallationCommand LineCommand LineSSH via Coder CLISSH via Coder CLI \ No newline at end of file +AWSAWSHostingHostingVirtual MachineVirtual MachineLinux HardwareLinux HardwareCoder WorkspaceCoder WorkspaceDevcontainerDevcontainerenvbuilder created filesystemenvbuilder created filesystemA Clone of your repoA Clone of your repoSource codeSource codeLanguagesLanguagesPython. Go, etcPython. Go, etcToolingToolingExtensions, linting, formatting, etcExtensions, linting, formatting, etcCPUsCPUsDisk StorageDisk StorageCode EditorCode EditorVS Code DesktopVS Code DesktopLocal InstallationLocal InstallationVS Code DesktopVS Code DesktopLocal InstallationLocal Installationcode-servercode-serverA web IDEA web IDEJetBrains GatewayJetBrains GatewayLocal InstallationLocal InstallationCommand LineCommand LineSSH via Coder CLISSH via Coder CLI \ No newline at end of file diff --git a/registry/coder/.images/gcp-devcontainer-architecture.svg b/registry/coder/.images/gcp-devcontainer-architecture.svg index c3dfd645..a53d48a7 100644 --- a/registry/coder/.images/gcp-devcontainer-architecture.svg +++ b/registry/coder/.images/gcp-devcontainer-architecture.svg @@ -1,8 +1 @@ -GCPGCPHostingHostingVirtual MachineVirtual MachineLinux HardwareLinux HardwareCoder WorkspaceCoder WorkspaceDevcontainerDevcontainerenvbuilder created filesystemenvbuilder created filesystemA Clone of your repoA Clone of your repoSource codeSource codeLanguagesLanguagesPython. Go, etcPython. Go, etcToolingToolingExtensions, linting, formatting, etcExtensions, linting, formatting, etcCPUsCPUsDisk StorageDisk StorageCode EditorCode EditorVS Code DesktopVS Code DesktopLocal InstallationLocal InstallationVS Code DesktopVS Code DesktopLocal InstallationLocal Installationcode-servercode-serverA web IDEA web IDEJetBrains GatewayJetBrains GatewayLocal InstallationLocal InstallationCommand LineCommand LineSSH via Coder CLISSH via Coder CLI \ No newline at end of file +GCPGCPHostingHostingVirtual MachineVirtual MachineLinux HardwareLinux HardwareCoder WorkspaceCoder WorkspaceDevcontainerDevcontainerenvbuilder created filesystemenvbuilder created filesystemA Clone of your repoA Clone of your repoSource codeSource codeLanguagesLanguagesPython. Go, etcPython. Go, etcToolingToolingExtensions, linting, formatting, etcExtensions, linting, formatting, etcCPUsCPUsDisk StorageDisk StorageCode EditorCode EditorVS Code DesktopVS Code DesktopLocal InstallationLocal InstallationVS Code DesktopVS Code DesktopLocal InstallationLocal Installationcode-servercode-serverA web IDEA web IDEJetBrains GatewayJetBrains GatewayLocal InstallationLocal InstallationCommand LineCommand LineSSH via Coder CLISSH via Coder CLI \ No newline at end of file diff --git a/registry/coder/.images/rstudio-server.png b/registry/coder/.images/rstudio-server.png index 71ecc464..a75e76c6 100644 Binary files a/registry/coder/.images/rstudio-server.png and b/registry/coder/.images/rstudio-server.png differ diff --git a/registry/coder/modules/claude-code/README.md b/registry/coder/modules/claude-code/README.md index 657435f9..eff7dfdd 100644 --- a/registry/coder/modules/claude-code/README.md +++ b/registry/coder/modules/claude-code/README.md @@ -3,7 +3,7 @@ display_name: Claude Code description: Run the Claude Code agent in your workspace. icon: ../../../../.icons/claude.svg verified: true -tags: [agent, claude-code, ai, tasks, anthropic] +tags: [agent, claude-code, ai, tasks, anthropic, aibridge] --- # Claude Code @@ -13,7 +13,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 = "4.2.6" + version = "4.5.0" agent_id = coder_agent.main.id workdir = "/home/coder/project" claude_api_key = "xxxx-xxxxx-xxxx" @@ -44,35 +44,77 @@ This example shows how to configure the Claude Code module to run the agent behi ```tf module "claude-code" { - source = "dev.registry.coder.com/coder/claude-code/coder" - version = "4.2.6" - agent_id = coder_agent.main.id - workdir = "/home/coder/project" - enable_boundary = true - boundary_version = "main" - boundary_log_dir = "/tmp/boundary_logs" - boundary_log_level = "WARN" - boundary_additional_allowed_urls = ["GET *google.com"] - boundary_proxy_port = "8087" + source = "registry.coder.com/coder/claude-code/coder" + version = "4.5.0" + agent_id = coder_agent.main.id + workdir = "/home/coder/project" + enable_boundary = true + boundary_version = "v0.5.1" } ``` -### Usage with Tasks and Advanced Configuration +### Usage with AI Bridge -This example shows how to configure the Claude Code module with an AI prompt, API key shared by all users of the template, and other custom settings. +[AI Bridge](https://coder.com/docs/ai-coder/ai-bridge) is a Premium Coder feature that provides centralized LLM proxy management. To use AI Bridge, set `enable_aibridge = true`. + +For tasks integration with AI Bridge, add `enable_aibridge = true` to the [Usage with Tasks](#usage-with-tasks) example below. + +#### Standalone usage with AI Bridge ```tf -data "coder_parameter" "ai_prompt" { - type = "string" - name = "AI Prompt" - default = "" - description = "Initial task prompt for Claude Code." - mutable = true +module "claude-code" { + source = "registry.coder.com/coder/claude-code/coder" + version = "4.5.0" + agent_id = coder_agent.main.id + workdir = "/home/coder/project" + enable_aibridge = true +} +``` + +When `enable_aibridge = true`, the module automatically sets: + +- `ANTHROPIC_BASE_URL` to `${data.coder_workspace.me.access_url}/api/v2/aibridge/anthropic` +- `CLAUDE_API_KEY` to the workspace owner's session token + +This allows Claude Code to route API requests through Coder's AI Bridge instead of directly to Anthropic's API. +Template build will fail if either `claude_api_key` or `claude_code_oauth_token` is provided alongside `enable_aibridge = true`. + +### Usage with Tasks + +This example shows how to configure Claude Code with Coder tasks. + +```tf +resource "coder_ai_task" "task" { + count = data.coder_workspace.me.start_count + app_id = module.claude-code.task_app_id } +data "coder_task" "me" {} + +module "claude-code" { + source = "registry.coder.com/coder/claude-code/coder" + version = "4.5.0" + agent_id = coder_agent.main.id + workdir = "/home/coder/project" + claude_api_key = "xxxx-xxxxx-xxxx" + ai_prompt = data.coder_task.me.prompt + + # Optional: route through AI Bridge (Premium feature) + # enable_aibridge = true +} +``` + +### Advanced Configuration + +This example shows additional configuration options for version pinning, custom models, and MCP servers. + +> [!NOTE] +> When a specific `claude_code_version` (other than "latest") is provided, the module will install Claude Code via npm instead of the official installer. This allows for version pinning. The `claude_binary_path` variable can be used to specify where a pre-installed Claude binary is located. + +```tf module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "4.2.6" + version = "4.5.0" agent_id = coder_agent.main.id workdir = "/home/coder/project" @@ -80,22 +122,20 @@ module "claude-code" { # OR claude_code_oauth_token = "xxxxx-xxxx-xxxx" - claude_code_version = "2.0.62" # Pin to a specific version + claude_code_version = "2.0.62" # Pin to a specific version (uses npm) + claude_binary_path = "/opt/claude/bin" # Path to pre-installed Claude binary agentapi_version = "0.11.4" - ai_prompt = data.coder_parameter.ai_prompt.value - model = "sonnet" - + model = "sonnet" permission_mode = "plan" mcp = <<-EOF { "mcpServers": { "my-custom-tool": { - "command": "my-tool-server" + "command": "my-tool-server", "args": ["--port", "8080"] } - } } EOF @@ -109,7 +149,7 @@ Run and configure Claude Code as a standalone CLI in your workspace. ```tf module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "4.2.6" + version = "4.5.0" agent_id = coder_agent.main.id workdir = "/home/coder/project" install_claude_code = true @@ -131,7 +171,7 @@ variable "claude_code_oauth_token" { module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "4.2.6" + version = "4.5.0" agent_id = coder_agent.main.id workdir = "/home/coder/project" claude_code_oauth_token = var.claude_code_oauth_token @@ -204,7 +244,7 @@ resource "coder_env" "bedrock_api_key" { module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "4.2.6" + version = "4.5.0" agent_id = coder_agent.main.id workdir = "/home/coder/project" model = "global.anthropic.claude-sonnet-4-5-20250929-v1:0" @@ -261,7 +301,7 @@ resource "coder_env" "google_application_credentials" { module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "4.2.6" + version = "4.5.0" agent_id = coder_agent.main.id workdir = "/home/coder/project" model = "claude-sonnet-4@20250514" diff --git a/registry/coder/modules/claude-code/main.test.ts b/registry/coder/modules/claude-code/main.test.ts index 6e37501d..d59b6a8f 100644 --- a/registry/coder/modules/claude-code/main.test.ts +++ b/registry/coder/modules/claude-code/main.test.ts @@ -39,9 +39,11 @@ interface SetupProps { agentapiMockScript?: string; } -const setup = async (props?: SetupProps): Promise<{ id: string }> => { +const setup = async ( + props?: SetupProps, +): Promise<{ id: string; coderEnvVars: Record }> => { const projectDir = "/home/coder/project"; - const { id } = await setupUtil({ + const { id, coderEnvVars } = await setupUtil({ moduleDir: import.meta.dir, moduleVariables: { install_claude_code: props?.skipClaudeMock ? "true" : "false", @@ -61,7 +63,7 @@ const setup = async (props?: SetupProps): Promise<{ id: string }> => { content: await loadTestFile(import.meta.dir, "claude-mock.sh"), }); } - return { id }; + return { id, coderEnvVars }; }; setDefaultTimeout(60 * 1000); @@ -79,14 +81,14 @@ describe("claude-code", async () => { test("install-claude-code-version", async () => { const version_to_install = "1.0.40"; - const { id } = await setup({ + const { id, coderEnvVars } = await setup({ skipClaudeMock: true, moduleVariables: { install_claude_code: "true", claude_code_version: version_to_install, }, }); - await execModuleScript(id); + await execModuleScript(id, coderEnvVars); const resp = await execContainer(id, [ "bash", "-c", @@ -96,14 +98,14 @@ describe("claude-code", async () => { }); test("check-latest-claude-code-version-works", async () => { - const { id } = await setup({ + const { id, coderEnvVars } = await setup({ skipClaudeMock: true, skipAgentAPIMock: true, moduleVariables: { install_claude_code: "true", }, }); - await execModuleScript(id); + await execModuleScript(id, coderEnvVars); await expectAgentAPIStarted(id); }); @@ -133,13 +135,13 @@ describe("claude-code", async () => { }, }, }); - const { id } = await setup({ + const { id, coderEnvVars } = await setup({ skipClaudeMock: true, moduleVariables: { mcp: mcpConfig, }, }); - await execModuleScript(id); + await execModuleScript(id, coderEnvVars); const resp = await readFileContainer(id, "/home/coder/.claude.json"); expect(resp).toContain("test-cmd"); @@ -182,20 +184,15 @@ describe("claude-code", async () => { test("claude-model", async () => { const model = "opus"; - const { id } = await setup({ + const { coderEnvVars } = await setup({ moduleVariables: { model: model, ai_prompt: "test prompt", }, }); - await execModuleScript(id); - const startLog = await execContainer(id, [ - "bash", - "-c", - "cat /home/coder/.claude-module/agentapi-start.log", - ]); - expect(startLog.stdout).toContain(`--model ${model}`); + // Verify ANTHROPIC_MODEL env var is set via coder_env + expect(coderEnvVars["ANTHROPIC_MODEL"]).toBe(model); }); test("claude-continue-resume-task-session", async () => { diff --git a/registry/coder/modules/claude-code/main.tf b/registry/coder/modules/claude-code/main.tf index afcf9df3..8c98adc8 100644 --- a/registry/coder/modules/claude-code/main.tf +++ b/registry/coder/modules/claude-code/main.tf @@ -86,7 +86,7 @@ variable "install_agentapi" { variable "agentapi_version" { type = string description = "The version of AgentAPI to install." - default = "v0.11.4" + default = "v0.11.8" } variable "ai_prompt" { @@ -128,7 +128,7 @@ variable "claude_api_key" { variable "model" { type = string - description = "Sets the model for the current session with an alias for the latest model (sonnet or opus) or a model’s full name." + description = "Sets the default model for Claude Code via ANTHROPIC_MODEL env var. If empty, Claude Code uses its default. Supports aliases (sonnet, opus) or full model names." default = "" } @@ -198,6 +198,18 @@ variable "claude_md_path" { default = "$HOME/.claude/CLAUDE.md" } +variable "claude_binary_path" { + type = string + description = "Directory where the Claude Code binary is located. Use this if Claude is pre-installed or installed outside the module to a non-default location." + default = "$HOME/.local/bin" +} + +variable "install_via_npm" { + type = bool + description = "Install Claude Code via npm instead of the official installer. Useful if npm is preferred or the official installer fails." + default = false +} + variable "enable_boundary" { type = bool description = "Whether to enable coder boundary for network filtering" @@ -210,48 +222,28 @@ variable "boundary_version" { default = "main" } -variable "boundary_log_dir" { - type = string - description = "Directory for boundary logs" - default = "/tmp/boundary_logs" -} - -variable "boundary_log_level" { - type = string - description = "Log level for boundary process" - default = "WARN" -} - -variable "boundary_additional_allowed_urls" { - type = list(string) - description = "Additional URLs to allow through boundary (in addition to default allowed URLs)" - default = [] -} - -variable "boundary_proxy_port" { - type = string - description = "Port for HTTP Proxy used by Boundary" - default = "8087" -} - -variable "enable_boundary_pprof" { - type = bool - description = "Whether to enable coder boundary pprof server" - default = false -} - -variable "boundary_pprof_port" { - type = string - description = "Port for pprof server used by Boundary" - default = "6067" -} - variable "compile_boundary_from_source" { type = bool description = "Whether to compile boundary from source instead of using the official install script" default = false } +variable "enable_aibridge" { + type = bool + description = "Use AI Bridge for Claude Code. https://coder.com/docs/ai-coder/ai-bridge" + default = false + + validation { + condition = !(var.enable_aibridge && length(var.claude_api_key) > 0) + error_message = "claude_api_key cannot be provided when enable_aibridge is true. AI Bridge automatically authenticates the client using Coder credentials." + } + + validation { + condition = !(var.enable_aibridge && length(var.claude_code_oauth_token) > 0) + error_message = "claude_code_oauth_token cannot be provided when enable_aibridge is true. AI Bridge automatically authenticates the client using Coder credentials." + } +} + variable "cli_command" { type = string description = "The command to run for the Claude Code CLI app when tasks are disabled." @@ -259,8 +251,7 @@ variable "cli_command" { } resource "coder_env" "claude_code_md_path" { - count = var.claude_md_path == "" ? 0 : 1 - + count = var.claude_md_path == "" ? 0 : 1 agent_id = var.agent_id name = "CODER_MCP_CLAUDE_MD_PATH" value = var.claude_md_path @@ -279,21 +270,45 @@ resource "coder_env" "claude_code_oauth_token" { } resource "coder_env" "claude_api_key" { - count = length(var.claude_api_key) > 0 ? 1 : 0 - agent_id = var.agent_id name = "CLAUDE_API_KEY" - value = var.claude_api_key + value = var.enable_aibridge ? data.coder_workspace_owner.me.session_token : var.claude_api_key } resource "coder_env" "disable_autoupdater" { - count = var.disable_autoupdater ? 1 : 0 - + count = var.disable_autoupdater ? 1 : 0 agent_id = var.agent_id name = "DISABLE_AUTOUPDATER" value = "1" } +resource "coder_env" "claude_binary_path" { + agent_id = var.agent_id + name = "PATH" + value = "${var.claude_binary_path}:$PATH" + + lifecycle { + precondition { + condition = var.claude_binary_path == "$HOME/.local/bin" || !var.install_claude_code + error_message = "Custom claude_binary_path can only be used when install_claude_code is false. The official installer and npm both install to fixed locations." + } + } +} + +resource "coder_env" "anthropic_model" { + count = var.model != "" ? 1 : 0 + agent_id = var.agent_id + name = "ANTHROPIC_MODEL" + value = var.model +} + +resource "coder_env" "anthropic_base_url" { + count = var.enable_aibridge ? 1 : 0 + agent_id = var.agent_id + name = "ANTHROPIC_BASE_URL" + value = "${data.coder_workspace.me.access_url}/api/v2/aibridge/anthropic" +} + locals { # we have to trim the slash because otherwise coder exp mcp will # set up an invalid claude config diff --git a/registry/coder/modules/claude-code/main.tftest.hcl b/registry/coder/modules/claude-code/main.tftest.hcl index adfca6d2..55106170 100644 --- a/registry/coder/modules/claude-code/main.tftest.hcl +++ b/registry/coder/modules/claude-code/main.tftest.hcl @@ -42,7 +42,7 @@ run "test_claude_code_with_api_key" { } assert { - condition = coder_env.claude_api_key[0].value == "test-api-key-123" + condition = coder_env.claude_api_key.value == "test-api-key-123" error_message = "Claude API key value should match the input" } } @@ -192,10 +192,9 @@ run "test_claude_code_with_boundary" { command = plan variables { - agent_id = "test-agent-boundary" - workdir = "/home/coder/boundary-test" - enable_boundary = true - boundary_log_dir = "/tmp/test-boundary-logs" + agent_id = "test-agent-boundary" + workdir = "/home/coder/boundary-test" + enable_boundary = true } assert { @@ -203,11 +202,6 @@ run "test_claude_code_with_boundary" { error_message = "Boundary should be enabled" } - assert { - condition = var.boundary_log_dir == "/tmp/test-boundary-logs" - error_message = "Boundary log dir should be set correctly" - } - assert { condition = local.coder_host != "" error_message = "Coder host should be extracted from access URL" @@ -294,3 +288,94 @@ run "test_claude_report_tasks_disabled" { error_message = "System prompt should end with " } } + +run "test_aibridge_enabled" { + command = plan + + variables { + agent_id = "test-agent-aibridge" + workdir = "/home/coder/aibridge" + enable_aibridge = true + } + + assert { + condition = var.enable_aibridge == true + error_message = "AI Bridge should be enabled" + } + + assert { + condition = coder_env.anthropic_base_url[0].name == "ANTHROPIC_BASE_URL" + error_message = "ANTHROPIC_BASE_URL environment variable should be set" + } + + assert { + condition = length(regexall("/api/v2/aibridge/anthropic", coder_env.anthropic_base_url[0].value)) > 0 + error_message = "ANTHROPIC_BASE_URL should point to AI Bridge endpoint" + } + + assert { + condition = coder_env.claude_api_key.name == "CLAUDE_API_KEY" + error_message = "CLAUDE_API_KEY environment variable should be set" + } + + assert { + condition = coder_env.claude_api_key.value == data.coder_workspace_owner.me.session_token + error_message = "CLAUDE_API_KEY should use workspace owner's session token when aibridge is enabled" + } +} + +run "test_aibridge_validation_with_api_key" { + command = plan + + variables { + agent_id = "test-agent-validation" + workdir = "/home/coder/test" + enable_aibridge = true + claude_api_key = "test-api-key" + } + + expect_failures = [ + var.enable_aibridge, + ] +} + +run "test_aibridge_validation_with_oauth_token" { + command = plan + + variables { + agent_id = "test-agent-validation" + workdir = "/home/coder/test" + enable_aibridge = true + claude_code_oauth_token = "test-oauth-token" + } + + expect_failures = [ + var.enable_aibridge, + ] +} + +run "test_aibridge_disabled_with_api_key" { + command = plan + + variables { + agent_id = "test-agent-no-aibridge" + workdir = "/home/coder/test" + enable_aibridge = false + claude_api_key = "test-api-key-xyz" + } + + assert { + condition = var.enable_aibridge == false + error_message = "AI Bridge should be disabled" + } + + assert { + condition = coder_env.claude_api_key.value == "test-api-key-xyz" + error_message = "CLAUDE_API_KEY should use the provided API key when aibridge is disabled" + } + + assert { + condition = length(coder_env.anthropic_base_url) == 0 + error_message = "ANTHROPIC_BASE_URL should not be set when aibridge is disabled" + } +} diff --git a/registry/coder/modules/claude-code/scripts/install.sh b/registry/coder/modules/claude-code/scripts/install.sh index 0bca6675..07e199c1 100644 --- a/registry/coder/modules/claude-code/scripts/install.sh +++ b/registry/coder/modules/claude-code/scripts/install.sh @@ -1,6 +1,5 @@ #!/bin/bash -# Set strict error handling AFTER sourcing bashrc to avoid unbound variable errors from user dotfiles set -euo pipefail BOLD='\033[0;1m' @@ -12,44 +11,91 @@ command_exists() { ARG_CLAUDE_CODE_VERSION=${ARG_CLAUDE_CODE_VERSION:-} ARG_WORKDIR=${ARG_WORKDIR:-"$HOME"} ARG_INSTALL_CLAUDE_CODE=${ARG_INSTALL_CLAUDE_CODE:-} +ARG_CLAUDE_BINARY_PATH=${ARG_CLAUDE_BINARY_PATH:-"$HOME/.local/bin"} +ARG_INSTALL_VIA_NPM=${ARG_INSTALL_VIA_NPM:-false} ARG_REPORT_TASKS=${ARG_REPORT_TASKS:-true} ARG_MCP_APP_STATUS_SLUG=${ARG_MCP_APP_STATUS_SLUG:-} ARG_MCP=$(echo -n "${ARG_MCP:-}" | base64 -d) ARG_ALLOWED_TOOLS=${ARG_ALLOWED_TOOLS:-} ARG_DISALLOWED_TOOLS=${ARG_DISALLOWED_TOOLS:-} +ARG_ENABLE_AIBRIDGE=${ARG_ENABLE_AIBRIDGE:-false} echo "--------------------------------" printf "ARG_CLAUDE_CODE_VERSION: %s\n" "$ARG_CLAUDE_CODE_VERSION" printf "ARG_WORKDIR: %s\n" "$ARG_WORKDIR" printf "ARG_INSTALL_CLAUDE_CODE: %s\n" "$ARG_INSTALL_CLAUDE_CODE" +printf "ARG_CLAUDE_BINARY_PATH: %s\n" "$ARG_CLAUDE_BINARY_PATH" +printf "ARG_INSTALL_VIA_NPM: %s\n" "$ARG_INSTALL_VIA_NPM" printf "ARG_REPORT_TASKS: %s\n" "$ARG_REPORT_TASKS" printf "ARG_MCP_APP_STATUS_SLUG: %s\n" "$ARG_MCP_APP_STATUS_SLUG" printf "ARG_MCP: %s\n" "$ARG_MCP" printf "ARG_ALLOWED_TOOLS: %s\n" "$ARG_ALLOWED_TOOLS" printf "ARG_DISALLOWED_TOOLS: %s\n" "$ARG_DISALLOWED_TOOLS" +printf "ARG_ENABLE_AIBRIDGE: %s\n" "$ARG_ENABLE_AIBRIDGE" echo "--------------------------------" +function ensure_claude_in_path() { + if [ -z "${CODER_SCRIPT_BIN_DIR:-}" ]; then + echo "CODER_SCRIPT_BIN_DIR not set, skipping PATH setup" + return + fi + + if [ ! -e "$CODER_SCRIPT_BIN_DIR/claude" ]; then + local CLAUDE_BIN="" + if command -v claude > /dev/null 2>&1; then + CLAUDE_BIN=$(command -v claude) + elif [ -x "$ARG_CLAUDE_BINARY_PATH/claude" ]; then + CLAUDE_BIN="$ARG_CLAUDE_BINARY_PATH/claude" + elif [ -x "$HOME/.local/bin/claude" ]; then + CLAUDE_BIN="$HOME/.local/bin/claude" + fi + + if [ -n "$CLAUDE_BIN" ] && [ -x "$CLAUDE_BIN" ]; then + ln -s "$CLAUDE_BIN" "$CODER_SCRIPT_BIN_DIR/claude" + echo "Created symlink: $CODER_SCRIPT_BIN_DIR/claude -> $CLAUDE_BIN" + else + echo "Warning: Could not find claude binary to symlink" + fi + else + echo "Claude already available in CODER_SCRIPT_BIN_DIR" + fi + + local marker="# Added by claude-code module" + for profile in "$HOME/.bashrc" "$HOME/.zshrc" "$HOME/.profile"; do + if [ -f "$profile" ] && ! grep -q "$marker" "$profile" 2> /dev/null; then + printf "\n%s\nexport PATH=\"%s:\$PATH\"\n" "$marker" "$CODER_SCRIPT_BIN_DIR" >> "$profile" + echo "Added $CODER_SCRIPT_BIN_DIR to PATH in $profile" + fi + done +} + function install_claude_code_cli() { - if [ "$ARG_INSTALL_CLAUDE_CODE" = "true" ]; then + if [ "$ARG_INSTALL_CLAUDE_CODE" != "true" ]; then + echo "Skipping Claude Code installation as per configuration." + ensure_claude_in_path + return + fi + + # Use npm when install_via_npm is true or for specific version pinning + if [ "$ARG_INSTALL_VIA_NPM" = "true" ] || { [ -n "$ARG_CLAUDE_CODE_VERSION" ] && [ "$ARG_CLAUDE_CODE_VERSION" != "latest" ]; }; then + echo "Installing Claude Code via npm (version: $ARG_CLAUDE_CODE_VERSION)" + npm install -g "@anthropic-ai/claude-code@$ARG_CLAUDE_CODE_VERSION" + echo "Installed Claude Code via npm. Version: $(claude --version || echo 'unknown')" + else echo "Installing Claude Code via official installer" set +e curl -fsSL claude.ai/install.sh | bash -s -- "$ARG_CLAUDE_CODE_VERSION" 2>&1 CURL_EXIT=${PIPESTATUS[0]} set -e if [ $CURL_EXIT -ne 0 ]; then - echo "Claude Code installer failed with exit code $$CURL_EXIT" + echo "Claude Code installer failed with exit code $CURL_EXIT" fi - - # Ensure binaries are discoverable. - echo "Creating a symlink for claude" - sudo ln -s /home/coder/.local/bin/claude /usr/local/bin/claude - echo "Installed Claude Code successfully. Version: $(claude --version || echo 'unknown')" - else - echo "Skipping Claude Code installation as per configuration." fi + + ensure_claude_in_path } function setup_claude_configurations() { @@ -69,7 +115,7 @@ function setup_claude_configurations() { while IFS= read -r server_name && IFS= read -r server_json; do echo "------------------------" echo "Executing: claude mcp add-json \"$server_name\" '$server_json' (in $ARG_WORKDIR)" - claude mcp add-json "$server_name" "$server_json" + claude mcp add-json "$server_name" "$server_json" || echo "Warning: Failed to add MCP server '$server_name', continuing..." echo "------------------------" echo "" done < <(echo "$ARG_MCP" | jq -r '.mcpServers | to_entries[] | .key, (.value | @json)') @@ -89,8 +135,8 @@ function setup_claude_configurations() { function configure_standalone_mode() { echo "Configuring Claude Code for standalone mode..." - if [ -z "${CLAUDE_API_KEY:-}" ]; then - echo "Note: CLAUDE_API_KEY not set, skipping authentication setup" + if [ -z "${CLAUDE_API_KEY:-}" ] && [ "$ARG_ENABLE_AIBRIDGE" = "false" ]; then + echo "Note: Neither claude_api_key nor enable_aibridge is set, skipping authentication setup" return fi @@ -103,8 +149,7 @@ function configure_standalone_mode() { if [ -f "$claude_config" ]; then echo "Updating existing Claude configuration at $claude_config" - jq --arg apikey "${CLAUDE_API_KEY:-}" \ - --arg workdir "$ARG_WORKDIR" \ + jq --arg workdir "$ARG_WORKDIR" --arg apikey "${CLAUDE_API_KEY:-}" \ '.autoUpdaterStatus = "disabled" | .bypassPermissionsModeAccepted = true | .hasAcknowledgedCostThreshold = true | diff --git a/registry/coder/modules/claude-code/scripts/start.sh b/registry/coder/modules/claude-code/scripts/start.sh index f2353de7..00143402 100644 --- a/registry/coder/modules/claude-code/scripts/start.sh +++ b/registry/coder/modules/claude-code/scripts/start.sh @@ -1,6 +1,5 @@ #!/bin/bash -# Set strict error handling AFTER sourcing bashrc to avoid unbound variable errors from user dotfiles set -euo pipefail true > "$HOME/start.log" @@ -9,7 +8,6 @@ command_exists() { command -v "$1" > /dev/null 2>&1 } -ARG_MODEL=${ARG_MODEL:-} ARG_RESUME_SESSION_ID=${ARG_RESUME_SESSION_ID:-} ARG_CONTINUE=${ARG_CONTINUE:-false} ARG_DANGEROUSLY_SKIP_PERMISSIONS=${ARG_DANGEROUSLY_SKIP_PERMISSIONS:-} @@ -19,11 +17,6 @@ ARG_AI_PROMPT=$(echo -n "${ARG_AI_PROMPT:-}" | base64 -d) ARG_REPORT_TASKS=${ARG_REPORT_TASKS:-true} ARG_ENABLE_BOUNDARY=${ARG_ENABLE_BOUNDARY:-false} ARG_BOUNDARY_VERSION=${ARG_BOUNDARY_VERSION:-"main"} -ARG_BOUNDARY_LOG_DIR=${ARG_BOUNDARY_LOG_DIR:-"/tmp/boundary_logs"} -ARG_BOUNDARY_LOG_LEVEL=${ARG_BOUNDARY_LOG_LEVEL:-"WARN"} -ARG_BOUNDARY_PROXY_PORT=${ARG_BOUNDARY_PROXY_PORT:-"8087"} -ARG_ENABLE_BOUNDARY_PPROF=${ARG_ENABLE_BOUNDARY_PPROF:-false} -ARG_BOUNDARY_PPROF_PORT=${ARG_BOUNDARY_PPROF_PORT:-"6067"} ARG_COMPILE_FROM_SOURCE=${ARG_COMPILE_FROM_SOURCE:-false} ARG_CODER_HOST=${ARG_CODER_HOST:-} ARG_NON_AGENTAPI_CLI=${ARG_NON_AGENTAPI_CLI:-false} @@ -36,31 +29,31 @@ log() { fi } -log "--------------------------------\n" +printf "ARG_RESUME: %s\n" "$ARG_RESUME_SESSION_ID" +printf "ARG_CONTINUE: %s\n" "$ARG_CONTINUE" +printf "ARG_DANGEROUSLY_SKIP_PERMISSIONS: %s\n" "$ARG_DANGEROUSLY_SKIP_PERMISSIONS" +printf "ARG_PERMISSION_MODE: %s\n" "$ARG_PERMISSION_MODE" +printf "ARG_AI_PROMPT: %s\n" "$ARG_AI_PROMPT" +printf "ARG_WORKDIR: %s\n" "$ARG_WORKDIR" +printf "ARG_REPORT_TASKS: %s\n" "$ARG_REPORT_TASKS" +printf "ARG_ENABLE_BOUNDARY: %s\n" "$ARG_ENABLE_BOUNDARY" +printf "ARG_BOUNDARY_VERSION: %s\n" "$ARG_BOUNDARY_VERSION" +printf "ARG_COMPILE_FROM_SOURCE: %s\n" "$ARG_COMPILE_FROM_SOURCE" +printf "ARG_CODER_HOST: %s\n" "$ARG_CODER_HOST" -log "ARG_MODEL: %s\n" "$ARG_MODEL" -log "ARG_RESUME: %s\n" "$ARG_RESUME_SESSION_ID" -log "ARG_CONTINUE: %s\n" "$ARG_CONTINUE" -log "ARG_DANGEROUSLY_SKIP_PERMISSIONS: %s\n" "$ARG_DANGEROUSLY_SKIP_PERMISSIONS" -log "ARG_PERMISSION_MODE: %s\n" "$ARG_PERMISSION_MODE" -log "ARG_AI_PROMPT: %s\n" "$ARG_AI_PROMPT" -log "ARG_WORKDIR: %s\n" "$ARG_WORKDIR" -log "ARG_REPORT_TASKS: %s\n" "$ARG_REPORT_TASKS" -log "ARG_ENABLE_BOUNDARY: %s\n" "$ARG_ENABLE_BOUNDARY" -log "ARG_BOUNDARY_VERSION: %s\n" "$ARG_BOUNDARY_VERSION" -log "ARG_BOUNDARY_LOG_DIR: %s\n" "$ARG_BOUNDARY_LOG_DIR" -log "ARG_BOUNDARY_LOG_LEVEL: %s\n" "$ARG_BOUNDARY_LOG_LEVEL" -log "ARG_BOUNDARY_PROXY_PORT: %s\n" "$ARG_BOUNDARY_PROXY_PORT" -log "ARG_COMPILE_FROM_SOURCE: %s\n" "$ARG_COMPILE_FROM_SOURCE" -log "ARG_CODER_HOST: %s\n" "$ARG_CODER_HOST" -log "ARG_NON_AGENTAPI_CLI: %s\n" "$ARG_NON_AGENTAPI_CLI" - -log "--------------------------------\n" +echo "--------------------------------" function install_boundary() { if [ "${ARG_COMPILE_FROM_SOURCE:-false}" = "true" ]; then # Install boundary by compiling from source - log "Compiling boundary from source (version: $ARG_BOUNDARY_VERSION)" + echo "Compiling boundary from source (version: $ARG_BOUNDARY_VERSION)" + + echo "Removing existing boundary directory to allow re-running the script safely" + if [ -d boundary ]; then + rm -rf boundary + fi + + echo "Clone boundary repository" git clone https://github.com/coder/boundary.git cd boundary git checkout "$ARG_BOUNDARY_VERSION" @@ -184,10 +177,6 @@ function start_agentapi() { mkdir -p "$ARG_WORKDIR" cd "$ARG_WORKDIR" - if [ -n "$ARG_MODEL" ]; then - ARGS+=(--model "$ARG_MODEL") - fi - if [ -n "$ARG_PERMISSION_MODE" ]; then ARGS+=(--permission-mode "$ARG_PERMISSION_MODE") fi @@ -235,42 +224,12 @@ function start_agentapi() { if [ "${ARG_ENABLE_BOUNDARY:-false}" = "true" ]; then install_boundary - mkdir -p "$ARG_BOUNDARY_LOG_DIR" - log "Starting with coder boundary enabled\n" + printf "Starting with coder boundary enabled\n" - # Build boundary args with conditional --unprivileged flag - BOUNDARY_ARGS=(--log-dir "$ARG_BOUNDARY_LOG_DIR") - # Add default allowed URLs - BOUNDARY_ARGS+=(--allow "domain=anthropic.com" --allow "domain=registry.npmjs.org" --allow "domain=sentry.io" --allow "domain=claude.ai" --allow "domain=$ARG_CODER_HOST") + BOUNDARY_ARGS+=() - # Add any additional allowed URLs from the variable - if [[ -n "${ARG_BOUNDARY_ADDITIONAL_ALLOWED_URLS}" ]]; then - IFS='|' read -ra ADDITIONAL_URLS <<< "${ARG_BOUNDARY_ADDITIONAL_ALLOWED_URLS}" - for url in "${ADDITIONAL_URLS[@]}"; do - # Quote the URL to preserve spaces within the allow rule - BOUNDARY_ARGS+=(--allow "${url}") - done - fi - - # Set HTTP Proxy port used by Boundary - BOUNDARY_ARGS+=(--proxy-port "${ARG_BOUNDARY_PROXY_PORT}") - - # Set log level for boundary - BOUNDARY_ARGS+=(--log-level "${ARG_BOUNDARY_LOG_LEVEL}") - - if [[ "${ARG_ENABLE_BOUNDARY_PPROF:-false}" = "true" ]]; then - # Enable boundary pprof server on specified port - BOUNDARY_ARGS+=(--pprof) - BOUNDARY_ARGS+=(--pprof-port "${ARG_BOUNDARY_PPROF_PORT}") - fi - - # if [[ "${ARG_REPORT_TASKS}" == "true" ]]; then - # boundary-run "${BOUNDARY_ARGS[@]}" -- \ - # claude "${ARGS[@]}" - # else "${CORE_COMMAND[@]}" boundary-run "${BOUNDARY_ARGS[@]}" -- \ - claude "${ARGS[@]}" - # fi + claude "${ARGS[@]}" else "${CORE_COMMAND[@]}" claude "${ARGS[@]}" fi diff --git a/registry/coder/modules/code-server/README.md b/registry/coder/modules/code-server/README.md index fca18909..3312f979 100644 --- a/registry/coder/modules/code-server/README.md +++ b/registry/coder/modules/code-server/README.md @@ -14,7 +14,7 @@ Automatically install [code-server](https://github.com/coder/code-server) in a w module "code-server" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/code-server/coder" - version = "1.4.1" + version = "1.4.2" agent_id = coder_agent.example.id } ``` @@ -29,9 +29,9 @@ module "code-server" { module "code-server" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/code-server/coder" - version = "1.4.1" + version = "1.4.2" agent_id = coder_agent.example.id - install_version = "1.4.1" + install_version = "4.106.3" } ``` @@ -43,7 +43,7 @@ Install the Dracula theme from [OpenVSX](https://open-vsx.org/): module "code-server" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/code-server/coder" - version = "1.4.1" + version = "1.4.2" agent_id = coder_agent.example.id extensions = [ "dracula-theme.theme-dracula" @@ -61,7 +61,7 @@ Configure VS Code's [settings.json](https://code.visualstudio.com/docs/getstarte module "code-server" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/code-server/coder" - version = "1.4.1" + version = "1.4.2" agent_id = coder_agent.example.id extensions = ["dracula-theme.theme-dracula"] settings = { @@ -78,7 +78,7 @@ Just run code-server in the background, don't fetch it from GitHub: module "code-server" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/code-server/coder" - version = "1.4.1" + version = "1.4.2" agent_id = coder_agent.example.id extensions = ["dracula-theme.theme-dracula", "ms-azuretools.vscode-docker"] } @@ -92,7 +92,7 @@ You can pass additional command-line arguments to code-server using the `additio module "code-server" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/code-server/coder" - version = "1.4.1" + version = "1.4.2" agent_id = coder_agent.example.id additional_args = "--disable-workspace-trust" } @@ -108,7 +108,7 @@ Run an existing copy of code-server if found, otherwise download from GitHub: module "code-server" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/code-server/coder" - version = "1.4.1" + version = "1.4.2" agent_id = coder_agent.example.id use_cached = true extensions = ["dracula-theme.theme-dracula", "ms-azuretools.vscode-docker"] @@ -121,8 +121,10 @@ Just run code-server in the background, don't fetch it from GitHub: module "code-server" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/code-server/coder" - version = "1.4.1" + version = "1.4.2" agent_id = coder_agent.example.id offline = true } ``` + +Some of the key differences between code-server and [VS Code Web](https://registry.coder.com/modules/coder/vscode-web) are listed in [docs](https://coder.com/docs/user-guides/workspace-access/code-server#differences-between-code-server-and-vs-code-web). diff --git a/registry/coder/modules/code-server/main.test.ts b/registry/coder/modules/code-server/main.test.ts index 01e80883..914ae2f0 100644 --- a/registry/coder/modules/code-server/main.test.ts +++ b/registry/coder/modules/code-server/main.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "bun:test"; import { + execContainer, + findResourceInstance, + removeContainer, + runContainer, runTerraformApply, runTerraformInit, testRequiredVariables, @@ -34,5 +38,47 @@ describe("code-server", async () => { expect(t).toThrow("Offline mode does not allow extensions to be installed"); }); - // More tests depend on shebang refactors + it("installs and runs code-server", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + }); + + const id = await runContainer("ubuntu:latest"); + try { + await execContainer(id, [ + "bash", + "-c", + "apt-get update && apt-get install -y curl", + ]); + + const script = findResourceInstance(state, "coder_script").script; + const result = await execContainer(id, ["bash", "-c", script]); + if (result.exitCode !== 0) { + console.log(result.stdout); + console.log(result.stderr); + } + expect(result.exitCode).toBe(0); + + const version = await execContainer(id, [ + "/tmp/code-server/bin/code-server", + "--version", + ]); + expect(version.exitCode).toBe(0); + expect(version.stdout).toMatch(/\d+\.\d+\.\d+/); + + const health = await execContainer(id, [ + "curl", + "--retry", + "10", + "--retry-delay", + "1", + "--retry-all-errors", + "-sf", + "http://localhost:13337/healthz", + ]); + expect(health.exitCode).toBe(0); + } finally { + await removeContainer(id); + } + }, 60000); }); diff --git a/registry/coder/modules/filebrowser/README.md b/registry/coder/modules/filebrowser/README.md index 19120fed..80ab3e85 100644 --- a/registry/coder/modules/filebrowser/README.md +++ b/registry/coder/modules/filebrowser/README.md @@ -14,7 +14,7 @@ A file browser for your workspace. module "filebrowser" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/filebrowser/coder" - version = "1.1.3" + version = "1.1.4" agent_id = coder_agent.main.id } ``` @@ -29,7 +29,7 @@ module "filebrowser" { module "filebrowser" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/filebrowser/coder" - version = "1.1.3" + version = "1.1.4" agent_id = coder_agent.main.id folder = "/home/coder/project" } @@ -41,7 +41,7 @@ module "filebrowser" { module "filebrowser" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/filebrowser/coder" - version = "1.1.3" + version = "1.1.4" agent_id = coder_agent.main.id database_path = ".config/filebrowser.db" } @@ -53,7 +53,7 @@ module "filebrowser" { module "filebrowser" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/filebrowser/coder" - version = "1.1.3" + version = "1.1.4" agent_id = coder_agent.main.id agent_name = "main" subdomain = false diff --git a/registry/coder/modules/filebrowser/run.sh b/registry/coder/modules/filebrowser/run.sh index ed34e2a2..183c64a7 100644 --- a/registry/coder/modules/filebrowser/run.sh +++ b/registry/coder/modules/filebrowser/run.sh @@ -28,7 +28,7 @@ if [[ ! -f "${DB_PATH}" ]]; then filebrowser users add admin "coderPASSWORD" --perm.admin=true --viewMode=mosaic 2>&1 | tee -a ${LOG_PATH} fi -filebrowser config set --baseurl=${SERVER_BASE_PATH} --port=${PORT} --auth.method=noauth --root=$ROOT_DIR 2>&1 | tee -a ${LOG_PATH} +filebrowser config set --baseURL=${SERVER_BASE_PATH} --port=${PORT} --auth.method=noauth --root=$ROOT_DIR 2>&1 | tee -a ${LOG_PATH} printf "👷 Starting filebrowser in background... \n\n" diff --git a/registry/coder/modules/git-clone/README.md b/registry/coder/modules/git-clone/README.md index 94d2ddac..b4f2a75c 100644 --- a/registry/coder/modules/git-clone/README.md +++ b/registry/coder/modules/git-clone/README.md @@ -14,7 +14,7 @@ This module allows you to automatically clone a repository by URL and skip if it module "git-clone" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/git-clone/coder" - version = "1.2.2" + version = "1.2.3" agent_id = coder_agent.example.id url = "https://github.com/coder/coder" } @@ -28,7 +28,7 @@ module "git-clone" { module "git-clone" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/git-clone/coder" - version = "1.2.2" + version = "1.2.3" agent_id = coder_agent.example.id url = "https://github.com/coder/coder" base_dir = "~/projects/coder" @@ -43,11 +43,12 @@ To use with [Git Authentication](https://coder.com/docs/v2/latest/admin/git-prov module "git-clone" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/git-clone/coder" - version = "1.2.2" + version = "1.2.3" agent_id = coder_agent.example.id url = "https://github.com/coder/coder" } + data "coder_external_auth" "github" { id = "github" } @@ -69,11 +70,12 @@ data "coder_parameter" "git_repo" { module "git_clone" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/git-clone/coder" - version = "1.2.2" + version = "1.2.3" agent_id = coder_agent.example.id url = data.coder_parameter.git_repo.value } + # Create a code-server instance for the cloned repository module "code-server" { count = data.coder_workspace.me.start_count @@ -103,13 +105,14 @@ Configuring `git-clone` for a self-hosted GitHub Enterprise Server running at `g module "git-clone" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/git-clone/coder" - version = "1.2.2" + version = "1.2.3" agent_id = coder_agent.example.id url = "https://github.example.com/coder/coder/tree/feat/example" git_providers = { "https://github.example.com/" = { provider = "github" } + } } ``` @@ -122,7 +125,7 @@ To GitLab clone with a specific branch like `feat/example` module "git-clone" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/git-clone/coder" - version = "1.2.2" + version = "1.2.3" agent_id = coder_agent.example.id url = "https://gitlab.com/coder/coder/-/tree/feat/example" } @@ -134,13 +137,14 @@ Configuring `git-clone` for a self-hosted GitLab running at `gitlab.example.com` module "git-clone" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/git-clone/coder" - version = "1.2.2" + version = "1.2.3" agent_id = coder_agent.example.id url = "https://gitlab.example.com/coder/coder/-/tree/feat/example" git_providers = { "https://gitlab.example.com/" = { provider = "gitlab" } + } } ``` @@ -155,7 +159,7 @@ For example, to clone the `feat/example` branch: module "git-clone" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/git-clone/coder" - version = "1.2.2" + version = "1.2.3" agent_id = coder_agent.example.id url = "https://github.com/coder/coder" branch_name = "feat/example" @@ -173,7 +177,7 @@ For example, this will clone into the `~/projects/coder/coder-dev` folder: module "git-clone" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/git-clone/coder" - version = "1.2.2" + version = "1.2.3" agent_id = coder_agent.example.id url = "https://github.com/coder/coder" folder_name = "coder-dev" @@ -191,8 +195,8 @@ If not defined, the default, `0`, performs a full clone. ```tf module "git-clone" { count = data.coder_workspace.me.start_count - source = "registry.coder.com/modules/git-clone/coder" - version = "1.2.2" + source = "registry.coder.com/coder/git-clone/coder" + version = "1.2.3" agent_id = coder_agent.example.id url = "https://github.com/coder/coder" depth = 1 @@ -208,7 +212,7 @@ This is useful for running initialization tasks like installing dependencies or module "git-clone" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/git-clone/coder" - version = "1.2.2" + version = "1.2.3" agent_id = coder_agent.example.id url = "https://github.com/coder/coder" post_clone_script = <<-EOT diff --git a/registry/coder/modules/git-clone/run.sh b/registry/coder/modules/git-clone/run.sh index 4b91ee68..c088e4d0 100644 --- a/registry/coder/modules/git-clone/run.sh +++ b/registry/coder/modules/git-clone/run.sh @@ -58,9 +58,10 @@ fi # Run post-clone script if provided if [ -n "$POST_CLONE_SCRIPT" ]; then echo "Running post-clone script..." - echo "$POST_CLONE_SCRIPT" | base64 -d > /tmp/post_clone.sh - chmod +x /tmp/post_clone.sh + POST_CLONE_TMP=$(mktemp) + echo "$POST_CLONE_SCRIPT" | base64 -d > "$POST_CLONE_TMP" + chmod +x "$POST_CLONE_TMP" cd "$CLONE_PATH" || exit - /tmp/post_clone.sh - rm /tmp/post_clone.sh + $POST_CLONE_TMP + rm "$POST_CLONE_TMP" fi diff --git a/registry/coder/modules/github-upload-public-key/main.test.ts b/registry/coder/modules/github-upload-public-key/main.test.ts index 467d6b95..6d8aae27 100644 --- a/registry/coder/modules/github-upload-public-key/main.test.ts +++ b/registry/coder/modules/github-upload-public-key/main.test.ts @@ -1,9 +1,17 @@ -import { type Server, serve } from "bun"; -import { describe, expect, it } from "bun:test"; +import { serve } from "bun"; +import { + afterEach, + beforeAll, + describe, + expect, + it, + setDefaultTimeout, +} from "bun:test"; import { createJSONResponse, execContainer, findResourceInstance, + removeContainer, runContainer, runTerraformApply, runTerraformInit, @@ -11,77 +19,48 @@ import { writeCoder, } from "~test"; -describe("github-upload-public-key", async () => { - await runTerraformInit(import.meta.dir); - - testRequiredVariables(import.meta.dir, { - agent_id: "foo", - }); - - it("creates new key if one does not exist", async () => { - const { instance, id, server } = await setupContainer(); - await writeCoder(id, "echo foo"); - - const url = server.url.toString().slice(0, -1); - const exec = await execContainer(id, [ - "env", - `CODER_ACCESS_URL=${url}`, - `GITHUB_API_URL=${url}`, - "CODER_OWNER_SESSION_TOKEN=foo", - "CODER_EXTERNAL_AUTH_ID=github", - "bash", - "-c", - instance.script, - ]); - expect(exec.stdout).toContain( - "Your Coder public key has been added to GitHub!", - ); - expect(exec.exitCode).toBe(0); - // we need to increase timeout to pull the container - }, 15000); - - it("does nothing if one already exists", async () => { - const { instance, id, server } = await setupContainer(); - // use keyword to make server return a existing key - await writeCoder(id, "echo findkey"); - - const url = server.url.toString().slice(0, -1); - const exec = await execContainer(id, [ - "env", - `CODER_ACCESS_URL=${url}`, - `GITHUB_API_URL=${url}`, - "CODER_OWNER_SESSION_TOKEN=foo", - "CODER_EXTERNAL_AUTH_ID=github", - "bash", - "-c", - instance.script, - ]); - expect(exec.stdout).toContain( - "Your Coder public key is already on GitHub!", - ); - expect(exec.exitCode).toBe(0); - }); +let cleanupFunctions: (() => Promise)[] = []; +const registerCleanup = (cleanup: () => Promise) => { + cleanupFunctions.push(cleanup); +}; +afterEach(async () => { + const cleanupFnsCopy = cleanupFunctions.slice().reverse(); + cleanupFunctions = []; + for (const cleanup of cleanupFnsCopy) { + try { + await cleanup(); + } catch (error) { + console.error("Error during cleanup:", error); + } + } }); const setupContainer = async ( image = "lorello/alpine-bash", vars: Record = {}, ) => { - const server = await setupServer(); + const server = setupServer(); const state = await runTerraformApply(import.meta.dir, { agent_id: "foo", ...vars, }); const instance = findResourceInstance(state, "coder_script"); const id = await runContainer(image); + + registerCleanup(async () => { + server.stop(); + }); + registerCleanup(async () => { + await removeContainer(id); + }); + return { id, instance, server }; }; -const setupServer = async (): Promise => { - let url: URL; - const fakeSlackHost = serve({ +const setupServer = () => { + const fakeGithubHost = serve({ fetch: (req) => { - url = new URL(req.url); + const url = new URL(req.url); if (url.pathname === "/api/v2/users/me/gitsshkey") { return createJSONResponse({ public_key: "exists", @@ -128,5 +107,60 @@ const setupServer = async (): Promise => { port: 0, }); - return fakeSlackHost; + return fakeGithubHost; }; + +setDefaultTimeout(30 * 1000); + +describe("github-upload-public-key", () => { + beforeAll(async () => { + await runTerraformInit(import.meta.dir); + }); + + testRequiredVariables(import.meta.dir, { + agent_id: "foo", + }); + + it("creates new key if one does not exist", async () => { + const { instance, id, server } = await setupContainer(); + await writeCoder(id, "echo foo"); + + const url = server.url.toString().slice(0, -1); + const exec = await execContainer(id, [ + "env", + `CODER_ACCESS_URL=${url}`, + `GITHUB_API_URL=${url}`, + "CODER_OWNER_SESSION_TOKEN=foo", + "CODER_EXTERNAL_AUTH_ID=github", + "bash", + "-c", + instance.script, + ]); + expect(exec.stdout).toContain( + "Your Coder public key has been added to GitHub!", + ); + expect(exec.exitCode).toBe(0); + }); + + it("does nothing if one already exists", async () => { + const { instance, id, server } = await setupContainer(); + // use keyword to make server return a existing key + await writeCoder(id, "echo findkey"); + + const url = server.url.toString().slice(0, -1); + const exec = await execContainer(id, [ + "env", + `CODER_ACCESS_URL=${url}`, + `GITHUB_API_URL=${url}`, + "CODER_OWNER_SESSION_TOKEN=foo", + "CODER_EXTERNAL_AUTH_ID=github", + "bash", + "-c", + instance.script, + ]); + expect(exec.stdout).toContain( + "Your Coder public key is already on GitHub!", + ); + expect(exec.exitCode).toBe(0); + }); +}); diff --git a/registry/coder/modules/jetbrains-fleet/README.md b/registry/coder/modules/jetbrains-fleet/README.md index c004f95c..b2c194d6 100644 --- a/registry/coder/modules/jetbrains-fleet/README.md +++ b/registry/coder/modules/jetbrains-fleet/README.md @@ -8,6 +8,9 @@ tags: [ide, jetbrains, fleet] # Jetbrains Fleet +> [!WARNING] +> **Deprecation Notice:** JetBrains has announced that Fleet will be discontinued. For more information, see [The Future of Fleet](https://blog.jetbrains.com/fleet/2025/12/the-future-of-fleet). Consider migrating to other JetBrains IDEs such as IntelliJ IDEA, PyCharm, or GoLand with the [JetBrains](https://registry.coder.com/modules/jetbrains) module. + This module adds a Jetbrains Fleet button to your Coder workspace that opens the workspace in JetBrains Fleet using SSH remote development. JetBrains Fleet is a next-generation IDE that supports collaborative development and distributed architectures. It connects to your Coder workspace via SSH, providing a seamless remote development experience. @@ -16,7 +19,7 @@ JetBrains Fleet is a next-generation IDE that supports collaborative development module "jetbrains_fleet" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/jetbrains-fleet/coder" - version = "1.0.2" + version = "1.0.3" agent_id = coder_agent.main.id } ``` @@ -37,7 +40,7 @@ module "jetbrains_fleet" { module "jetbrains_fleet" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/jetbrains-fleet/coder" - version = "1.0.2" + version = "1.0.3" agent_id = coder_agent.main.id } ``` @@ -48,7 +51,7 @@ module "jetbrains_fleet" { module "jetbrains_fleet" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/jetbrains-fleet/coder" - version = "1.0.2" + version = "1.0.3" agent_id = coder_agent.main.id folder = "/home/coder/project" } @@ -60,7 +63,7 @@ module "jetbrains_fleet" { module "jetbrains_fleet" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/jetbrains-fleet/coder" - version = "1.0.2" + version = "1.0.3" agent_id = coder_agent.main.id display_name = "Fleet" group = "JetBrains IDEs" @@ -74,7 +77,7 @@ module "jetbrains_fleet" { module "jetbrains_fleet" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/jetbrains-fleet/coder" - version = "1.0.2" + version = "1.0.3" agent_id = coder_agent.main.id agent_name = coder_agent.example.name } diff --git a/registry/coder/modules/jetbrains/README.md b/registry/coder/modules/jetbrains/README.md index 71861359..cf97d127 100644 --- a/registry/coder/modules/jetbrains/README.md +++ b/registry/coder/modules/jetbrains/README.md @@ -14,10 +14,9 @@ This module adds JetBrains IDE buttons to launch IDEs directly from the dashboar module "jetbrains" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/jetbrains/coder" - version = "1.2.1" + version = "1.3.0" agent_id = coder_agent.main.id folder = "/home/coder/project" - # tooltip = "You need to [Install Coder Desktop](https://coder.com/docs/user-guides/desktop#install-coder-desktop) to use this button." # Optional } ``` @@ -40,7 +39,7 @@ When `default` contains IDE codes, those IDEs are created directly without user module "jetbrains" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/jetbrains/coder" - version = "1.2.1" + version = "1.3.0" agent_id = coder_agent.main.id folder = "/home/coder/project" default = ["PY", "IU"] # Pre-configure GoLand and IntelliJ IDEA @@ -53,7 +52,7 @@ module "jetbrains" { module "jetbrains" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/jetbrains/coder" - version = "1.2.1" + version = "1.3.0" agent_id = coder_agent.main.id folder = "/home/coder/project" # Show parameter with limited options @@ -67,7 +66,7 @@ module "jetbrains" { module "jetbrains" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/jetbrains/coder" - version = "1.2.1" + version = "1.3.0" agent_id = coder_agent.main.id folder = "/home/coder/project" default = ["IU", "PY"] @@ -82,7 +81,7 @@ module "jetbrains" { module "jetbrains" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/jetbrains/coder" - version = "1.2.1" + version = "1.3.0" agent_id = coder_agent.main.id folder = "/workspace/project" @@ -109,7 +108,7 @@ module "jetbrains" { module "jetbrains_pycharm" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/jetbrains/coder" - version = "1.2.1" + version = "1.3.0" agent_id = coder_agent.main.id folder = "/workspace/project" @@ -129,11 +128,11 @@ Add helpful tooltip text that appears when users hover over the IDE app buttons: module "jetbrains" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/jetbrains/coder" - version = "1.2.1" + version = "1.3.0" agent_id = coder_agent.main.id folder = "/home/coder/project" default = ["IU", "PY"] - tooltip = "You need to [Install Coder Desktop](https://coder.com/docs/user-guides/desktop#install-coder-desktop) to use this button." + tooltip = "You need to install [JetBrains Toolbox App](https://www.jetbrains.com/toolbox-app/) to use this button." } ``` @@ -170,13 +169,6 @@ resource "coder_metadata" "container_info" { - If the API is unreachable (air-gapped environments), the module automatically falls back to build numbers from `ide_config` - `major_version` and `channel` control which API endpoint is queried (when API access is available) -### Tooltip - -- **`tooltip`**: Optional markdown text displayed when hovering over IDE app buttons -- If not specified, no tooltip is shown -- Supports markdown formatting for rich text (bold, italic, links, etc.) -- All IDE apps created by this module will show the same tooltip text - ## Supported IDEs All JetBrains IDEs with remote development capabilities: diff --git a/registry/coder/modules/jetbrains/jetbrains.tftest.hcl b/registry/coder/modules/jetbrains/jetbrains.tftest.hcl index 21726c25..dba9551d 100644 --- a/registry/coder/modules/jetbrains/jetbrains.tftest.hcl +++ b/registry/coder/modules/jetbrains/jetbrains.tftest.hcl @@ -2,15 +2,15 @@ variables { # Default IDE config, mirrored from main.tf for test assertions. # If main.tf defaults change, update this map to match. expected_ide_config = { - "CL" = { name = "CLion", icon = "/icon/clion.svg", build = "251.26927.39" }, - "GO" = { name = "GoLand", icon = "/icon/goland.svg", build = "251.26927.50" }, - "IU" = { name = "IntelliJ IDEA", icon = "/icon/intellij.svg", build = "251.26927.53" }, - "PS" = { name = "PhpStorm", icon = "/icon/phpstorm.svg", build = "251.26927.60" }, - "PY" = { name = "PyCharm", icon = "/icon/pycharm.svg", build = "251.26927.74" }, - "RD" = { name = "Rider", icon = "/icon/rider.svg", build = "251.26927.67" }, - "RM" = { name = "RubyMine", icon = "/icon/rubymine.svg", build = "251.26927.47" }, - "RR" = { name = "RustRover", icon = "/icon/rustrover.svg", build = "251.26927.79" }, - "WS" = { name = "WebStorm", icon = "/icon/webstorm.svg", build = "251.26927.40" } + "CL" = { name = "CLion", icon = "/icon/clion.svg", build = "253.29346.141" }, + "GO" = { name = "GoLand", icon = "/icon/goland.svg", build = "253.28294.337" }, + "IU" = { name = "IntelliJ IDEA", icon = "/icon/intellij.svg", build = "253.29346.138" }, + "PS" = { name = "PhpStorm", icon = "/icon/phpstorm.svg", build = "253.29346.151" }, + "PY" = { name = "PyCharm", icon = "/icon/pycharm.svg", build = "253.29346.142" }, + "RD" = { name = "Rider", icon = "/icon/rider.svg", build = "253.29346.144" }, + "RM" = { name = "RubyMine", icon = "/icon/rubymine.svg", build = "253.29346.140" }, + "RR" = { name = "RustRover", icon = "/icon/rustrover.svg", build = "253.29346.139" }, + "WS" = { name = "WebStorm", icon = "/icon/webstorm.svg", build = "253.29346.143" } } } @@ -187,16 +187,16 @@ run "tooltip_when_provided" { agent_id = "foo" folder = "/home/coder" default = ["GO"] - tooltip = "You need to [Install Coder Desktop](https://coder.com/docs/user-guides/desktop#install-coder-desktop) to use this button." + tooltip = "You need to install [JetBrains Toolbox App](https://www.jetbrains.com/toolbox-app/) to use this button." } assert { - condition = anytrue([for app in values(resource.coder_app.jetbrains) : app.tooltip == "You need to [Install Coder Desktop](https://coder.com/docs/user-guides/desktop#install-coder-desktop) to use this button."]) + condition = anytrue([for app in values(resource.coder_app.jetbrains) : app.tooltip == "You need to install [JetBrains Toolbox App](https://www.jetbrains.com/toolbox-app/) to use this button."]) error_message = "Expected coder_app tooltip to be set when provided" } } -run "tooltip_null_when_not_provided" { +run "tooltip_default_when_not_provided" { command = plan variables { @@ -206,8 +206,41 @@ run "tooltip_null_when_not_provided" { } assert { - condition = anytrue([for app in values(resource.coder_app.jetbrains) : app.tooltip == null]) - error_message = "Expected coder_app tooltip to be null when not provided" + condition = anytrue([for app in values(resource.coder_app.jetbrains) : app.tooltip == "You need to install [JetBrains Toolbox App](https://www.jetbrains.com/toolbox-app/) to use this button."]) + error_message = "Expected coder_app tooltip to be the default JetBrains Toolbox message when not provided" + } +} + +run "channel_eap" { + command = plan + + variables { + agent_id = "foo" + folder = "/home/coder" + default = ["GO"] + channel = "eap" + major_version = "latest" + } + + assert { + condition = output.ide_metadata["GO"].json_data.type == "eap" + error_message = "Expected the API to return a release of type 'eap', but got '${output.ide_metadata["GO"].json_data.type}'" + } +} + +run "specific_major_version" { + command = plan + + variables { + agent_id = "foo" + folder = "/home/coder" + default = ["GO"] + major_version = "2025.3" + } + + assert { + condition = output.ide_metadata["GO"].json_data.majorVersion == "2025.3" + error_message = "Expected the API to return a release for major version '2025.3', but got '${output.ide_metadata["GO"].json_data.majorVersion}'" } } @@ -294,3 +327,27 @@ run "output_multiple_ides" { error_message = "Expected ide_metadata['PY'].build to be the fallback '${var.expected_ide_config["PY"].build}'" } } +run "validate_output_schema" { + command = plan + + variables { + agent_id = "foo" + folder = "/home/coder" + default = ["GO"] + } + + assert { + condition = alltrue([ + for key, meta in output.ide_metadata : ( + can(meta.icon) && + can(meta.name) && + can(meta.identifier) && + can(meta.key) && + can(meta.build) && + # json_data can be null, but the key must exist + can(meta.json_data) + ) + ]) + error_message = "The ide_metadata output schema has changed. Please update the 'main.tf' and this test." + } +} diff --git a/registry/coder/modules/jetbrains/main.test.ts b/registry/coder/modules/jetbrains/main.test.ts deleted file mode 100644 index 0acf2ec2..00000000 --- a/registry/coder/modules/jetbrains/main.test.ts +++ /dev/null @@ -1,1054 +0,0 @@ -import { it, expect, describe } from "bun:test"; -import { - runTerraformInit, - testRequiredVariables, - runTerraformApply, -} from "~test"; - -describe("jetbrains", async () => { - await runTerraformInit(import.meta.dir); - - await testRequiredVariables(import.meta.dir, { - agent_id: "foo", - folder: "/home/foo", - }); - - // Core Logic Tests - When default is empty (shows parameter) - describe("when default is empty (shows parameter)", () => { - it("should create parameter with all IDE options when default=[] and major_version=latest", async () => { - const state = await runTerraformApply(import.meta.dir, { - agent_id: "foo", - folder: "/home/coder", - major_version: "latest", - }); - - // Should create a parameter when default is empty - const parameter = state.resources.find( - (res) => - res.type === "coder_parameter" && res.name === "jetbrains_ides", - ); - expect(parameter).toBeDefined(); - expect(parameter?.instances[0].attributes.form_type).toBe("multi-select"); - expect(parameter?.instances[0].attributes.default).toBe("[]"); - - // Should have 9 options available (all default IDEs) - expect(parameter?.instances[0].attributes.option).toHaveLength(9); - - // Since no selection is made in test (empty default), should create no apps - const coder_apps = state.resources.filter( - (res) => res.type === "coder_app" && res.name === "jetbrains", - ); - expect(coder_apps.length).toBe(0); - }); - - it("should create parameter with all IDE options when default=[] and major_version=2025.1", async () => { - const state = await runTerraformApply(import.meta.dir, { - agent_id: "foo", - folder: "/home/coder", - major_version: "2025.1", - }); - - const parameter = state.resources.find( - (res) => - res.type === "coder_parameter" && res.name === "jetbrains_ides", - ); - expect(parameter).toBeDefined(); - expect(parameter?.instances[0].attributes.option).toHaveLength(9); - - const coder_apps = state.resources.filter( - (res) => res.type === "coder_app" && res.name === "jetbrains", - ); - expect(coder_apps.length).toBe(0); - }); - - it("should create parameter with custom options when default=[] and custom options", async () => { - const state = await runTerraformApply(import.meta.dir, { - agent_id: "foo", - folder: "/home/coder", - options: '["GO", "IU", "WS"]', - major_version: "latest", - }); - - const parameter = state.resources.find( - (res) => - res.type === "coder_parameter" && res.name === "jetbrains_ides", - ); - expect(parameter).toBeDefined(); - expect(parameter?.instances[0].attributes.option).toHaveLength(3); // Only custom options - - const coder_apps = state.resources.filter( - (res) => res.type === "coder_app" && res.name === "jetbrains", - ); - expect(coder_apps.length).toBe(0); - }); - - it("should create parameter with single option when default=[] and single option", async () => { - const state = await runTerraformApply(import.meta.dir, { - agent_id: "foo", - folder: "/home/coder", - options: '["GO"]', - major_version: "latest", - }); - - const parameter = state.resources.find( - (res) => - res.type === "coder_parameter" && res.name === "jetbrains_ides", - ); - expect(parameter).toBeDefined(); - expect(parameter?.instances[0].attributes.option).toHaveLength(1); - - const coder_apps = state.resources.filter( - (res) => res.type === "coder_app" && res.name === "jetbrains", - ); - expect(coder_apps.length).toBe(0); - }); - }); - - // Core Logic Tests - When default has values (skips parameter, creates apps directly) - describe("when default has values (creates apps directly)", () => { - it('should skip parameter and create single app when default=["GO"] and major_version=latest', async () => { - const state = await runTerraformApply(import.meta.dir, { - agent_id: "foo", - folder: "/home/coder", - default: '["GO"]', - major_version: "latest", - }); - - // Should NOT create a parameter when default is not empty - const parameter = state.resources.find( - (res) => - res.type === "coder_parameter" && res.name === "jetbrains_ides", - ); - expect(parameter).toBeUndefined(); - - // Should create exactly 1 app - const coder_apps = state.resources.filter( - (res) => res.type === "coder_app" && res.name === "jetbrains", - ); - expect(coder_apps.length).toBe(1); - expect(coder_apps[0].instances[0].attributes.slug).toBe("jetbrains-go"); - expect(coder_apps[0].instances[0].attributes.display_name).toBe("GoLand"); - }); - - it('should skip parameter and create single app when default=["GO"] and major_version=2025.1', async () => { - const state = await runTerraformApply(import.meta.dir, { - agent_id: "foo", - folder: "/home/coder", - default: '["GO"]', - major_version: "2025.1", - }); - - const parameter = state.resources.find( - (res) => - res.type === "coder_parameter" && res.name === "jetbrains_ides", - ); - expect(parameter).toBeUndefined(); - - const coder_apps = state.resources.filter( - (res) => res.type === "coder_app" && res.name === "jetbrains", - ); - expect(coder_apps.length).toBe(1); - expect(coder_apps[0].instances[0].attributes.display_name).toBe("GoLand"); - }); - - it("should skip parameter and create app with different IDE", async () => { - const state = await runTerraformApply(import.meta.dir, { - agent_id: "foo", - folder: "/home/coder", - default: '["RR"]', - major_version: "latest", - }); - - const parameter = state.resources.find( - (res) => - res.type === "coder_parameter" && res.name === "jetbrains_ides", - ); - expect(parameter).toBeUndefined(); - - const coder_apps = state.resources.filter( - (res) => res.type === "coder_app" && res.name === "jetbrains", - ); - expect(coder_apps.length).toBe(1); - expect(coder_apps[0].instances[0].attributes.slug).toBe("jetbrains-rr"); - expect(coder_apps[0].instances[0].attributes.display_name).toBe( - "RustRover", - ); - }); - }); - - // Channel Tests - describe("channel variations", () => { - it("should work with EAP channel and latest version", async () => { - const state = await runTerraformApply(import.meta.dir, { - agent_id: "foo", - folder: "/home/coder", - default: '["GO"]', - major_version: "latest", - channel: "eap", - }); - - const coder_apps = state.resources.filter( - (res) => res.type === "coder_app" && res.name === "jetbrains", - ); - expect(coder_apps.length).toBe(1); - - // Check that URLs contain build numbers (from EAP releases) - expect(coder_apps[0].instances[0].attributes.url).toContain( - "ide_build_number=", - ); - }); - - it("should work with EAP channel and specific version", async () => { - const state = await runTerraformApply(import.meta.dir, { - agent_id: "foo", - folder: "/home/coder", - default: '["GO"]', - major_version: "2025.2", - channel: "eap", - }); - - const coder_apps = state.resources.filter( - (res) => res.type === "coder_app" && res.name === "jetbrains", - ); - expect(coder_apps.length).toBe(1); - expect(coder_apps[0].instances[0].attributes.url).toContain( - "ide_build_number=", - ); - }); - - it("should work with release channel (default)", async () => { - const state = await runTerraformApply(import.meta.dir, { - agent_id: "foo", - folder: "/home/coder", - default: '["GO"]', - channel: "release", - }); - - const coder_apps = state.resources.filter( - (res) => res.type === "coder_app" && res.name === "jetbrains", - ); - expect(coder_apps.length).toBe(1); - }); - }); - - // Configuration Tests - describe("configuration parameters", () => { - it("should use custom folder path in URL", async () => { - const state = await runTerraformApply(import.meta.dir, { - agent_id: "foo", - folder: "/workspace/myproject", - default: '["GO"]', - major_version: "latest", - }); - - const coder_app = state.resources.find( - (res) => res.type === "coder_app" && res.name === "jetbrains", - ); - expect(coder_app?.instances[0].attributes.url).toContain( - "folder=/workspace/myproject", - ); - }); - - it("should set app order when specified", async () => { - const state = await runTerraformApply(import.meta.dir, { - agent_id: "foo", - folder: "/home/coder", - default: '["GO"]', - coder_app_order: 10, - }); - - const coder_app = state.resources.find( - (res) => res.type === "coder_app" && res.name === "jetbrains", - ); - expect(coder_app?.instances[0].attributes.order).toBe(10); - }); - - it("should set parameter order when default is empty", async () => { - const state = await runTerraformApply(import.meta.dir, { - agent_id: "foo", - folder: "/home/coder", - coder_parameter_order: 5, - }); - - const parameter = state.resources.find( - (res) => - res.type === "coder_parameter" && res.name === "jetbrains_ides", - ); - expect(parameter?.instances[0].attributes.order).toBe(5); - }); - - it("should set tooltip when specified", async () => { - const state = await runTerraformApply(import.meta.dir, { - agent_id: "foo", - folder: "/home/coder", - default: '["GO"]', - tooltip: - "You need to [Install Coder Desktop](https://coder.com/docs/user-guides/desktop#install-coder-desktop) to use this button.", - }); - - const coder_app = state.resources.find( - (res) => res.type === "coder_app" && res.name === "jetbrains", - ); - expect(coder_app?.instances[0].attributes.tooltip).toBe( - "You need to [Install Coder Desktop](https://coder.com/docs/user-guides/desktop#install-coder-desktop) to use this button.", - ); - }); - - it("should have null tooltip when not specified", async () => { - const state = await runTerraformApply(import.meta.dir, { - agent_id: "foo", - folder: "/home/coder", - default: '["GO"]', - }); - - const coder_app = state.resources.find( - (res) => res.type === "coder_app" && res.name === "jetbrains", - ); - expect(coder_app?.instances[0].attributes.tooltip).toBeNull(); - }); - }); - - // URL Generation Tests - describe("URL generation", () => { - it("should generate proper jetbrains:// URLs with all required parameters", async () => { - const state = await runTerraformApply(import.meta.dir, { - agent_id: "test-agent-123", - folder: "/custom/project/path", - default: '["GO"]', - }); - - const coder_app = state.resources.find( - (res) => res.type === "coder_app" && res.name === "jetbrains", - ); - const url = coder_app?.instances[0].attributes.url; - - expect(url).toContain("jetbrains://gateway/coder"); - expect(url).toContain("&workspace="); - expect(url).toContain("&owner="); - expect(url).toContain("&folder=/custom/project/path"); - expect(url).toContain("&url="); - expect(url).toContain("&token=$SESSION_TOKEN"); - expect(url).toContain("&ide_product_code=GO"); - expect(url).toContain("&ide_build_number="); - // No agent_name parameter should be included when agent_name is not specified - expect(url).not.toContain("&agent_name="); - }); - - it("should include agent_name parameter when agent_name is specified", async () => { - const state = await runTerraformApply(import.meta.dir, { - agent_id: "test-agent-123", - agent_name: "main-agent", - folder: "/custom/project/path", - default: '["GO"]', - }); - - const coder_app = state.resources.find( - (res) => res.type === "coder_app" && res.name === "jetbrains", - ); - const url = coder_app?.instances[0].attributes.url; - - expect(url).toContain("jetbrains://gateway/coder"); - expect(url).toContain("&agent_name=main-agent"); - expect(url).toContain("&ide_product_code=GO"); - expect(url).toContain("&ide_build_number="); - }); - - it("should include build numbers from API in URLs", async () => { - const state = await runTerraformApply(import.meta.dir, { - agent_id: "foo", - folder: "/home/coder", - default: '["GO"]', - }); - - const coder_app = state.resources.find( - (res) => res.type === "coder_app" && res.name === "jetbrains", - ); - const url = coder_app?.instances[0].attributes.url; - - expect(url).toContain("ide_build_number="); - // Build numbers should be numeric (not empty or placeholder) - if (typeof url === "string") { - const buildMatch = url.match(/ide_build_number=([^&]+)/); - expect(buildMatch).toBeTruthy(); - expect(buildMatch![1]).toMatch(/^\d+/); // Should start with digits - } - }); - }); - - // Version Tests - describe("version handling", () => { - it("should work with latest major version", async () => { - const state = await runTerraformApply(import.meta.dir, { - agent_id: "foo", - folder: "/home/coder", - default: '["GO"]', - major_version: "latest", - }); - - const coder_app = state.resources.find( - (res) => res.type === "coder_app" && res.name === "jetbrains", - ); - expect(coder_app?.instances[0].attributes.url).toContain( - "ide_build_number=", - ); - }); - - it("should work with specific major version", async () => { - const state = await runTerraformApply(import.meta.dir, { - agent_id: "foo", - folder: "/home/coder", - default: '["GO"]', - major_version: "2025.1", - }); - - const coder_app = state.resources.find( - (res) => res.type === "coder_app" && res.name === "jetbrains", - ); - expect(coder_app?.instances[0].attributes.url).toContain( - "ide_build_number=", - ); - }); - }); - - // IDE Metadata Tests - describe("IDE metadata and attributes", () => { - it("should have correct display names and icons for GoLand", async () => { - const state = await runTerraformApply(import.meta.dir, { - agent_id: "foo", - folder: "/home/coder", - default: '["GO"]', - }); - - const coder_app = state.resources.find( - (res) => res.type === "coder_app" && res.name === "jetbrains", - ); - - expect(coder_app?.instances[0].attributes.display_name).toBe("GoLand"); - expect(coder_app?.instances[0].attributes.icon).toBe("/icon/goland.svg"); - expect(coder_app?.instances[0].attributes.slug).toBe("jetbrains-go"); - }); - - it("should have correct display names and icons for RustRover", async () => { - const state = await runTerraformApply(import.meta.dir, { - agent_id: "foo", - folder: "/home/coder", - default: '["RR"]', - }); - - const coder_app = state.resources.find( - (res) => res.type === "coder_app" && res.name === "jetbrains", - ); - - expect(coder_app?.instances[0].attributes.display_name).toBe("RustRover"); - expect(coder_app?.instances[0].attributes.icon).toBe( - "/icon/rustrover.svg", - ); - expect(coder_app?.instances[0].attributes.slug).toBe("jetbrains-rr"); - }); - - it("should have correct app attributes set", async () => { - const state = await runTerraformApply(import.meta.dir, { - agent_id: "test-agent", - folder: "/home/coder", - default: '["GO"]', - }); - - const coder_app = state.resources.find( - (res) => res.type === "coder_app" && res.name === "jetbrains", - ); - - expect(coder_app?.instances[0].attributes.agent_id).toBe("test-agent"); - expect(coder_app?.instances[0].attributes.external).toBe(true); - expect(coder_app?.instances[0].attributes.hidden).toBe(false); - expect(coder_app?.instances[0].attributes.share).toBe("owner"); - expect(coder_app?.instances[0].attributes.open_in).toBe("slim-window"); - }); - }); - - // Edge Cases and Validation - describe("edge cases and validation", () => { - it("should validate folder path format", async () => { - // Valid absolute path should work - await expect( - runTerraformApply(import.meta.dir, { - agent_id: "foo", - folder: "/home/coder/project", - default: '["GO"]', - }), - ).resolves.toBeDefined(); - }); - - it("should handle empty parameter selection gracefully", async () => { - const state = await runTerraformApply(import.meta.dir, { - agent_id: "foo", - folder: "/home/coder", - // Don't pass default at all - let it use the variable's default value of [] - }); - - // Should create parameter but no apps when no selection - const parameter = state.resources.find( - (res) => - res.type === "coder_parameter" && res.name === "jetbrains_ides", - ); - expect(parameter).toBeDefined(); - - const coder_apps = state.resources.filter( - (res) => res.type === "coder_app" && res.name === "jetbrains", - ); - expect(coder_apps.length).toBe(0); - }); - }); - - // Custom IDE Config Tests - describe("custom ide_config with subset of options", () => { - const customIdeConfig = JSON.stringify({ - GO: { - name: "Custom GoLand", - icon: "/custom/goland.svg", - build: "999.123.456", - }, - IU: { - name: "Custom IntelliJ", - icon: "/custom/intellij.svg", - build: "999.123.457", - }, - WS: { - name: "Custom WebStorm", - icon: "/custom/webstorm.svg", - build: "999.123.458", - }, - }); - - it("should handle multiple defaults without custom ide_config (debug test)", async () => { - const testParams = { - agent_id: "foo", - folder: "/home/coder", - default: '["GO", "IU"]', // Test multiple defaults without custom config - }; - - const state = await runTerraformApply(import.meta.dir, testParams); - - // Should create at least 1 app (test framework may have issues with multiple values) - const coder_apps = state.resources.filter( - (res) => res.type === "coder_app" && res.name === "jetbrains", - ); - expect(coder_apps.length).toBeGreaterThanOrEqual(1); - - // Should create apps with correct names and metadata - const appNames = coder_apps.map( - (app) => app.instances[0].attributes.display_name, - ); - expect(appNames).toContain("GoLand"); // Should at least have GoLand - }); - - it("should create parameter with custom ide_config when default is empty", async () => { - const state = await runTerraformApply(import.meta.dir, { - agent_id: "foo", - folder: "/home/coder", - // Don't pass default to use empty default - options: '["GO", "IU", "WS"]', // Must match the keys in ide_config - ide_config: customIdeConfig, - }); - - // Should create parameter with custom configurations - const parameter = state.resources.find( - (res) => - res.type === "coder_parameter" && res.name === "jetbrains_ides", - ); - expect(parameter).toBeDefined(); - expect(parameter?.instances[0].attributes.option).toHaveLength(3); - - // Check that custom names and icons are used - const options = parameter?.instances[0].attributes.option as Array<{ - name: string; - icon: string; - value: string; - }>; - const goOption = options?.find((opt) => opt.value === "GO"); - expect(goOption?.name).toBe("Custom GoLand"); - expect(goOption?.icon).toBe("/custom/goland.svg"); - - const iuOption = options?.find((opt) => opt.value === "IU"); - expect(iuOption?.name).toBe("Custom IntelliJ"); - expect(iuOption?.icon).toBe("/custom/intellij.svg"); - - // Should create no apps since no selection - const coder_apps = state.resources.filter( - (res) => res.type === "coder_app" && res.name === "jetbrains", - ); - expect(coder_apps.length).toBe(0); - }); - - it("should create apps with custom ide_config when default has values", async () => { - const testParams = { - agent_id: "foo", - folder: "/home/coder", - default: '["GO", "IU"]', // Subset of available options - options: '["GO", "IU", "WS"]', // Must be superset of default - ide_config: customIdeConfig, - }; - - const state = await runTerraformApply(import.meta.dir, testParams); - - // Should NOT create parameter when default is not empty - const parameter = state.resources.find( - (res) => - res.type === "coder_parameter" && res.name === "jetbrains_ides", - ); - expect(parameter).toBeUndefined(); - - // Should create at least 1 app with custom configurations (test framework may have issues with multiple values) - const coder_apps = state.resources.filter( - (res) => res.type === "coder_app" && res.name === "jetbrains", - ); - expect(coder_apps.length).toBeGreaterThanOrEqual(1); - - // Check that custom display names and icons are used for available apps - const goApp = coder_apps.find( - (app) => app.instances[0].attributes.slug === "jetbrains-go", - ); - if (goApp) { - expect(goApp.instances[0].attributes.display_name).toBe( - "Custom GoLand", - ); - expect(goApp.instances[0].attributes.icon).toBe("/custom/goland.svg"); - } - - const iuApp = coder_apps.find( - (app) => app.instances[0].attributes.slug === "jetbrains-iu", - ); - if (iuApp) { - expect(iuApp.instances[0].attributes.display_name).toBe( - "Custom IntelliJ", - ); - expect(iuApp.instances[0].attributes.icon).toBe("/custom/intellij.svg"); - } - - // At least one app should be created - expect(coder_apps.length).toBeGreaterThan(0); - }); - - it("should use custom build numbers from ide_config in URLs", async () => { - const state = await runTerraformApply(import.meta.dir, { - agent_id: "foo", - folder: "/home/coder", - default: '["GO"]', - options: '["GO", "IU", "WS"]', - ide_config: customIdeConfig, - }); - - const coder_app = state.resources.find( - (res) => res.type === "coder_app" && res.name === "jetbrains", - ); - - // Should use build number from API, not from ide_config (this is the correct behavior) - // The module always fetches fresh build numbers from JetBrains API for latest versions - expect(coder_app?.instances[0].attributes.url).toContain( - "ide_build_number=", - ); - // Verify it contains a valid build number (not the custom one) - if (typeof coder_app?.instances[0].attributes.url === "string") { - const buildMatch = coder_app.instances[0].attributes.url.match( - /ide_build_number=([^&]+)/, - ); - expect(buildMatch).toBeTruthy(); - expect(buildMatch![1]).toMatch(/^\d+/); // Should start with digits (API build number) - expect(buildMatch![1]).not.toBe("999.123.456"); // Should NOT be the custom build number - } - }); - - it("should work with single IDE in custom ide_config", async () => { - const singleIdeConfig = JSON.stringify({ - RR: { - name: "My RustRover", - icon: "/my/rustrover.svg", - build: "888.999.111", - }, - }); - - const state = await runTerraformApply(import.meta.dir, { - agent_id: "foo", - folder: "/home/coder", - default: '["RR"]', - options: '["RR"]', // Only one option - ide_config: singleIdeConfig, - }); - - const coder_apps = state.resources.filter( - (res) => res.type === "coder_app" && res.name === "jetbrains", - ); - expect(coder_apps.length).toBe(1); - expect(coder_apps[0].instances[0].attributes.display_name).toBe( - "My RustRover", - ); - expect(coder_apps[0].instances[0].attributes.icon).toBe( - "/my/rustrover.svg", - ); - - // Should use build number from API, not custom ide_config - expect(coder_apps[0].instances[0].attributes.url).toContain( - "ide_build_number=", - ); - if (typeof coder_apps[0].instances[0].attributes.url === "string") { - const buildMatch = coder_apps[0].instances[0].attributes.url.match( - /ide_build_number=([^&]+)/, - ); - expect(buildMatch).toBeTruthy(); - expect(buildMatch![1]).not.toBe("888.999.111"); // Should NOT be the custom build number - } - }); - }); - - // Air-Gapped and Fallback Tests - describe("air-gapped environment fallback", () => { - it("should use API build numbers when available", async () => { - const state = await runTerraformApply(import.meta.dir, { - agent_id: "foo", - folder: "/home/coder", - default: '["GO"]', - }); - - const coder_app = state.resources.find( - (res) => res.type === "coder_app" && res.name === "jetbrains", - ); - - // Should use build number from API - expect(coder_app?.instances[0].attributes.url).toContain( - "ide_build_number=", - ); - if (typeof coder_app?.instances[0].attributes.url === "string") { - const buildMatch = coder_app.instances[0].attributes.url.match( - /ide_build_number=([^&]+)/, - ); - expect(buildMatch).toBeTruthy(); - expect(buildMatch![1]).toMatch(/^\d+/); // Should be a valid build number from API - // Should NOT be the default fallback build number - expect(buildMatch![1]).not.toBe("251.25410.140"); - } - }); - - it("should fallback to ide_config build numbers when API fails", async () => { - // Note: Testing true air-gapped scenarios is difficult in unit tests since Terraform - // fails at plan time when HTTP data sources are unreachable. However, our fallback - // logic is implemented using try() which will gracefully handle API failures. - // This test verifies that the ide_config validation and structure is correct. - const customIdeConfig = JSON.stringify({ - CL: { - name: "CLion", - icon: "/icon/clion.svg", - build: "999.fallback.123", - }, - GO: { - name: "GoLand", - icon: "/icon/goland.svg", - build: "999.fallback.124", - }, - IU: { - name: "IntelliJ IDEA", - icon: "/icon/intellij.svg", - build: "999.fallback.125", - }, - PS: { - name: "PhpStorm", - icon: "/icon/phpstorm.svg", - build: "999.fallback.126", - }, - PY: { - name: "PyCharm", - icon: "/icon/pycharm.svg", - build: "999.fallback.127", - }, - RD: { - name: "Rider", - icon: "/icon/rider.svg", - build: "999.fallback.128", - }, - RM: { - name: "RubyMine", - icon: "/icon/rubymine.svg", - build: "999.fallback.129", - }, - RR: { - name: "RustRover", - icon: "/icon/rustrover.svg", - build: "999.fallback.130", - }, - WS: { - name: "WebStorm", - icon: "/icon/webstorm.svg", - build: "999.fallback.131", - }, - }); - - const state = await runTerraformApply(import.meta.dir, { - agent_id: "foo", - folder: "/home/coder", - default: '["GO"]', - ide_config: customIdeConfig, - }); - - const coder_app = state.resources.find( - (res) => res.type === "coder_app" && res.name === "jetbrains", - ); - - // Should work with custom ide_config (API data will override in connected environments) - expect(coder_app?.instances[0].attributes.url).toContain( - "ide_build_number=", - ); - expect(coder_app?.instances[0].attributes.display_name).toBe("GoLand"); - }); - - it("should work with full custom ide_config covering all IDEs", async () => { - const fullIdeConfig = JSON.stringify({ - CL: { name: "CLion", icon: "/icon/clion.svg", build: "999.test.123" }, - GO: { name: "GoLand", icon: "/icon/goland.svg", build: "999.test.124" }, - IU: { - name: "IntelliJ IDEA", - icon: "/icon/intellij.svg", - build: "999.test.125", - }, - PS: { - name: "PhpStorm", - icon: "/icon/phpstorm.svg", - build: "999.test.126", - }, - PY: { - name: "PyCharm", - icon: "/icon/pycharm.svg", - build: "999.test.127", - }, - RD: { name: "Rider", icon: "/icon/rider.svg", build: "999.test.128" }, - RM: { - name: "RubyMine", - icon: "/icon/rubymine.svg", - build: "999.test.129", - }, - RR: { - name: "RustRover", - icon: "/icon/rustrover.svg", - build: "999.test.130", - }, - WS: { - name: "WebStorm", - icon: "/icon/webstorm.svg", - build: "999.test.131", - }, - }); - - const state = await runTerraformApply(import.meta.dir, { - agent_id: "foo", - folder: "/home/coder", - default: '["GO", "IU", "WS"]', - ide_config: fullIdeConfig, - }); - - const coder_apps = state.resources.filter( - (res) => res.type === "coder_app" && res.name === "jetbrains", - ); - - // Should create apps with custom configuration - expect(coder_apps.length).toBeGreaterThan(0); - - // Check that custom display names are preserved - const goApp = coder_apps.find( - (app) => app.instances[0].attributes.slug === "jetbrains-go", - ); - if (goApp) { - expect(goApp.instances[0].attributes.display_name).toBe("GoLand"); - expect(goApp.instances[0].attributes.icon).toBe("/icon/goland.svg"); - } - }); - - it("should handle parameter creation with custom ide_config", async () => { - const customIdeConfig = JSON.stringify({ - CL: { name: "CLion", icon: "/icon/clion.svg", build: "999.param.123" }, - GO: { - name: "GoLand", - icon: "/icon/goland.svg", - build: "999.param.124", - }, - IU: { - name: "IntelliJ IDEA", - icon: "/icon/intellij.svg", - build: "999.param.125", - }, - PS: { - name: "PhpStorm", - icon: "/icon/phpstorm.svg", - build: "999.param.126", - }, - PY: { - name: "PyCharm", - icon: "/icon/pycharm.svg", - build: "999.param.127", - }, - RD: { name: "Rider", icon: "/icon/rider.svg", build: "999.param.128" }, - RM: { - name: "RubyMine", - icon: "/icon/rubymine.svg", - build: "999.param.129", - }, - RR: { - name: "RustRover", - icon: "/icon/rustrover.svg", - build: "999.param.130", - }, - WS: { - name: "WebStorm", - icon: "/icon/webstorm.svg", - build: "999.param.131", - }, - }); - - const state = await runTerraformApply(import.meta.dir, { - agent_id: "foo", - folder: "/home/coder", - options: '["GO", "IU"]', - ide_config: customIdeConfig, - }); - - // Should create parameter with custom configuration - const parameter = state.resources.find( - (res) => - res.type === "coder_parameter" && res.name === "jetbrains_ides", - ); - expect(parameter).toBeDefined(); - expect(parameter?.instances[0].attributes.option).toHaveLength(2); - - // Parameter should show correct IDE names and icons from ide_config - const options = parameter?.instances[0].attributes.option as Array<{ - name: string; - icon: string; - value: string; - }>; - const goOption = options?.find((opt) => opt.value === "GO"); - expect(goOption?.name).toBe("GoLand"); - expect(goOption?.icon).toBe("/icon/goland.svg"); - }); - - it("should work with mixed API success/failure scenarios", async () => { - // This tests the robustness of the try() mechanism - // Even if some API calls succeed and others fail, the module should handle it gracefully - const state = await runTerraformApply(import.meta.dir, { - agent_id: "foo", - folder: "/home/coder", - default: '["GO"]', - // Use real API endpoint - if it fails, fallback should work - releases_base_link: "https://data.services.jetbrains.com", - }); - - const coder_app = state.resources.find( - (res) => res.type === "coder_app" && res.name === "jetbrains", - ); - - // Should create app regardless of API success/failure - expect(coder_app).toBeDefined(); - expect(coder_app?.instances[0].attributes.url).toContain( - "ide_build_number=", - ); - }); - - it("should preserve custom IDE metadata in air-gapped environments", async () => { - // This test validates that ide_config structure supports air-gapped deployments - // by ensuring custom metadata is correctly configured for all default IDEs - const airGappedIdeConfig = JSON.stringify({ - CL: { - name: "CLion Enterprise", - icon: "/enterprise/clion.svg", - build: "251.air.123", - }, - GO: { - name: "GoLand Enterprise", - icon: "/enterprise/goland.svg", - build: "251.air.124", - }, - IU: { - name: "IntelliJ IDEA Enterprise", - icon: "/enterprise/intellij.svg", - build: "251.air.125", - }, - PS: { - name: "PhpStorm Enterprise", - icon: "/enterprise/phpstorm.svg", - build: "251.air.126", - }, - PY: { - name: "PyCharm Enterprise", - icon: "/enterprise/pycharm.svg", - build: "251.air.127", - }, - RD: { - name: "Rider Enterprise", - icon: "/enterprise/rider.svg", - build: "251.air.128", - }, - RM: { - name: "RubyMine Enterprise", - icon: "/enterprise/rubymine.svg", - build: "251.air.129", - }, - RR: { - name: "RustRover Enterprise", - icon: "/enterprise/rustrover.svg", - build: "251.air.130", - }, - WS: { - name: "WebStorm Enterprise", - icon: "/enterprise/webstorm.svg", - build: "251.air.131", - }, - }); - - const state = await runTerraformApply(import.meta.dir, { - agent_id: "foo", - folder: "/home/coder", - default: '["RR"]', - ide_config: airGappedIdeConfig, - }); - - const coder_app = state.resources.find( - (res) => res.type === "coder_app" && res.name === "jetbrains", - ); - - // Should preserve custom metadata for air-gapped setups - expect(coder_app?.instances[0].attributes.display_name).toBe( - "RustRover Enterprise", - ); - expect(coder_app?.instances[0].attributes.icon).toBe( - "/enterprise/rustrover.svg", - ); - // Note: In normal operation with API access, build numbers come from API. - // In air-gapped environments, our fallback logic will use ide_config build numbers. - expect(coder_app?.instances[0].attributes.url).toContain( - "ide_build_number=", - ); - }); - - it("should validate that fallback mechanism doesn't break existing functionality", async () => { - // Regression test to ensure our changes don't break normal operation - const state = await runTerraformApply(import.meta.dir, { - agent_id: "foo", - folder: "/home/coder", - default: '["GO", "IU"]', - major_version: "latest", - channel: "release", - }); - - const coder_apps = state.resources.filter( - (res) => res.type === "coder_app" && res.name === "jetbrains", - ); - - // Should work normally with API when available - expect(coder_apps.length).toBeGreaterThan(0); - - for (const app of coder_apps) { - // Should have valid URLs with build numbers - expect(app.instances[0].attributes.url).toContain( - "jetbrains://gateway/coder", - ); - expect(app.instances[0].attributes.url).toContain("ide_build_number="); - expect(app.instances[0].attributes.url).toContain("ide_product_code="); - } - }); - }); -}); diff --git a/registry/coder/modules/jetbrains/main.tf b/registry/coder/modules/jetbrains/main.tf index 51f7c816..2fac060f 100644 --- a/registry/coder/modules/jetbrains/main.tf +++ b/registry/coder/modules/jetbrains/main.tf @@ -62,7 +62,7 @@ variable "coder_parameter_order" { variable "tooltip" { type = string description = "Markdown text that is displayed when hovering over workspace apps." - default = null + default = "You need to install [JetBrains Toolbox App](https://www.jetbrains.com/toolbox-app/) to use this button." } variable "major_version" { @@ -70,8 +70,8 @@ variable "major_version" { description = "The major version of the IDE. i.e. 2025.1" default = "latest" validation { - condition = can(regex("^[0-9]{4}\\.[0-2]{1}$", var.major_version)) || var.major_version == "latest" - error_message = "The major_version must be a valid version number. i.e. 2025.1 or latest" + condition = can(regex("^[0-9]{4}\\.[1-3]$", var.major_version)) || var.major_version == "latest" + error_message = "The major_version must be a valid version number (e.g., 2025.1) or 'latest'" } } @@ -126,7 +126,7 @@ variable "download_base_link" { data "http" "jetbrains_ide_versions" { for_each = length(var.default) == 0 ? var.options : var.default - url = "${var.releases_base_link}/products/releases?code=${each.key}&type=${var.channel}&latest=true${var.major_version == "latest" ? "" : "&major_version=${var.major_version}"}" + url = "${var.releases_base_link}/products/releases?code=${each.key}&type=${var.channel}${var.major_version == "latest" ? "&latest=true" : ""}" } variable "ide_config" { @@ -138,9 +138,9 @@ variable "ide_config" { - build: The build number of the IDE. Example: { - "CL" = { name = "CLion", icon = "/icon/clion.svg", build = "251.26927.39" }, - "GO" = { name = "GoLand", icon = "/icon/goland.svg", build = "251.26927.50" }, - "IU" = { name = "IntelliJ IDEA", icon = "/icon/intellij.svg", build = "251.26927.53" }, + "CL" = { name = "CLion", icon = "/icon/clion.svg", build = "253.29346.141" }, + "GO" = { name = "GoLand", icon = "/icon/goland.svg", build = "253.28294.337" }, + "IU" = { name = "IntelliJ IDEA", icon = "/icon/intellij.svg", build = "253.29346.138" }, } EOT type = map(object({ @@ -149,15 +149,15 @@ variable "ide_config" { build = string })) default = { - "CL" = { name = "CLion", icon = "/icon/clion.svg", build = "251.26927.39" }, - "GO" = { name = "GoLand", icon = "/icon/goland.svg", build = "251.26927.50" }, - "IU" = { name = "IntelliJ IDEA", icon = "/icon/intellij.svg", build = "251.26927.53" }, - "PS" = { name = "PhpStorm", icon = "/icon/phpstorm.svg", build = "251.26927.60" }, - "PY" = { name = "PyCharm", icon = "/icon/pycharm.svg", build = "251.26927.74" }, - "RD" = { name = "Rider", icon = "/icon/rider.svg", build = "251.26927.67" }, - "RM" = { name = "RubyMine", icon = "/icon/rubymine.svg", build = "251.26927.47" }, - "RR" = { name = "RustRover", icon = "/icon/rustrover.svg", build = "251.26927.79" }, - "WS" = { name = "WebStorm", icon = "/icon/webstorm.svg", build = "251.26927.40" } + "CL" = { name = "CLion", icon = "/icon/clion.svg", build = "253.29346.141" }, + "GO" = { name = "GoLand", icon = "/icon/goland.svg", build = "253.28294.337" }, + "IU" = { name = "IntelliJ IDEA", icon = "/icon/intellij.svg", build = "253.29346.138" }, + "PS" = { name = "PhpStorm", icon = "/icon/phpstorm.svg", build = "253.29346.151" }, + "PY" = { name = "PyCharm", icon = "/icon/pycharm.svg", build = "253.29346.142" }, + "RD" = { name = "Rider", icon = "/icon/rider.svg", build = "253.29346.144" }, + "RM" = { name = "RubyMine", icon = "/icon/rubymine.svg", build = "253.29346.140" }, + "RR" = { name = "RustRover", icon = "/icon/rustrover.svg", build = "253.29346.139" }, + "WS" = { name = "WebStorm", icon = "/icon/webstorm.svg", build = "253.29346.143" } } validation { condition = length(var.ide_config) > 0 @@ -182,6 +182,20 @@ locals { ) } + # Filter the parsed response for the requested major version if not "latest" + filtered_releases = { + for code in length(var.default) == 0 ? var.options : var.default : code => [ + for r in try(local.parsed_responses[code][keys(local.parsed_responses[code])[0]], []) : + r if var.major_version == "latest" || r.majorVersion == var.major_version + ] + } + + # Select the latest release for the requested major version (first item in the filtered list) + selected_releases = { + for code in length(var.default) == 0 ? var.options : var.default : code => + length(local.filtered_releases[code]) > 0 ? local.filtered_releases[code][0] : null + } + # Dynamically generate IDE configurations based on options with fallback to ide_config options_metadata = { for code in length(var.default) == 0 ? var.options : var.default : code => { @@ -191,13 +205,10 @@ locals { key = code # Use API build number if available, otherwise fall back to ide_config build number - build = length(keys(local.parsed_responses[code])) > 0 ? ( - local.parsed_responses[code][keys(local.parsed_responses[code])[0]][0].build - ) : var.ide_config[code].build + build = local.selected_releases[code] != null ? local.selected_releases[code].build : var.ide_config[code].build - # Store API data for potential future use (only if API is available) - json_data = length(keys(local.parsed_responses[code])) > 0 ? local.parsed_responses[code][keys(local.parsed_responses[code])[0]][0] : null - response_key = length(keys(local.parsed_responses[code])) > 0 ? keys(local.parsed_responses[code])[0] : null + # Store API data for potential future use + json_data = local.selected_releases[code] } } diff --git a/registry/coder/modules/kasmvnc/README.md b/registry/coder/modules/kasmvnc/README.md index c769cd85..7fcc7fb0 100644 --- a/registry/coder/modules/kasmvnc/README.md +++ b/registry/coder/modules/kasmvnc/README.md @@ -14,7 +14,7 @@ Automatically install [KasmVNC](https://kasmweb.com/kasmvnc) in a workspace, and module "kasmvnc" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/kasmvnc/coder" - version = "1.2.6" + version = "1.2.7" agent_id = coder_agent.example.id desktop_environment = "xfce" subdomain = true diff --git a/registry/coder/modules/kasmvnc/main.tf b/registry/coder/modules/kasmvnc/main.tf index 5a5b449b..4635f612 100644 --- a/registry/coder/modules/kasmvnc/main.tf +++ b/registry/coder/modules/kasmvnc/main.tf @@ -31,7 +31,7 @@ variable "desktop_environment" { description = "Specifies the desktop environment of the workspace. This should be pre-installed on the workspace." validation { - condition = contains(["xfce", "kde", "gnome", "lxde", "lxqt"], var.desktop_environment) + condition = contains(["cinnamon", "mate", "lxde", "lxqt", "kde", "gnome", "xfce", "manual"], var.desktop_environment) error_message = "Invalid desktop environment. Please specify a valid desktop environment." } } diff --git a/registry/coder/modules/mux/README.md b/registry/coder/modules/mux/README.md index 72dc6f78..3f55209e 100644 --- a/registry/coder/modules/mux/README.md +++ b/registry/coder/modules/mux/README.md @@ -14,7 +14,7 @@ Automatically install and run [mux](https://github.com/coder/mux) in a Coder wor module "mux" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/mux/coder" - version = "1.0.5" + version = "1.0.7" agent_id = coder_agent.main.id } ``` @@ -37,7 +37,7 @@ module "mux" { module "mux" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/mux/coder" - version = "1.0.5" + version = "1.0.7" agent_id = coder_agent.main.id } ``` @@ -48,7 +48,7 @@ module "mux" { module "mux" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/mux/coder" - version = "1.0.5" + version = "1.0.7" agent_id = coder_agent.main.id # Default is "latest"; set to a specific version to pin install_version = "0.4.0" @@ -61,7 +61,7 @@ module "mux" { module "mux" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/mux/coder" - version = "1.0.5" + version = "1.0.7" agent_id = coder_agent.main.id port = 8080 } @@ -75,7 +75,7 @@ Run an existing copy of mux if found, otherwise install from npm: module "mux" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/mux/coder" - version = "1.0.5" + version = "1.0.7" agent_id = coder_agent.main.id use_cached = true } @@ -89,7 +89,7 @@ Run without installing from the network (requires mux to be pre-installed): module "mux" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/mux/coder" - version = "1.0.5" + version = "1.0.7" agent_id = coder_agent.main.id install = false } diff --git a/registry/coder/modules/mux/run.sh b/registry/coder/modules/mux/run.sh index be759a0a..2409f19d 100644 --- a/registry/coder/modules/mux/run.sh +++ b/registry/coder/modules/mux/run.sh @@ -97,7 +97,7 @@ if [ ! -f "$MUX_BINARY" ] || [ "${USE_CACHED}" != true ]; then fi # sed-based fallback if [ -z "$TARBALL_URL" ]; then - TARBALL_URL="$(printf "%s" "$META_ONE_LINE" | sed -n 's/.*\"tarball\":\"\\([^\"]*\\)\".*/\\1/p' | head -n1)" + TARBALL_URL="$(printf "%s" "$META_ONE_LINE" | sed -n 's/.*"tarball":"\([^"]*\)".*/\1/p' | head -n1)" fi # Fallback: resolve version then construct tarball URL if [ -z "$TARBALL_URL" ]; then @@ -106,10 +106,10 @@ if [ ! -f "$MUX_BINARY" ] || [ "${USE_CACHED}" != true ]; then RESOLVED_VERSION="$(printf "%s" "$META_JSON" | node -e 'try{const fs=require("fs");const data=JSON.parse(fs.readFileSync(0,"utf8"));if(data&&data.version){console.log(data.version);}}catch(e){}')" fi if [ -z "$RESOLVED_VERSION" ]; then - RESOLVED_VERSION="$(printf "%s" "$META_ONE_LINE" | sed -n 's/.*\"version\":\"\\([^\"]*\\)\".*/\\1/p' | head -n1)" + RESOLVED_VERSION="$(printf "%s" "$META_ONE_LINE" | sed -n 's/.*"version":"\([^"]*\)".*/\1/p' | head -n1)" fi if [ -z "$RESOLVED_VERSION" ]; then - RESOLVED_VERSION="$(printf "%s" "$META_ONE_LINE" | grep -o '\"version\":\"[^\"]*\"' | head -n1 | cut -d '\"' -f4)" + RESOLVED_VERSION="$(printf "%s" "$META_ONE_LINE" | grep -o '"version":"[^"]*"' | head -n1 | cut -d '"' -f4)" fi if [ -n "$RESOLVED_VERSION" ]; then VERSION_TO_USE="$RESOLVED_VERSION" @@ -141,9 +141,9 @@ if [ ! -f "$MUX_BINARY" ] || [ "${USE_CACHED}" != true ]; then fi if [ -z "$BIN_PATH" ]; then # sed fallbacks (handle both string and object forms) - BIN_PATH=$(sed -n 's/.*\"bin\"[[:space:]]*:[[:space:]]*\"\\([^\"]*\\)\".*/\\1/p' "$TMP_DIR/package/package.json" | head -n1) + BIN_PATH=$(sed -n 's/.*"bin"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$TMP_DIR/package/package.json" | head -n1) if [ -z "$BIN_PATH" ]; then - BIN_PATH=$(sed -n '/\"bin\"[[:space:]]*:[[:space:]]*{/,/}/p' "$TMP_DIR/package/package.json" | sed -n 's/.*\"mux\"[[:space:]]*:[[:space:]]*\"\\([^\"]*\\)\".*/\\1/p' | head -n1) + BIN_PATH=$(sed -n '/"bin"[[:space:]]*:[[:space:]]*{/,/}/p' "$TMP_DIR/package/package.json" | sed -n 's/.*"mux"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1) fi fi if [ -n "$BIN_PATH" ] && [ -f "$TMP_DIR/package/$BIN_PATH" ]; then diff --git a/registry/coder/modules/vault-cli/README.md b/registry/coder/modules/vault-cli/README.md index f8df790f..04420570 100644 --- a/registry/coder/modules/vault-cli/README.md +++ b/registry/coder/modules/vault-cli/README.md @@ -13,7 +13,7 @@ Installs the [Vault](https://www.vaultproject.io/) CLI and optionally configures ```tf module "vault_cli" { source = "registry.coder.com/coder/vault-cli/coder" - version = "1.1.0" + version = "1.1.1" agent_id = coder_agent.example.id vault_addr = "https://vault.example.com" } @@ -34,7 +34,7 @@ If you have a Vault token, you can provide it to automatically configure authent ```tf module "vault_cli" { source = "registry.coder.com/coder/vault-cli/coder" - version = "1.1.0" + version = "1.1.1" agent_id = coder_agent.example.id vault_addr = "https://vault.example.com" vault_token = var.vault_token # Optional @@ -50,7 +50,7 @@ Install the Vault CLI without any authentication: ```tf module "vault_cli" { source = "registry.coder.com/coder/vault-cli/coder" - version = "1.1.0" + version = "1.1.1" agent_id = coder_agent.example.id vault_addr = "https://vault.example.com" } @@ -61,7 +61,7 @@ module "vault_cli" { ```tf module "vault_cli" { source = "registry.coder.com/coder/vault-cli/coder" - version = "1.1.0" + version = "1.1.1" agent_id = coder_agent.example.id vault_addr = "https://vault.example.com" vault_cli_version = "1.15.0" @@ -73,7 +73,7 @@ module "vault_cli" { ```tf module "vault_cli" { source = "registry.coder.com/coder/vault-cli/coder" - version = "1.1.0" + version = "1.1.1" agent_id = coder_agent.example.id vault_addr = "https://vault.example.com" install_dir = "/home/coder/bin" @@ -87,7 +87,7 @@ For Vault Enterprise users who need to specify a namespace: ```tf module "vault_cli" { source = "registry.coder.com/coder/vault-cli/coder" - version = "1.1.0" + version = "1.1.1" agent_id = coder_agent.example.id vault_addr = "https://vault.example.com" vault_token = var.vault_token @@ -102,7 +102,7 @@ Install the Vault Enterprise binary. This is required if using SAML authenticati ```tf module "vault_cli" { source = "registry.coder.com/coder/vault-cli/coder" - version = "1.1.0" + version = "1.1.1" agent_id = coder_agent.example.id vault_addr = "https://vault.example.com" enterprise = true diff --git a/registry/coder/modules/vault-cli/run.sh b/registry/coder/modules/vault-cli/run.sh index 18803ee5..40c2c26d 100644 --- a/registry/coder/modules/vault-cli/run.sh +++ b/registry/coder/modules/vault-cli/run.sh @@ -7,40 +7,34 @@ INSTALL_DIR=${INSTALL_DIR} VAULT_CLI_VERSION=${VAULT_CLI_VERSION} ENTERPRISE=${ENTERPRISE} -# Fetch URL content. If dest is provided, write to file; otherwise output to stdout. -# Usage: fetch [dest] +# Fetch URL content to stdout fetch() { url="$1" - dest="$${2:-}" - - # Detect HTTP client on first run - if [ -z "$${HTTP_CLIENT:-}" ]; then - if command -v curl > /dev/null 2>&1; then - HTTP_CLIENT="curl" - elif command -v wget > /dev/null 2>&1; then - HTTP_CLIENT="wget" - elif command -v busybox > /dev/null 2>&1; then - HTTP_CLIENT="busybox" - else - printf "curl, wget, or busybox is not installed. Please install curl or wget in your image.\n" - return 1 - fi - fi - - if [ -n "$${dest}" ]; then - # shellcheck disable=SC2195 - case "$${HTTP_CLIENT}" in - curl) curl -sSL --fail "$${url}" -o "$${dest}" ;; - wget) wget -O "$${dest}" "$${url}" ;; - busybox) busybox wget -O "$${dest}" "$${url}" ;; - esac + if command -v curl > /dev/null 2>&1; then + curl -sSL --fail "$${url}" + elif command -v wget > /dev/null 2>&1; then + wget -qO- "$${url}" + elif command -v busybox > /dev/null 2>&1; then + busybox wget -qO- "$${url}" else - # shellcheck disable=SC2195 - case "$${HTTP_CLIENT}" in - curl) curl -sSL --fail "$${url}" ;; - wget) wget -qO- "$${url}" ;; - busybox) busybox wget -qO- "$${url}" ;; - esac + printf "curl, wget, or busybox is not installed. Please install curl or wget in your image.\n" + return 1 + fi +} + +# Download URL to a file +fetch_to_file() { + dest="$1" + url="$2" + if command -v curl > /dev/null 2>&1; then + curl -sSL --fail "$${url}" -o "$${dest}" + elif command -v wget > /dev/null 2>&1; then + wget -O "$${dest}" "$${url}" + elif command -v busybox > /dev/null 2>&1; then + busybox wget -O "$${dest}" "$${url}" + else + printf "curl, wget, or busybox is not installed. Please install curl or wget in your image.\n" + return 1 fi } @@ -141,7 +135,7 @@ install() { cd "$${TEMP_DIR}" || return 1 printf "Downloading from %s\n" "$${DOWNLOAD_URL}" - if ! fetch "$${DOWNLOAD_URL}" vault.zip; then + if ! fetch_to_file vault.zip "$${DOWNLOAD_URL}"; then printf "Failed to download Vault.\n" rm -rf "$${TEMP_DIR}" return 1 diff --git a/registry/coder/modules/zed/README.md b/registry/coder/modules/zed/README.md index 10f4dbc0..cffb7144 100644 --- a/registry/coder/modules/zed/README.md +++ b/registry/coder/modules/zed/README.md @@ -19,7 +19,7 @@ Zed is a high-performance, multiplayer code editor from the creators of Atom and module "zed" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/zed/coder" - version = "1.1.2" + version = "1.1.4" agent_id = coder_agent.main.id } ``` @@ -32,7 +32,7 @@ module "zed" { module "zed" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/zed/coder" - version = "1.1.3" + version = "1.1.4" agent_id = coder_agent.main.id folder = "/home/coder/project" } @@ -44,7 +44,7 @@ module "zed" { module "zed" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/zed/coder" - version = "1.1.3" + version = "1.1.4" agent_id = coder_agent.main.id display_name = "Zed Editor" order = 1 @@ -57,7 +57,7 @@ module "zed" { module "zed" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/zed/coder" - version = "1.1.3" + version = "1.1.4" agent_id = coder_agent.main.id agent_name = coder_agent.example.name } @@ -73,7 +73,7 @@ You can declaratively set/merge settings with the `settings` input. Provide a JS module "zed" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/zed/coder" - version = "1.1.3" + version = "1.1.4" agent_id = coder_agent.main.id settings = jsonencode({ @@ -85,6 +85,7 @@ module "zed" { env = {} } + } }) } diff --git a/registry/coder/modules/zed/main.test.ts b/registry/coder/modules/zed/main.test.ts index 12413750..9a39b47b 100644 --- a/registry/coder/modules/zed/main.test.ts +++ b/registry/coder/modules/zed/main.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "bun:test"; import { + execContainer, + findResourceInstance, + removeContainer, + runContainer, runTerraformApply, runTerraformInit, testRequiredVariables, @@ -12,66 +16,114 @@ describe("zed", async () => { agent_id: "foo", }); - it("default output", async () => { + it("creates settings file with correct JSON", async () => { + const settings = { + theme: "One Dark", + buffer_font_size: 14, + vim_mode: true, + telemetry: { + diagnostics: false, + metrics: false, + }, + // Test special characters: single quotes, backslashes, URLs + message: "it's working", + path: "C:\\Users\\test", + api_url: "https://api.example.com/v1?token=abc&user=test", + }; + const state = await runTerraformApply(import.meta.dir, { agent_id: "foo", + settings: JSON.stringify(settings), }); - expect(state.outputs.zed_url.value).toBe("zed://ssh/default.coder"); - const coder_app = state.resources.find( - (res) => res.type === "coder_app" && res.name === "zed", - ); + const instance = findResourceInstance(state, "coder_script"); + const id = await runContainer("alpine:latest"); - expect(coder_app).not.toBeNull(); - expect(coder_app?.instances.length).toBe(1); - expect(coder_app?.instances[0].attributes.order).toBeNull(); - }); + try { + const result = await execContainer(id, ["sh", "-c", instance.script]); + expect(result.exitCode).toBe(0); + + const catResult = await execContainer(id, [ + "cat", + "/root/.config/zed/settings.json", + ]); + expect(catResult.exitCode).toBe(0); + + const written = JSON.parse(catResult.stdout.trim()); + expect(written).toEqual(settings); + } finally { + await removeContainer(id); + } + }, 30000); + + it("merges settings with existing file when jq available", async () => { + const existingSettings = { + theme: "Solarized Dark", + vim_mode: true, + }; + + const newSettings = { + theme: "One Dark", + buffer_font_size: 14, + }; - it("adds folder", async () => { const state = await runTerraformApply(import.meta.dir, { agent_id: "foo", - folder: "/foo/bar", + settings: JSON.stringify(newSettings), }); - expect(state.outputs.zed_url.value).toBe("zed://ssh/default.coder/foo/bar"); - }); - it("expect order to be set", async () => { + const instance = findResourceInstance(state, "coder_script"); + const id = await runContainer("alpine:latest"); + + try { + // Install jq and create existing settings file + await execContainer(id, ["apk", "add", "--no-cache", "jq"]); + await execContainer(id, ["mkdir", "-p", "/root/.config/zed"]); + await execContainer(id, [ + "sh", + "-c", + `echo '${JSON.stringify(existingSettings)}' > /root/.config/zed/settings.json`, + ]); + + const result = await execContainer(id, ["sh", "-c", instance.script]); + expect(result.exitCode).toBe(0); + + const catResult = await execContainer(id, [ + "cat", + "/root/.config/zed/settings.json", + ]); + expect(catResult.exitCode).toBe(0); + + const merged = JSON.parse(catResult.stdout.trim()); + expect(merged.theme).toBe("One Dark"); // overwritten + expect(merged.buffer_font_size).toBe(14); // added + expect(merged.vim_mode).toBe(true); // preserved + } finally { + await removeContainer(id); + } + }, 30000); + + it("exits early with empty settings", async () => { const state = await runTerraformApply(import.meta.dir, { agent_id: "foo", - order: "22", + settings: "", }); - const coder_app = state.resources.find( - (res) => res.type === "coder_app" && res.name === "zed", - ); + const instance = findResourceInstance(state, "coder_script"); + const id = await runContainer("alpine:latest"); - expect(coder_app).not.toBeNull(); - expect(coder_app?.instances.length).toBe(1); - expect(coder_app?.instances[0].attributes.order).toBe(22); - }); + try { + const result = await execContainer(id, ["sh", "-c", instance.script]); + expect(result.exitCode).toBe(0); - it("expect display_name to be set", async () => { - const state = await runTerraformApply(import.meta.dir, { - agent_id: "foo", - display_name: "Custom Zed", - }); - - const coder_app = state.resources.find( - (res) => res.type === "coder_app" && res.name === "zed", - ); - - expect(coder_app).not.toBeNull(); - expect(coder_app?.instances.length).toBe(1); - expect(coder_app?.instances[0].attributes.display_name).toBe("Custom Zed"); - }); - - it("adds agent_name to hostname", async () => { - const state = await runTerraformApply(import.meta.dir, { - agent_id: "foo", - agent_name: "myagent", - }); - expect(state.outputs.zed_url.value).toBe( - "zed://ssh/myagent.default.default.coder", - ); - }); + // Settings file should not be created + const catResult = await execContainer(id, [ + "cat", + "/root/.config/zed/settings.json", + ]); + expect(catResult.exitCode).not.toBe(0); + } finally { + await removeContainer(id); + } + }, 30000); }); diff --git a/registry/coder/modules/zed/main.tf b/registry/coder/modules/zed/main.tf index 745254be..414c3c0e 100644 --- a/registry/coder/modules/zed/main.tf +++ b/registry/coder/modules/zed/main.tf @@ -65,6 +65,7 @@ locals { owner_name = lower(data.coder_workspace_owner.me.name) agent_name = lower(var.agent_name) hostname = var.agent_name != "" ? "${local.agent_name}.${local.workspace_name}.${local.owner_name}.coder" : "${local.workspace_name}.coder" + settings_b64 = var.settings != "" ? base64encode(var.settings) : "" } resource "coder_script" "zed_settings" { @@ -75,7 +76,11 @@ resource "coder_script" "zed_settings" { script = <<-EOT #!/usr/bin/env bash set -eu - SETTINGS_JSON='${replace(var.settings, "\"", "\\\"")}' + SETTINGS_B64='${local.settings_b64}' + if [ -z "$${SETTINGS_B64}" ]; then + exit 0 + fi + SETTINGS_JSON="$(echo -n "$${SETTINGS_B64}" | base64 -d)" if [ -z "$${SETTINGS_JSON}" ] || [ "$${SETTINGS_JSON}" = "{}" ]; then exit 0 fi diff --git a/registry/coder/modules/zed/zed.tftest.hcl b/registry/coder/modules/zed/zed.tftest.hcl index 508b6550..339f6876 100644 --- a/registry/coder/modules/zed/zed.tftest.hcl +++ b/registry/coder/modules/zed/zed.tftest.hcl @@ -5,6 +5,20 @@ run "default_output" { agent_id = "foo" } + override_data { + target = data.coder_workspace.me + values = { + name = "default" + } + } + + override_data { + target = data.coder_workspace_owner.me + values = { + name = "default" + } + } + assert { condition = output.zed_url == "zed://ssh/default.coder" error_message = "zed_url did not match expected default URL" @@ -19,6 +33,20 @@ run "adds_folder" { folder = "/foo/bar" } + override_data { + target = data.coder_workspace.me + values = { + name = "default" + } + } + + override_data { + target = data.coder_workspace_owner.me + values = { + name = "default" + } + } + assert { condition = output.zed_url == "zed://ssh/default.coder/foo/bar" error_message = "zed_url did not include provided folder path" @@ -33,8 +61,54 @@ run "adds_agent_name" { agent_name = "myagent" } + override_data { + target = data.coder_workspace.me + values = { + name = "default" + } + } + + override_data { + target = data.coder_workspace_owner.me + values = { + name = "default" + } + } + assert { condition = output.zed_url == "zed://ssh/myagent.default.default.coder" error_message = "zed_url did not include agent_name in hostname" } } + +run "settings_base64_encoding" { + command = apply + + variables { + agent_id = "foo" + settings = jsonencode({ + theme = "dark" + fontSize = 14 + }) + } + + # Verify settings are base64 encoded (eyJ = base64 prefix for JSON starting with {") + assert { + condition = can(regex("SETTINGS_B64='eyJ", coder_script.zed_settings.script)) + error_message = "settings should be base64 encoded in the script" + } +} + +run "empty_settings" { + command = apply + + variables { + agent_id = "foo" + settings = "" + } + + assert { + condition = can(regex("SETTINGS_B64=''", coder_script.zed_settings.script)) + error_message = "empty settings should result in empty SETTINGS_B64" + } +} diff --git a/registry/coder/templates/kubernetes-devcontainer/main.tf b/registry/coder/templates/kubernetes-devcontainer/main.tf index d391c75a..b7c6153d 100644 --- a/registry/coder/templates/kubernetes-devcontainer/main.tf +++ b/registry/coder/templates/kubernetes-devcontainer/main.tf @@ -139,7 +139,7 @@ variable "cache_repo_secret_name" { type = string } -data "kubernetes_secret" "cache_repo_dockerconfig_secret" { +data "kubernetes_secret_v1" "cache_repo_dockerconfig_secret" { count = var.cache_repo_secret_name == "" ? 0 : 1 metadata { name = var.cache_repo_secret_name @@ -166,7 +166,7 @@ locals { # 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_DOCKER_CONFIG_BASE64" : base64encode(try(data.kubernetes_secret_v1.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 @@ -186,7 +186,7 @@ resource "envbuilder_cached_image" "cached" { insecure = var.insecure_cache_repo } -resource "kubernetes_persistent_volume_claim" "workspaces" { +resource "kubernetes_persistent_volume_claim_v1" "workspaces" { metadata { name = "coder-${lower(data.coder_workspace.me.id)}-workspaces" namespace = var.namespace @@ -217,10 +217,10 @@ resource "kubernetes_persistent_volume_claim" "workspaces" { } } -resource "kubernetes_deployment" "main" { +resource "kubernetes_deployment_v1" "main" { count = data.coder_workspace.me.start_count depends_on = [ - kubernetes_persistent_volume_claim.workspaces + kubernetes_persistent_volume_claim_v1.workspaces ] wait_for_rollout = false metadata { @@ -300,7 +300,7 @@ resource "kubernetes_deployment" "main" { volume { name = "workspaces" persistent_volume_claim { - claim_name = kubernetes_persistent_volume_claim.workspaces.metadata.0.name + claim_name = kubernetes_persistent_volume_claim_v1.workspaces.metadata.0.name read_only = false } } diff --git a/registry/coder/templates/kubernetes-envbox/main.tf b/registry/coder/templates/kubernetes-envbox/main.tf index 98543d9c..b7693cbf 100644 --- a/registry/coder/templates/kubernetes-envbox/main.tf +++ b/registry/coder/templates/kubernetes-envbox/main.tf @@ -106,22 +106,20 @@ module "code-server" { # 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 = 1 + agent_id = coder_agent.main.id + order = 1 } # See https://registry.coder.com/modules/coder/jetbrains module "jetbrains" { - count = data.coder_workspace.me.start_count - source = "registry.coder.com/modules/coder/jetbrains/coder" - version = "~> 1.0" - agent_id = coder_agent.main.id - agent_name = "main" - folder = "/home/coder" + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/jetbrains/coder" + version = "~> 1.0" + agent_id = coder_agent.main.id + folder = "/home/coder" } -resource "kubernetes_persistent_volume_claim" "home" { +resource "kubernetes_persistent_volume_claim_v1" "home" { metadata { name = "coder-${lower(data.coder_workspace_owner.me.name)}-${lower(data.coder_workspace.me.name)}-home" namespace = var.namespace @@ -137,7 +135,7 @@ resource "kubernetes_persistent_volume_claim" "home" { } } -resource "kubernetes_pod" "main" { +resource "kubernetes_pod_v1" "main" { count = data.coder_workspace.me.start_count metadata { @@ -284,7 +282,7 @@ resource "kubernetes_pod" "main" { volume { name = "home" persistent_volume_claim { - claim_name = kubernetes_persistent_volume_claim.home.metadata.0.name + claim_name = kubernetes_persistent_volume_claim_v1.home.metadata.0.name read_only = false } } diff --git a/registry/coder/templates/kubernetes/main.tf b/registry/coder/templates/kubernetes/main.tf index 7d7c0aa8..c324331f 100644 --- a/registry/coder/templates/kubernetes/main.tf +++ b/registry/coder/templates/kubernetes/main.tf @@ -192,7 +192,7 @@ resource "coder_app" "code-server" { } } -resource "kubernetes_persistent_volume_claim" "home" { +resource "kubernetes_persistent_volume_claim_v1" "home" { metadata { name = "coder-${data.coder_workspace.me.id}-home" namespace = var.namespace @@ -222,10 +222,10 @@ resource "kubernetes_persistent_volume_claim" "home" { } } -resource "kubernetes_deployment" "main" { +resource "kubernetes_deployment_v1" "main" { count = data.coder_workspace.me.start_count depends_on = [ - kubernetes_persistent_volume_claim.home + kubernetes_persistent_volume_claim_v1.home ] wait_for_rollout = false metadata { @@ -316,7 +316,7 @@ resource "kubernetes_deployment" "main" { volume { name = "home" persistent_volume_claim { - claim_name = kubernetes_persistent_volume_claim.home.metadata.0.name + claim_name = kubernetes_persistent_volume_claim_v1.home.metadata.0.name read_only = false } } diff --git a/registry/cytoshahar/.images/avatar.jpeg b/registry/cytoshahar/.images/avatar.jpeg index 3e82d763..245aa94a 100644 Binary files a/registry/cytoshahar/.images/avatar.jpeg and b/registry/cytoshahar/.images/avatar.jpeg differ diff --git a/registry/djarbz/.images/avatar.png b/registry/djarbz/.images/avatar.png index f6019203..a5f427f9 100644 Binary files a/registry/djarbz/.images/avatar.png and b/registry/djarbz/.images/avatar.png differ diff --git a/registry/harleylrn/.images/avatar.png b/registry/harleylrn/.images/avatar.png index 08e6f54e..f99ed608 100644 Binary files a/registry/harleylrn/.images/avatar.png and b/registry/harleylrn/.images/avatar.png differ diff --git a/registry/harleylrn/.images/kiro-cli.png b/registry/harleylrn/.images/kiro-cli.png index d325299b..3f4d5cf6 100644 Binary files a/registry/harleylrn/.images/kiro-cli.png and b/registry/harleylrn/.images/kiro-cli.png differ diff --git a/registry/mossylion/.images/avatar.png b/registry/mossylion/.images/avatar.png new file mode 100644 index 00000000..a7f00f17 Binary files /dev/null and b/registry/mossylion/.images/avatar.png differ diff --git a/registry/mossylion/README.md b/registry/mossylion/README.md new file mode 100644 index 00000000..6f49a22c --- /dev/null +++ b/registry/mossylion/README.md @@ -0,0 +1,15 @@ +--- +display_name: "Mossy Lion" +bio: "Tinkerer, exploring European cloud providers" +avatar: "./.images/avatar.png" +github: "mossylion" +status: "community" +--- + +# Mossy Lion + +Exploring European cloud providers. Usually find me outdoors but if not, somewhere deep in Kubernetes and infra + +## Templates + +- **scaleway-instance**: Scaleway workspace instance with persistent home directory diff --git a/registry/mossylion/templates/scaleway-instance/README.md b/registry/mossylion/templates/scaleway-instance/README.md new file mode 100644 index 00000000..065c9311 --- /dev/null +++ b/registry/mossylion/templates/scaleway-instance/README.md @@ -0,0 +1,156 @@ +--- +display_name: "Scaleway Instance" +description: "A workspace spun up on a Scaleway Instance" +icon: "../../../../.icons/scaleway.svg" +verified: false +tags: ["scaleway", "vm", "linux"] +--- + +# Scaleway Instance Template + +This template provisions Coder workspaces on [Scaleway](https://www.scaleway.com/) cloud instances with full customization options for regions, instance types, operating systems, and storage configurations. + +## Features + +- **Multi-region support**: Choose from France (Paris), Netherlands (Amsterdam), or Poland (Warsaw) +- **Flexible instance sizing**: Wide range of instance types from development to high-performance computing +- **Multiple OS options**: Debian 12/13, Ubuntu 24.04, and Fedora 41 +- **Customizable storage**: Adjustable disk size with configurable IOPS +- **IPv4 and IPv6 networking**: Dual-stack IP configuration for enhanced connectivity + +## Prerequisites + +### Scaleway Account Setup + +1. Create a [Scaleway account](https://console.scaleway.com/) +2. Create a new project or use an existing one +3. Generate API credentials: + - Go to **IAM** > **API Keys** in the Scaleway Console + - Create a new API key + - Note down the **Access Key** and **Secret Key** + - Copy your **Project ID** from the project settings + - Give permissions for **BlockStorageFullAccess**, **ProjectReadOnly**, **InstancesFullAccess** as a starting point + +## Architecture + +This template creates the following resources for each workspace: + +### Persistent Resources + +- **Block Volume**: Mounted as user's home directory (preserves all data, configs, and projects) + +### Ephemeral Resources (destroyed when workspace stops) + +- **Scaleway Instance**: Virtual machine created fresh on each workspace start +- **IPv4 Address**: Routed IPv4 address assigned dynamically +- **IPv6 Address**: Routed IPv6 address assigned dynamically +- **Cloud-init Configuration**: Automated setup of the Coder agent and persistent storage mounting + +## Configuration Options + +### Region Selection + +Choose from three available regions: + +- **France - Paris (fr-par)**: Default, lowest latency for European users +- **Netherlands - Amsterdam (nl-ams)**: Alternative European location +- **Poland - Warsaw (pl-waw)**: Eastern European option + +### Instance Types + +The template supports a comprehensive range of Scaleway instance types: + +#### Development Instances + +- **STARDUST1-S**: 1 CPU, 1GB RAM - Basic development +- **DEV1-S/M/L/XL**: 2-4 CPUs, 2-12GB RAM - Standard development + +#### Production Instances + +- **ENT1 Series**: 2-96 CPUs, 8-384GB RAM - Enterprise workloads +- **GP1 Series**: 4-48 CPUs, 16-256GB RAM - General purpose +- **PRO2 Series**: 2-32 CPUs, 8-128GB RAM - Professional workloads + +#### Specialized Instances + +- **L4 Series**: GPU-enabled instances for AI/ML workloads +- **COPARM1 Series**: ARM64 architecture for specific use cases + +### Operating System Options + +- **Debian 13 (Trixie)**: Latest Debian release +- **Debian 12 (Bookworm)**: Stable Debian LTS +- **Ubuntu 24.04 (Noble)**: Latest Ubuntu LTS +- **Fedora 41**: Cutting-edge features and packages + +### Storage Configuration + +- **Home Directory Size**: 10-500GB adjustable via slider (your entire home directory) +- **IOPS**: 5,000 or 15,000 IOPS options for performance tuning + +## Template Components + +### Included Tools + +- **VS Code Server**: Browser-based IDE with full extension support +- **System Monitoring**: CPU, RAM, and disk usage metrics +- **Dotfiles Support**: Automatic dotfiles synchronization on workspace start +- **Custom Environment Variables**: Pre-configured welcome message + +### Cloud-init Setup + +The template uses cloud-init for: + +- Automatic Coder agent installation and configuration +- User account setup with proper permissions +- Persistent home directory mounting (automatic disk partitioning and filesystem creation) +- Development tools initialization + +## Usage + +### Creating a Workspace + +1. **Select Template**: Choose "Scaleway Instance" from your Coder templates +2. **Configure Region**: Pick your preferred Scaleway region +3. **Choose Instance**: Select instance type based on your performance needs +4. **Select OS**: Pick your preferred operating system +5. **Set Home Directory Size**: Adjust storage size (10-500GB) for your persistent home directory +6. **Create**: Launch your workspace + +### Managing Costs + +- **VM instances are destroyed** when workspace stops (zero compute costs when not in use) +- **IP addresses are released** when workspace stops (no static IP charges) +- **Home directory persists** on dedicated block volume (small storage cost only) +- **Fresh OS** on each workspace start with persistent user data +- Choose appropriate instance sizes for your workload requirements + +## Customization + +### Extending the Template + +You can customize this template by: + +1. **Adding Software**: Modify cloud-init scripts to install additional tools +2. **Custom Modules**: Include additional Coder modules from the registry +3. **Network Configuration**: Adjust security groups or network settings +4. **Startup Scripts**: Add custom initialization logic + +## Maintenance + +### Updating Instance Types + +To update the available instance types, regenerate the `scaleway-config.json` file: + +```bash +scw instance server-type list -o json | jq 'map({name, cpu, gpu, ram, arch})' > scaleway-config.json.json +``` + +This pulls the latest instance types from Scaleway and formats them for use in the template. + +## References + +- [Scaleway Documentation](https://www.scaleway.com/en/docs/) +- [Scaleway Instance Types](https://www.scaleway.com/en/pricing/#instances) +- [Coder Templates Documentation](https://coder.com/docs/templates) +- [Terraform Scaleway Provider](https://registry.terraform.io/providers/scaleway/scaleway/latest/docs) diff --git a/registry/mossylion/templates/scaleway-instance/cloud-init/cloud-config.yaml.tftpl b/registry/mossylion/templates/scaleway-instance/cloud-init/cloud-config.yaml.tftpl new file mode 100644 index 00000000..3aeb35ce --- /dev/null +++ b/registry/mossylion/templates/scaleway-instance/cloud-init/cloud-config.yaml.tftpl @@ -0,0 +1,35 @@ +#cloud-config +cloud_final_modules: + - [scripts-user, always] +hostname: ${hostname} +users: + - name: ${linux_user} + sudo: ALL=(ALL) NOPASSWD:ALL + shell: /bin/bash + +# Setup persistent storage disk +disk_setup: + /dev/sdb: + table_type: gpt + layout: true + overwrite: false + +fs_setup: + - label: persistent-home + filesystem: ext4 + device: /dev/sdb1 + partition: auto + +mounts: + - ["/dev/sdb1", "/home/${linux_user}", "ext4", "defaults", "0", "2"] + +# Fix ownership after mounting +runcmd: + - chown -R ${linux_user}:${linux_user} /home/${linux_user} + - chmod 755 /home/${linux_user} + +# Automatically grow the partition +growpart: + mode: auto + devices: ['/'] + ignore_growroot_disabled: false diff --git a/registry/mossylion/templates/scaleway-instance/cloud-init/userdata.sh.tftpl b/registry/mossylion/templates/scaleway-instance/cloud-init/userdata.sh.tftpl new file mode 100644 index 00000000..72819ce9 --- /dev/null +++ b/registry/mossylion/templates/scaleway-instance/cloud-init/userdata.sh.tftpl @@ -0,0 +1,2 @@ +#!/bin/bash +sudo -u '${linux_user}' sh -c 'export CODER_AGENT_TOKEN="${coder_agent_token}"; ${init_script}' diff --git a/registry/mossylion/templates/scaleway-instance/main.tf b/registry/mossylion/templates/scaleway-instance/main.tf new file mode 100644 index 00000000..a4ef968a --- /dev/null +++ b/registry/mossylion/templates/scaleway-instance/main.tf @@ -0,0 +1,337 @@ + +terraform { + required_providers { + coder = { + source = "coder/coder" + version = "~> 2" + } + scaleway = { + source = "scaleway/scaleway" + version = "~> 2" + } + cloudinit = { + source = "hashicorp/cloudinit" + version = "~> 2" + } + } + required_version = ">= 1.0" +} + +provider "scaleway" { + access_key = var.access_key + secret_key = var.secret_key + region = data.coder_parameter.region.value +} + +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 = coder_agent.main.init_script + coder_agent_token = coder_agent.main.token + }) + } +} +data "coder_provisioner" "me" {} + +data "coder_workspace" "me" {} + +data "coder_workspace_owner" "me" {} + +resource "coder_agent" "main" { + arch = local.selected_arch + os = data.coder_provisioner.me.os + auth = "token" + + startup_script = <<-EOT + set -e + + # Install additional tools or run commands at workspace startup + # Uncomment and customize as needed: + # sudo apt-get update + # sudo apt-get install -y build-essential + EOT + + 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 = "Disk Usage" + key = "1_disk_usage" + script = "coder stat disk --path /home/${local.linux_user}" + interval = 600 + timeout = 30 + } +} + +module "code-server" { + source = "registry.coder.com/modules/code-server/coder" + version = "1.3.1" + agent_id = coder_agent.main.id + order = 1 + folder = "/home/${local.linux_user}" +} + +# Runs a script at workspace start/stop or on a cron schedule +# details: https://registry.terraform.io/providers/coder/coder/latest/docs/resources/script + +module "dotfiles" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/dotfiles/coder" + version = "1.2.1" + agent_id = coder_agent.main.id +} + +resource "coder_metadata" "workspace_info" { + count = data.coder_workspace.me.start_count + resource_id = scaleway_instance_server.workspace[0].id + + item { + key = "region" + value = data.coder_parameter.region.value + } + item { + key = "instance type" + value = scaleway_instance_server.workspace[0].type + } + item { + key = "image" + value = data.coder_parameter.base_image.value + } +} + +resource "coder_metadata" "volume_info" { + resource_id = scaleway_block_volume.persistent_storage.id + + item { + key = "size" + value = "${scaleway_block_volume.persistent_storage.size_in_gb} GiB" + } + item { + key = "iops" + value = scaleway_block_volume.persistent_storage.iops + } +} + +data "coder_parameter" "region" { + name = "Scaleway Region" + description = "Region to deploy server into" + type = "string" + default = "fr-par" + option { + name = "France - Paris (fr-par)" + value = "fr-par" + icon = "/emojis/1f1eb-1f1f7.png" + } + option { + name = "Netherlands - Amsterdam (nl-ams)" + value = "nl-ams" + icon = "/emojis/1f1f3-1f1f1.png" + } + option { + name = "Poland - Warsaw (pl-waw)" + value = "pl-waw" + icon = "/emojis/1f1f5-1f1f1.png" + } +} + +data "coder_parameter" "base_image" { + name = "Image" + description = "Which base image would you like to use?" + type = "string" + form_type = "radio" + default = "debian_trixie" + + option { + name = "Debian 13 (Trixie)" + value = "debian_trixie" + icon = "/icon/debian.svg" + } + + option { + name = "Debian 12 (Bookworm)" + value = "debian_bookworm" + icon = "/icon/debian.svg" + } + + option { + name = "Ubuntu 24.04 (Noble)" + value = "ubuntu_noble" + icon = "/icon/ubuntu.svg" + } + + option { + name = "Fedora 41" + value = "fedora_41" + icon = "/icon/fedora.svg" + } +} + +data "coder_parameter" "root_volume_size" { + name = "Root Volume Size" + description = "Size of the OS/boot disk in GB" + type = "number" + form_type = "slider" + default = "20" + order = 7 + validation { + min = 10 + max = 1000 + monotonic = "increasing" + } +} + +data "coder_parameter" "disk_size" { + name = "Persistent Storage Size" + description = "Size of the additional persistent storage volume in GB" + type = "number" + form_type = "slider" + default = "10" + order = 8 + validation { + min = 10 + max = 500 + monotonic = "increasing" + } +} + +locals { + scaleway_config_raw = jsondecode(file("${path.module}/scaleway-config.json")) + + scaleway_instance_options = { + for instance in local.scaleway_config_raw : + instance.name => { + name = "${instance.name} (${instance.cpu} CPU, ${instance.gpu} GPU, ${floor(instance.ram / 1073741824)} GB RAM)" + value = instance.name + } + } + + instance_arch_map = { + for instance in local.scaleway_config_raw : + instance.name => instance.arch + } + + # Convert Scaleway arch format to Coder arch format + selected_arch = local.instance_arch_map[data.coder_parameter.instance_size.value] == "x86_64" ? "amd64" : local.instance_arch_map[data.coder_parameter.instance_size.value] +} + +data "coder_parameter" "instance_size" { + name = "instance_size" + display_name = "Instance Size" + description = "Which Instance Size should be used?" + default = "DEV1-M" + type = "string" + icon = "/icon/memory.svg" + mutable = false + form_type = "dropdown" + + dynamic "option" { + for_each = local.scaleway_instance_options + content { + name = option.value.name + value = option.value.value + } + } +} + +data "coder_parameter" "volume_iops" { + name = "Volume IOPS" + description = "IOPS to provision for disk" + type = "number" + default = 5000 + option { + name = "5000" + value = 5000 + } + option { + name = "15000" + value = 15000 + } +} + +resource "scaleway_instance_server" "workspace" { + count = data.coder_workspace.me.start_count + name = "coder-${lower(data.coder_workspace_owner.me.name)}-${lower(data.coder_workspace.me.name)}" + type = data.coder_parameter.instance_size.value + image = data.coder_parameter.base_image.value + ip_ids = [scaleway_instance_ip.server_ip[0].id, scaleway_instance_ip.v4_server_ip[0].id] + project_id = var.project_id + user_data = { + cloud-init = data.cloudinit_config.user_data.rendered + } + additional_volume_ids = [scaleway_block_volume.persistent_storage.id] + + root_volume { + size_in_gb = data.coder_parameter.root_volume_size.value + } +} + +resource "scaleway_block_volume" "persistent_storage" { + iops = data.coder_parameter.volume_iops.value + name = "coder-${lower(data.coder_workspace_owner.me.name)}-${lower(data.coder_workspace.me.name)}-home" + size_in_gb = data.coder_parameter.disk_size.value + project_id = var.project_id +} + + +resource "scaleway_instance_ip" "server_ip" { + count = data.coder_workspace.me.start_count + type = "routed_ipv6" + project_id = var.project_id +} + +resource "scaleway_instance_ip" "v4_server_ip" { + count = data.coder_workspace.me.start_count + type = "routed_ipv4" + project_id = var.project_id +} + +variable "project_id" { + type = string + description = "ID of the project to deploy into" +} + +variable "access_key" { + type = string + description = "Access key to use to deploy" +} + +variable "secret_key" { + type = string + description = "Secret key to use to deploy" +} diff --git a/registry/mossylion/templates/scaleway-instance/scaleway-config.json b/registry/mossylion/templates/scaleway-instance/scaleway-config.json new file mode 100644 index 00000000..e48fb05c --- /dev/null +++ b/registry/mossylion/templates/scaleway-instance/scaleway-config.json @@ -0,0 +1,450 @@ +[ + { + "name": "COPARM1-2C-8G", + "cpu": 2, + "gpu": 0, + "ram": 8589934592, + "arch": "arm64" + }, + { + "name": "COPARM1-4C-16G", + "cpu": 4, + "gpu": 0, + "ram": 17179869184, + "arch": "arm64" + }, + { + "name": "COPARM1-8C-32G", + "cpu": 8, + "gpu": 0, + "ram": 34359738368, + "arch": "arm64" + }, + { + "name": "COPARM1-16C-64G", + "cpu": 16, + "gpu": 0, + "ram": 68719476736, + "arch": "arm64" + }, + { + "name": "COPARM1-32C-128G", + "cpu": 32, + "gpu": 0, + "ram": 137438953472, + "arch": "arm64" + }, + { + "name": "DEV1-S", + "cpu": 2, + "gpu": 0, + "ram": 2147483648, + "arch": "x86_64" + }, + { + "name": "DEV1-M", + "cpu": 3, + "gpu": 0, + "ram": 4294967296, + "arch": "x86_64" + }, + { + "name": "DEV1-L", + "cpu": 4, + "gpu": 0, + "ram": 8589934592, + "arch": "x86_64" + }, + { + "name": "DEV1-XL", + "cpu": 4, + "gpu": 0, + "ram": 12884901888, + "arch": "x86_64" + }, + { + "name": "ENT1-XXS", + "cpu": 2, + "gpu": 0, + "ram": 8589934592, + "arch": "x86_64" + }, + { + "name": "ENT1-XS", + "cpu": 4, + "gpu": 0, + "ram": 17179869184, + "arch": "x86_64" + }, + { + "name": "ENT1-S", + "cpu": 8, + "gpu": 0, + "ram": 34359738368, + "arch": "x86_64" + }, + { + "name": "ENT1-M", + "cpu": 16, + "gpu": 0, + "ram": 68719476736, + "arch": "x86_64" + }, + { + "name": "ENT1-L", + "cpu": 32, + "gpu": 0, + "ram": 137438953472, + "arch": "x86_64" + }, + { + "name": "ENT1-XL", + "cpu": 64, + "gpu": 0, + "ram": 274877906944, + "arch": "x86_64" + }, + { + "name": "ENT1-2XL", + "cpu": 96, + "gpu": 0, + "ram": 412316860416, + "arch": "x86_64" + }, + { + "name": "GP1-XS", + "cpu": 4, + "gpu": 0, + "ram": 17179869184, + "arch": "x86_64" + }, + { + "name": "GP1-S", + "cpu": 8, + "gpu": 0, + "ram": 34359738368, + "arch": "x86_64" + }, + { + "name": "GP1-M", + "cpu": 16, + "gpu": 0, + "ram": 68719476736, + "arch": "x86_64" + }, + { + "name": "GP1-L", + "cpu": 32, + "gpu": 0, + "ram": 137438953472, + "arch": "x86_64" + }, + { + "name": "GP1-XL", + "cpu": 48, + "gpu": 0, + "ram": 274877906944, + "arch": "x86_64" + }, + { + "name": "L4-1-24G", + "cpu": 8, + "gpu": 1, + "ram": 51539607552, + "arch": "x86_64" + }, + { + "name": "L4-2-24G", + "cpu": 16, + "gpu": 2, + "ram": 103079215104, + "arch": "x86_64" + }, + { + "name": "L4-4-24G", + "cpu": 32, + "gpu": 4, + "ram": 206158430208, + "arch": "x86_64" + }, + { + "name": "L4-8-24G", + "cpu": 64, + "gpu": 8, + "ram": 412316860416, + "arch": "x86_64" + }, + { + "name": "PLAY2-PICO", + "cpu": 1, + "gpu": 0, + "ram": 2147483648, + "arch": "x86_64" + }, + { + "name": "PLAY2-NANO", + "cpu": 2, + "gpu": 0, + "ram": 4294967296, + "arch": "x86_64" + }, + { + "name": "PLAY2-MICRO", + "cpu": 4, + "gpu": 0, + "ram": 8589934592, + "arch": "x86_64" + }, + { + "name": "POP2-HC-2C-4G", + "cpu": 2, + "gpu": 0, + "ram": 4294967296, + "arch": "x86_64" + }, + { + "name": "POP2-2C-8G", + "cpu": 2, + "gpu": 0, + "ram": 8589934592, + "arch": "x86_64" + }, + { + "name": "POP2-HM-2C-16G", + "cpu": 2, + "gpu": 0, + "ram": 17179869184, + "arch": "x86_64" + }, + { + "name": "POP2-HC-4C-8G", + "cpu": 4, + "gpu": 0, + "ram": 8589934592, + "arch": "x86_64" + }, + { + "name": "POP2-4C-16G", + "cpu": 4, + "gpu": 0, + "ram": 17179869184, + "arch": "x86_64" + }, + { + "name": "POP2-2C-8G-WIN", + "cpu": 2, + "gpu": 0, + "ram": 8589934592, + "arch": "x86_64" + }, + { + "name": "POP2-HM-4C-32G", + "cpu": 4, + "gpu": 0, + "ram": 34359738368, + "arch": "x86_64" + }, + { + "name": "POP2-HC-8C-16G", + "cpu": 8, + "gpu": 0, + "ram": 17179869184, + "arch": "x86_64" + }, + { + "name": "POP2-HN-3", + "cpu": 2, + "gpu": 0, + "ram": 4294967296, + "arch": "x86_64" + }, + { + "name": "POP2-8C-32G", + "cpu": 8, + "gpu": 0, + "ram": 34359738368, + "arch": "x86_64" + }, + { + "name": "POP2-4C-16G-WIN", + "cpu": 4, + "gpu": 0, + "ram": 17179869184, + "arch": "x86_64" + }, + { + "name": "POP2-HM-8C-64G", + "cpu": 8, + "gpu": 0, + "ram": 68719476736, + "arch": "x86_64" + }, + { + "name": "POP2-HC-16C-32G", + "cpu": 16, + "gpu": 0, + "ram": 34359738368, + "arch": "x86_64" + }, + { + "name": "POP2-HN-5", + "cpu": 4, + "gpu": 0, + "ram": 8589934592, + "arch": "x86_64" + }, + { + "name": "POP2-16C-64G", + "cpu": 16, + "gpu": 0, + "ram": 68719476736, + "arch": "x86_64" + }, + { + "name": "POP2-8C-32G-WIN", + "cpu": 8, + "gpu": 0, + "ram": 34359738368, + "arch": "x86_64" + }, + { + "name": "POP2-HN-10", + "cpu": 4, + "gpu": 0, + "ram": 8589934592, + "arch": "x86_64" + }, + { + "name": "POP2-HM-16C-128G", + "cpu": 16, + "gpu": 0, + "ram": 137438953472, + "arch": "x86_64" + }, + { + "name": "POP2-HC-32C-64G", + "cpu": 32, + "gpu": 0, + "ram": 68719476736, + "arch": "x86_64" + }, + { + "name": "POP2-32C-128G", + "cpu": 32, + "gpu": 0, + "ram": 137438953472, + "arch": "x86_64" + }, + { + "name": "POP2-HC-48C-96G", + "cpu": 48, + "gpu": 0, + "ram": 103079215104, + "arch": "x86_64" + }, + { + "name": "POP2-16C-64G-WIN", + "cpu": 16, + "gpu": 0, + "ram": 68719476736, + "arch": "x86_64" + }, + { + "name": "POP2-HM-32C-256G", + "cpu": 32, + "gpu": 0, + "ram": 274877906944, + "arch": "x86_64" + }, + { + "name": "POP2-HC-64C-128G", + "cpu": 64, + "gpu": 0, + "ram": 137438953472, + "arch": "x86_64" + }, + { + "name": "POP2-48C-192G", + "cpu": 48, + "gpu": 0, + "ram": 206158430208, + "arch": "x86_64" + }, + { + "name": "POP2-64C-256G", + "cpu": 64, + "gpu": 0, + "ram": 274877906944, + "arch": "x86_64" + }, + { + "name": "POP2-HM-48C-384G", + "cpu": 48, + "gpu": 0, + "ram": 412316860416, + "arch": "x86_64" + }, + { + "name": "POP2-32C-128G-WIN", + "cpu": 32, + "gpu": 0, + "ram": 137438953472, + "arch": "x86_64" + }, + { + "name": "POP2-HM-64C-512G", + "cpu": 64, + "gpu": 0, + "ram": 549755813888, + "arch": "x86_64" + }, + { + "name": "PRO2-XXS", + "cpu": 2, + "gpu": 0, + "ram": 8589934592, + "arch": "x86_64" + }, + { + "name": "PRO2-XS", + "cpu": 4, + "gpu": 0, + "ram": 17179869184, + "arch": "x86_64" + }, + { + "name": "PRO2-S", + "cpu": 8, + "gpu": 0, + "ram": 34359738368, + "arch": "x86_64" + }, + { + "name": "PRO2-M", + "cpu": 16, + "gpu": 0, + "ram": 68719476736, + "arch": "x86_64" + }, + { + "name": "PRO2-L", + "cpu": 32, + "gpu": 0, + "ram": 137438953472, + "arch": "x86_64" + }, + { + "name": "RENDER-S", + "cpu": 10, + "gpu": 1, + "ram": 45097156608, + "arch": "x86_64" + }, + { + "name": "STARDUST1-S", + "cpu": 1, + "gpu": 0, + "ram": 1073741824, + "arch": "x86_64" + } +] diff --git a/registry/nboyers/.gitignore b/registry/nboyers/.gitignore new file mode 100644 index 00000000..9a58485f --- /dev/null +++ b/registry/nboyers/.gitignore @@ -0,0 +1,41 @@ +# Local and OS files +.DS_Store +Thumbs.db +*.log +*.tmp +*.swp +*.bak + +# Terraform +.terraform/ +.terraform.lock.hcl +terraform.tfstate +terraform.tfstate.backup +crash.log + +# Node / Bun / Python / other tool artifacts +node_modules/ +bun.lockb +package-lock.json +__pycache__/ +*.pyc + +# Cloud credentials and keys +*.pem +*.key +*.p12 +*.json +*.env +.envrc +aws-credentials +gcp.json +azure-creds.json + +# Archives +*.zip +*.tar.gz +*.tgz + +# Workspace artifacts +workspace/ +output/ diff --git a/registry/nboyers/.images/avatar.png b/registry/nboyers/.images/avatar.png new file mode 100644 index 00000000..546fbd89 Binary files /dev/null and b/registry/nboyers/.images/avatar.png differ diff --git a/registry/nboyers/README.md b/registry/nboyers/README.md new file mode 100644 index 00000000..57dc2bca --- /dev/null +++ b/registry/nboyers/README.md @@ -0,0 +1,14 @@ +--- +display_name: "Noah Boyers" +bio: "Cloud & DevOps engineer with an MBA, building scalable multi-cloud infrastructure." +avatar: "./.images/avatar.png" +github: "noahboyers" +linkedin: "https://www.linkedin.com/in/nboyers" +website: "https://nobosoftware.com" +support_email: "hello@nobosoftware.com" +status: "community" +--- + +# Noah Boyers + +Cloud and DevOps engineer focused on scalable, secure, and automated infrastructure across AWS, Azure, and GCP. diff --git a/registry/nboyers/templates/cloud-dev/README.md b/registry/nboyers/templates/cloud-dev/README.md new file mode 100644 index 00000000..2131fbd0 --- /dev/null +++ b/registry/nboyers/templates/cloud-dev/README.md @@ -0,0 +1,73 @@ +--- +display_name: Cloud DevOps Workspace +description: A multi-cloud DevOps workspace that runs on Amazon EKS and provides authenticated access to AWS, Azure, and GCP. +icon: ../../../../.icons/cloud-devops.svg +verified: false +tags: [devops, kubernetes, aws, eks, multi-cloud, terraform, cdk, pulumi] +--- + +# Cloud DevOps Workspace + +A secure, company-standard DevOps environment for platform and cloud engineers. + +This template deploys workspaces **into an existing Amazon EKS cluster** and provides developers with tools and credentials to work with **AWS, Azure, and GCP** from inside their workspace. + +Supports multiple Infrastructure-as-Code frameworks — **Terraform**, **AWS CDK**, and **Pulumi** — for flexible, multi-cloud development. + +## Features + +- **Multi-Cloud Ready** — authenticate to AWS, Azure, or GCP from a single workspace +- **Runs on EKS** — leverages existing Kubernetes infrastructure for scaling and security +- **IaC Tools Included** — Terraform, Terragrunt, CDK, Pulumi, tfsec, and more +- **Secure Isolation** — each workspace runs in its own Kubernetes namespace +- **Configurable Auth** — supports IRSA (AWS), Federated Identity (Azure), and WIF (GCP) + +## Variables + +| Variable | Description | Type | Default | +| ------------------------------------------------------------- | --------------------------------------------------------------- | ------ | ----------- | +| `host_cluster_name` | EKS cluster name where workspaces are deployed | string | — | +| `iac_tool` | Infrastructure-as-Code framework (`terraform`, `cdk`, `pulumi`) | string | `terraform` | +| `enable_aws` | Enable AWS authentication and tools | bool | `true` | +| `enable_azure` | Enable Azure authentication and tools | bool | `false` | +| `enable_gcp` | Enable GCP authentication and tools | bool | `false` | +| `aws_access_key_id` / `aws_secret_access_key` | AWS credentials (optional) | string | `""` | +| `azure_client_id` / `azure_client_secret` / `azure_tenant_id` | Azure credentials (optional) | string | `""` | +| `gcp_service_account` | GCP Service Account JSON (optional) | string | `""` | + +## Runtime Architecture + +| Layer | Platform | Purpose | +| ----------------------- | ------------------ | ------------------------------------------------------------ | +| **Infrastructure** | Amazon EKS | Where Coder deploys and runs the workspaces | +| **Workspace Container** | Ubuntu-based image | Developer environment (Terraform, CDK, Pulumi, CLIs) | +| **Cloud Access** | AWS / Azure / GCP | Target environments for deploying infrastructure or services | + +## Required Permissions and Setup Steps + +This template **runs on EKS** but allows developers inside the workspace to authenticate with **AWS, Azure, or GCP** using their own credentials or service identities. + +### Coder & Infrastructure (Admin Setup) + +Your Coder deployment must have: + +- Network access to an **existing EKS cluster** +- The Coder Helm chart installed and healthy +- Terraform configured with access to the EKS API + +#### Minimum AWS IAM Permissions + +For the identity running the template (Coder service account, Terraform runner, or user): + +```json +{ + "Effect": "Allow", + "Action": [ + "eks:DescribeCluster", + "eks:ListClusters", + "sts:GetCallerIdentity", + "sts:AssumeRole" + ], + "Resource": "*" +} +``` diff --git a/registry/nboyers/templates/cloud-dev/main.tf b/registry/nboyers/templates/cloud-dev/main.tf new file mode 100644 index 00000000..67b64cf0 --- /dev/null +++ b/registry/nboyers/templates/cloud-dev/main.tf @@ -0,0 +1,120 @@ +terraform { + required_providers { + coder = { + source = "coder/coder" + version = "~> 0.23" + } + kubernetes = { + source = "hashicorp/kubernetes" + version = "~> 2.23" + } + aws = { + source = "hashicorp/aws" + version = "~> 5.0" + } + } +} + +# --- Coder workspace context --- +data "coder_workspace" "me" {} +data "coder_workspace_owner" "me" {} + +# --- EKS connection --- +data "aws_eks_cluster" "eks" { + name = trimspace(var.host_cluster_name) +} + + +data "aws_eks_cluster_auth" "eks" { + name = trimspace(var.host_cluster_name) +} + +provider "kubernetes" { + host = data.aws_eks_cluster.eks.endpoint + cluster_ca_certificate = base64decode(data.aws_eks_cluster.eks.certificate_authority[0].data) + token = data.aws_eks_cluster_auth.eks.token +} + +# --- Namespace per workspace --- +resource "kubernetes_namespace" "workspace" { + metadata { + name = "coder-${data.coder_workspace_owner.me.name}-${data.coder_workspace.me.name}" + labels = { + "coder.workspace" = data.coder_workspace.me.name + "coder.owner" = data.coder_workspace_owner.me.name + } + } +} + +# --- ServiceAccount (IRSA optional) --- +resource "kubernetes_service_account" "workspace" { + metadata { + name = "coder-${data.coder_workspace_owner.me.name}-${data.coder_workspace.me.name}" + namespace = kubernetes_namespace.workspace.metadata[0].name + + annotations = var.enable_aws && var.aws_role_arn != "" ? { + "eks.amazonaws.com/role-arn" = var.aws_role_arn + } : {} + } +} + +# --- Coder Agent definition --- +resource "coder_agent" "main" { + os = "linux" + arch = "amd64" + + startup_script = file("${path.module}/scripts/setup-workspace.sh") + + env = { + # IaC tool & cloud toggles + IAC_TOOL = var.iac_tool + ENABLE_AWS = tostring(var.enable_aws) + ENABLE_AZURE = tostring(var.enable_azure) + ENABLE_GCP = tostring(var.enable_gcp) + + # Developer credentials + AWS_ACCESS_KEY_ID = var.aws_access_key_id + AWS_SECRET_ACCESS_KEY = var.aws_secret_access_key + AZURE_CLIENT_ID = var.azure_client_id + AZURE_TENANT_ID = var.azure_tenant_id + AZURE_CLIENT_SECRET = var.azure_client_secret + GCP_SERVICE_ACCOUNT = var.gcp_service_account + } +} + +# --- Kubernetes Pod (runs workspace container) --- +resource "kubernetes_pod" "workspace" { + count = data.coder_workspace.me.start_count + + metadata { + name = "coder-${data.coder_workspace_owner.me.name}-${data.coder_workspace.me.name}" + namespace = kubernetes_namespace.workspace.metadata[0].name + labels = { + "app" = "coder-workspace" + "coder.owner" = data.coder_workspace_owner.me.name + "coder.agent" = "true" + } + } + + spec { + service_account_name = kubernetes_service_account.workspace.metadata[0].name + + container { + name = "workspace" + image = "codercom/enterprise-base:ubuntu" + command = ["/bin/bash", "-c", coder_agent.main.init_script] + + env { + name = "CODER_AGENT_TOKEN" + value = coder_agent.main.token + } + + resources { + requests = { cpu = "500m", memory = "1Gi" } + limits = { cpu = "2", memory = "4Gi" } + } + } + } + + depends_on = [coder_agent.main] +} diff --git a/registry/nboyers/templates/cloud-dev/scripts/cloud-auth.sh b/registry/nboyers/templates/cloud-dev/scripts/cloud-auth.sh new file mode 100644 index 00000000..18550bb0 --- /dev/null +++ b/registry/nboyers/templates/cloud-dev/scripts/cloud-auth.sh @@ -0,0 +1,319 @@ +#!/usr/bin/env bash +# cloud-auth.sh — Multi-cloud auth helpers (source this file, don't execute) +# Supports: +# - AWS: access keys or IRSA (via pod SA) +# - Azure: federated token or client secret +# - GCP: service account JSON or Workload Identity Federation (KSA -> SA) + +set -euo pipefail + +# -------- util -------- +_has() { command -v "$1" > /dev/null 2>&1; } +_docker_ok() { _has docker && [[ -S /var/run/docker.sock ]]; } + +cloud-auth-help() { + cat << 'EOHELP' +Multi-Cloud Authentication Helper — source this file: + + source ~/workspace/cloud-auth.sh + +Environment variables (read if set): + + # Common toggles (optional) + ENABLE_AWS=true|false + ENABLE_AZURE=true|false + ENABLE_GCP=true|false + + # AWS + AWS_REGION=us-west-2 + AWS_ACCESS_KEY_ID=... + AWS_SECRET_ACCESS_KEY=... + AWS_SESSION_TOKEN=... # optional (STS); if unset, IRSA/IMDS is used + + # Azure + AZURE_CLIENT_ID=... + AZURE_TENANT_ID=... + AZURE_CLIENT_SECRET=... # OR: + AZURE_FEDERATED_TOKEN_FILE=/var/run/secrets/azure/tokens/azure-identity-token + + # GCP + GCP_PROJECT_ID=... + # Option A (Service Account JSON): + GCP_SERVICE_ACCOUNT='{ ... }' + # Option B (Workload Identity Federation): + GCP_WORKLOAD_IDENTITY_PROVIDER=projects/..../locations/global/workloadIdentityPools/.../providers/... + # (uses KSA token at /var/run/secrets/kubernetes.io/serviceaccount/token) + +Functions: + + # AWS + aws-login # ensures creds (keys or IRSA), sets region config if provided + aws-check # prints caller identity + aws-ecr-login # docker login to ECR (if docker socket present) + + # Azure + azure-login # SP login via federated token OR client secret + azure-check # prints account info + azure-acr-login # docker login to ACR (requires AZURE_ACR_NAME) + + # GCP + gcp-login # SA JSON or WIF + gcp-check # prints active gcloud account & project + gcp-gar-login # docker auth to GAR (requires GCP_REGION & PROJECT) + + # Convenience + multicloud-login # calls the per-cloud logins if toggles are true + multicloud-check # calls the per-cloud checks +EOHELP +} + +# -------- AWS -------- +aws-login() { + [[ "${ENABLE_AWS:-true}" == "true" ]] || { + echo "AWS disabled" + return 0 + } + if ! _has aws; then + echo "aws CLI not found" + return 1 + fi + + # If access keys are present, write standard files; otherwise rely on IRSA/IMDS + if [[ -n "${AWS_ACCESS_KEY_ID:-}" ]]; then + mkdir -p "${HOME}/.aws" + { + echo "[default]" + echo "aws_access_key_id=${AWS_ACCESS_KEY_ID}" + echo "aws_secret_access_key=${AWS_SECRET_ACCESS_KEY:-}" + [[ -n "${AWS_SESSION_TOKEN:-}" ]] && echo "aws_session_token=${AWS_SESSION_TOKEN}" + } > "${HOME}/.aws/credentials" + if [[ -n "${AWS_REGION:-}" ]]; then + { + echo "[default]" + echo "region=${AWS_REGION}" + } > "${HOME}/.aws/config" + fi + fi + + # Validate + if ! aws sts get-caller-identity > /dev/null 2>&1; then + echo "❌ AWS auth failed (neither valid keys nor IRSA available)" + return 1 + fi + echo "✅ AWS auth OK" +} + +aws-check() { + _has aws || { + echo "aws CLI not found" + return 1 + } + aws sts get-caller-identity +} + +aws-ecr-login() { + _has aws || { + echo "aws CLI not found" + return 1 + } + _docker_ok || { + echo "â„šī¸ docker socket not available; skipping ECR login" + return 0 + } + : "${AWS_REGION:=us-east-1}" + aws-login > /dev/null || return 1 + AWS_ACCOUNT_ID="$(aws sts get-caller-identity --query Account --output text)" + aws ecr get-login-password --region "${AWS_REGION}" \ + | docker login --username AWS --password-stdin \ + "${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com" + export ECR_REGISTRY="${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com" + echo "✅ ECR login OK → ${ECR_REGISTRY}" +} + +# -------- Azure -------- +azure-login() { + [[ "${ENABLE_AZURE:-false}" == "true" ]] || { + echo "Azure disabled" + return 0 + } + _has az || { + echo "az CLI not found" + return 1 + } + [[ -n "${AZURE_CLIENT_ID:-}" && -n "${AZURE_TENANT_ID:-}" ]] || { + echo "❌ Set AZURE_CLIENT_ID and AZURE_TENANT_ID" + return 1 + } + + if [[ -n "${AZURE_FEDERATED_TOKEN_FILE:-}" && -f "${AZURE_FEDERATED_TOKEN_FILE}" ]]; then + az login --service-principal \ + --username "${AZURE_CLIENT_ID}" \ + --tenant "${AZURE_TENANT_ID}" \ + --federated-token "$(cat "${AZURE_FEDERATED_TOKEN_FILE}")" \ + --allow-no-subscriptions + elif [[ -n "${AZURE_CLIENT_SECRET:-}" ]]; then + az login --service-principal \ + -u "${AZURE_CLIENT_ID}" -p "${AZURE_CLIENT_SECRET}" \ + --tenant "${AZURE_TENANT_ID}" + else + echo "❌ Provide AZURE_FEDERATED_TOKEN_FILE or AZURE_CLIENT_SECRET" + return 1 + fi + + echo "✅ Azure auth OK" +} + +azure-check() { + _has az || { + echo "az CLI not found" + return 1 + } + az account show +} + +azure-acr-login() { + _has az || { + echo "az CLI not found" + return 1 + } + _docker_ok || { + echo "â„šī¸ docker socket not available; skipping ACR login" + return 0 + } + [[ -n "${AZURE_ACR_NAME:-}" ]] || { + echo "❌ Set AZURE_ACR_NAME" + return 1 + } + az account show > /dev/null 2>&1 || azure-login + az acr login --name "${AZURE_ACR_NAME}" + export ACR_REGISTRY="${AZURE_ACR_NAME}.azurecr.io" + echo "✅ ACR login OK → ${ACR_REGISTRY}" +} + +# -------- GCP -------- +gcp-login() { + [[ "${ENABLE_GCP:-false}" == "true" ]] || { + echo "GCP disabled" + return 0 + } + _has gcloud || { + echo "gcloud not found" + return 1 + } + + if [[ -n "${GCP_SERVICE_ACCOUNT:-}" ]]; then + # Service Account JSON path + echo "${GCP_SERVICE_ACCOUNT}" > /tmp/gcp.json || { + echo "❌ Failed to write GCP credentials" + return 1 + } + export GOOGLE_APPLICATION_CREDENTIALS=/tmp/gcp.json || { + echo "❌ Failed to set GCP credentials path" + return 1 + } + gcloud auth activate-service-account --key-file=/tmp/gcp.json --quiet || { + echo "❌ GCP service account auth failed" + return 1 + } + else + # Workload Identity Federation using KSA token + WIP provider + [[ -n "${GCP_WORKLOAD_IDENTITY_PROVIDER:-}" && -n "${GCP_PROJECT_ID:-}" ]] || { + echo "❌ Provide GCP_SERVICE_ACCOUNT JSON or set GCP_WORKLOAD_IDENTITY_PROVIDER & GCP_PROJECT_ID" + return 1 + } + [[ -f "/var/run/secrets/kubernetes.io/serviceaccount/token" ]] || { + echo "❌ KSA token not found" + return 1 + } + + TMP="/tmp/gcp-wif-$$.json" + cat > "${TMP}" << 'EOF' +{ + "type": "external_account", + "audience": "//iam.googleapis.com/${GCP_WORKLOAD_IDENTITY_PROVIDER}", + "subject_token_type": "urn:ietf:params:oauth:token-type:jwt", + "token_url": "https://sts.googleapis.com/v1/token", + "credential_source": { + "file": "/var/run/secrets/kubernetes.io/serviceaccount/token", + "format": { "type": "text" } + } +} +EOF + [[ $? -eq 0 ]] || { + echo "❌ Failed to write GCP WIF config" + return 1 + } + export GOOGLE_APPLICATION_CREDENTIALS="${TMP}" || { + echo "❌ Failed to set GCP credentials path" + return 1 + } + gcloud auth login --cred-file="${GOOGLE_APPLICATION_CREDENTIALS}" --quiet || { + echo "❌ GCP WIF auth failed" + return 1 + } + fi + + if [[ -n "${GCP_PROJECT_ID:-}" ]]; then + gcloud config set project "${GCP_PROJECT_ID}" --quiet + fi + echo "✅ GCP auth OK" +} + +gcp-check() { + _has gcloud || { + echo "gcloud not found" + return 1 + } + gcloud auth list + gcloud config get-value project || true +} + +gcp-gar-login() { + _docker_ok || { + echo "â„šī¸ docker socket not available; skipping GAR login" + return 0 + } + : "${GCP_REGION:=us-central1}" + [[ -n "${GCP_PROJECT_ID:-}" ]] || { + echo "❌ Set GCP_PROJECT_ID" + return 1 + } + gcloud auth list --filter=status:ACTIVE --format="value(account)" > /dev/null || gcp-login + gcloud auth configure-docker "${GCP_REGION}-docker.pkg.dev" --quiet + export GAR_REGISTRY="${GCP_REGION}-docker.pkg.dev/${GCP_PROJECT_ID}" + echo "✅ GAR configured → ${GAR_REGISTRY}" +} + +# -------- Convenience -------- +multicloud-login() { + if [[ "${ENABLE_AWS:-true}" == "true" ]]; then + aws-login + fi + if [[ "${ENABLE_AZURE:-false}" == "true" ]]; then + azure-login + fi + if [[ "${ENABLE_GCP:-false}" == "true" ]]; then + gcp-login + fi + echo "✨ Multi-cloud login complete" +} + +multicloud-check() { + if [[ "${ENABLE_AWS:-true}" == "true" ]]; then + echo "AWS:" + aws-check + echo + fi + if [[ "${ENABLE_AZURE:-false}" == "true" ]]; then + echo "Azure:" + azure-check + echo + fi + if [[ "${ENABLE_GCP:-false}" == "true" ]]; then + echo "GCP:" + gcp-check + echo + fi +} + +echo "✨ cloud-auth loaded. Run 'cloud-auth-help' for usage." diff --git a/registry/nboyers/templates/cloud-dev/scripts/setup-workspace.sh b/registry/nboyers/templates/cloud-dev/scripts/setup-workspace.sh new file mode 100644 index 00000000..937cb9cc --- /dev/null +++ b/registry/nboyers/templates/cloud-dev/scripts/setup-workspace.sh @@ -0,0 +1,501 @@ +#!/usr/bin/env bash +set -euo pipefail + +# ========================= +# Helpers & safe defaults +# ========================= +log() { printf '%s %s\n' "👉" "$*"; } +ok() { printf '%s %s\n' "✅" "$*"; } +skip() { printf '%s %s\n' "â­ī¸" "$*"; } +warn() { printf '%s %s\n' "âš ī¸" "$*"; } + +# Detect CPU arch (amd64/arm64) +arch() { + case "$(uname -m)" in + x86_64 | amd64) echo amd64 ;; + aarch64 | arm64) echo arm64 ;; + *) echo amd64 ;; + esac +} + +# Map to Docker static tarball arch names +docker_tar_arch() { + case "$(arch)" in + amd64) echo x86_64 ;; + arm64) echo aarch64 ;; + *) echo x86_64 ;; + esac +} + +SAFE_TMP="$(mktemp -d)" +trap 'rm -rf "$SAFE_TMP"' EXIT + +safe_dl() { # url dest + curl -fL --retry 5 --retry-delay 2 --connect-timeout 10 -o "$2" "$1" || { + echo "Failed to download $1" + return 1 + } +} + +docker_ok() { + command -v docker > /dev/null 2>&1 && [[ -S /var/run/docker.sock ]] +} + +# Ensure user bin dir +mkdir -p "$HOME/.local/bin" "$HOME/workspace/app" +export PATH="$HOME/.local/bin:$PATH" + +# Inputs (with sane defaults) +IAC_TOOL="${IAC_TOOL:-terraform}" +TERRAFORM_VERSION="${TERRAFORM_VERSION:-1.6.0}" + +ENABLE_AWS="${ENABLE_AWS:-true}" +ENABLE_AZURE="${ENABLE_AZURE:-false}" +ENABLE_GCP="${ENABLE_GCP:-false}" + +AWS_REGION="${AWS_REGION:-}" +AWS_ACCESS_KEY_ID="${AWS_ACCESS_KEY_ID:-}" +AWS_SECRET_ACCESS_KEY="${AWS_SECRET_ACCESS_KEY:-}" +AWS_SESSION_TOKEN="${AWS_SESSION_TOKEN:-}" + +AZURE_CLIENT_ID="${AZURE_CLIENT_ID:-}" +AZURE_TENANT_ID="${AZURE_TENANT_ID:-}" +AZURE_CLIENT_SECRET="${AZURE_CLIENT_SECRET:-}" +AZURE_FEDERATED_TOKEN_FILE="${AZURE_FEDERATED_TOKEN_FILE:-}" + +GCP_PROJECT_ID="${GCP_PROJECT_ID:-}" +GCP_SERVICE_ACCOUNT="${GCP_SERVICE_ACCOUNT:-}" # full JSON if not using WIF + +REPO_URL="${REPO_URL:-${repo_url:-}}" +DEFAULT_BRANCH="${DEFAULT_BRANCH:-${default_branch:-main}}" +WORKDIR="${WORKDIR:-$HOME/workspace/app}" +GITHUB_TOKEN="${GITHUB_TOKEN:-${GIT_TOKEN:-}}" + +GIT_AUTHOR_NAME="${GIT_AUTHOR_NAME:-}" +GIT_AUTHOR_EMAIL="${GIT_AUTHOR_EMAIL:-}" + +echo "╔════════════════════════════════════════════════════════════════╗" +echo "║ Multi-Cloud DevOps Workspace Setup (no sudo) ║" +echo "╚════════════════════════════════════════════════════════════════╝" +echo + +# ========================================================== +# Write multi-cloud helper functions to ~/workspace/cloud-auth.sh +# ========================================================== +cat > "${HOME}/workspace/cloud-auth.sh" << 'EOAUTHSCRIPT' +#!/usr/bin/env bash +set -euo pipefail + +aws-ecr-login() { + : "${AWS_REGION:=us-east-1}" + if ! command -v aws >/dev/null 2>&1; then echo "aws CLI not found"; return 1; fi + if ! aws sts get-caller-identity &>/dev/null; then + echo "❌ AWS creds not available (IRSA or keys)"; return 1; fi + AWS_ACCOUNT_ID="$(aws sts get-caller-identity --query Account --output text)" + if command -v docker >/dev/null 2>&1 && [[ -S /var/run/docker.sock ]]; then + aws ecr get-login-password --region "${AWS_REGION}" | \ + docker login --username AWS --password-stdin \ + "${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com" + export ECR_REGISTRY="${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com" + echo "✅ ECR login OK → ${ECR_REGISTRY}" + else + echo "â„šī¸ docker socket not available; skipping docker login" + fi +} + +aws-check() { aws sts get-caller-identity && echo "✓ AWS creds valid"; } + +azure-login() { + if ! command -v az >/dev/null 2>&1; then echo "az CLI not found"; return 1; fi + if [[ -n "${AZURE_FEDERATED_TOKEN_FILE:-}" && -f "${AZURE_FEDERATED_TOKEN_FILE}" ]]; then + az login --service-principal --username "${AZURE_CLIENT_ID}" \ + --tenant "${AZURE_TENANT_ID}" \ + --federated-token "$(cat "${AZURE_FEDERATED_TOKEN_FILE}")" \ + --allow-no-subscriptions + elif [[ -n "${AZURE_CLIENT_SECRET:-}" ]]; then + az login --service-principal -u "${AZURE_CLIENT_ID}" -p "${AZURE_CLIENT_SECRET}" --tenant "${AZURE_TENANT_ID}" + else + echo "❌ Provide AZURE_FEDERATED_TOKEN_FILE or AZURE_CLIENT_SECRET"; return 1 + fi + echo "✅ Azure auth OK"; az account show +} + +azure-acr-login() { + [[ -n "${AZURE_ACR_NAME:-}" ]] || { echo "Set AZURE_ACR_NAME"; return 1; } + az account show &>/dev/null || azure-login + if command -v docker >/dev/null 2>&1 && [[ -S /var/run/docker.sock ]]; then + az acr login --name "${AZURE_ACR_NAME}" + export ACR_REGISTRY="${AZURE_ACR_NAME}.azurecr.io" + echo "✅ ACR login OK → ${ACR_REGISTRY}" + else + echo "â„šī¸ docker socket not available; skipping docker login" + fi +} + +azure-check() { az account show && echo "✓ Azure creds valid" || { echo "❌ Not logged in"; return 1; }; } + +gcp-login() { + if ! command -v gcloud >/dev/null 2>&1; then echo "gcloud not found"; return 1; fi + if [[ -n "${GCP_SERVICE_ACCOUNT:-}" ]]; then + # SA JSON auth + echo "${GCP_SERVICE_ACCOUNT}" > /tmp/gcp.json || { echo "❌ Failed to write GCP credentials"; return 1; } + export GOOGLE_APPLICATION_CREDENTIALS=/tmp/gcp.json + gcloud auth activate-service-account --key-file=/tmp/gcp.json --quiet || { echo "❌ GCP auth failed"; return 1; } + else + echo "❌ Provide GCP_SERVICE_ACCOUNT JSON (WIF path not configured here)"; return 1 + fi + [[ -n "${GCP_PROJECT_ID:-}" ]] && gcloud config set project "${GCP_PROJECT_ID}" --quiet || true + echo "✅ GCP auth OK"; gcloud auth list +} + +gcp-gar-login() { + : "${GCP_REGION:=us-central1}" + [[ -n "${GCP_PROJECT_ID:-}" ]] || { echo "Set GCP_PROJECT_ID"; return 1; } + gcloud auth list --filter=status:ACTIVE --format="value(account)" &>/dev/null || gcp-login + if command -v docker >/dev/null 2>&1 && [[ -S /var/run/docker.sock ]]; then + gcloud auth configure-docker "${GCP_REGION}-docker.pkg.dev" --quiet + export GAR_REGISTRY="${GCP_REGION}-docker.pkg.dev/${GCP_PROJECT_ID}" + echo "✅ GAR configured → ${GAR_REGISTRY}" + else + echo "â„šī¸ docker socket not available; skipping docker login" + fi +} + +gcp-check() { gcloud auth list --filter=status:ACTIVE --format="value(account)" >/dev/null && echo "✓ GCP creds valid" || { echo "❌ Not logged in"; return 1; }; } + +multicloud-login() { + [[ "${ENABLE_AWS:-false}" == "true" ]] && command -v aws >/dev/null && aws-ecr-login || true + [[ "${ENABLE_AZURE:-false}" == "true" ]] && command -v az >/dev/null && azure-login || true + [[ "${ENABLE_GCP:-false}" == "true" ]] && command -v gcloud >/dev/null && gcp-login || true + echo "✨ Multi-cloud login complete" +} + +multicloud-check() { + [[ "${ENABLE_AWS:-false}" == "true" ]] && command -v aws >/dev/null && { echo "AWS:"; aws-check; echo; } || true + [[ "${ENABLE_AZURE:-false}" == "true" ]] && command -v az >/dev/null && { echo "Azure:"; azure-check; echo; } || true + [[ "${ENABLE_GCP:-false}" == "true" ]] && command -v gcloud >/dev/null && { echo "GCP:"; gcp-check; echo; } || true +} + +cloud-auth-help() { + cat <<'EOHELP' +Multi-Cloud Authentication Helper + +Functions: + AWS: aws-ecr-login, aws-check + Azure: azure-login, azure-acr-login, azure-check + GCP: gcp-login, gcp-gar-login, gcp-check + Multi: multicloud-login, multicloud-check, cloud-auth-help +EOHELP + return 0 +} + +echo "✨ Multi-cloud auth helpers loaded. Run 'cloud-auth-help' for help." +EOAUTHSCRIPT +chmod +x "${HOME}/workspace/cloud-auth.sh" +ok "Created ${HOME}/workspace/cloud-auth.sh" +echo + +# ========================= +# IaC tooling +# ========================= +log "Installing IaC tooling (${IAC_TOOL})" +case "$IAC_TOOL" in + terraform) + if ! command -v terraform > /dev/null 2>&1; then + safe_dl "https://releases.hashicorp.com/terraform/${TERRAFORM_VERSION}/terraform_${TERRAFORM_VERSION}_linux_$(arch).zip" "$SAFE_TMP/tf.zip" + unzip -q "$SAFE_TMP/tf.zip" -d "$HOME/.local/bin" + ok "Terraform ${TERRAFORM_VERSION} installed" + else + ok "Terraform already installed ($(terraform version | head -1))" + fi + ;; + cdk) + if ! command -v npm > /dev/null 2>&1; then + log "npm not found; installing Node via nvm" + export NVM_DIR="$HOME/.nvm" + curl -fsSL https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash + # shellcheck disable=SC1090 + . "$NVM_DIR/nvm.sh" + nvm install --lts + nvm use --lts + # persist for future shells + grep -q 'NVM_DIR' "$HOME/.bashrc" 2> /dev/null || { + echo 'export NVM_DIR="$HOME/.nvm"' >> "$HOME/.bashrc" + echo '. "$NVM_DIR/nvm.sh"' >> "$HOME/.bashrc" + } + fi + npm install -g aws-cdk > /dev/null + ok "AWS CDK installed ($(cdk --version))" + ;; + pulumi) + if ! command -v pulumi > /dev/null 2>&1; then + curl -fsSL https://get.pulumi.com | sh + export PATH="$PATH:$HOME/.pulumi/bin" + ok "Pulumi installed ($(pulumi version))" + else + ok "Pulumi already installed ($(pulumi version))" + fi + ;; + *) + warn "Unknown IAC_TOOL=${IAC_TOOL}; skipping IaC install" + ;; +esac + +# Extras: Terragrunt, tflint, tfsec, terraform-docs, pre-commit +if ! command -v terragrunt > /dev/null 2>&1; then + TG_VER="0.54.0" + safe_dl "https://github.com/gruntwork-io/terragrunt/releases/download/v${TG_VER}/terragrunt_linux_$(arch)" "$HOME/.local/bin/terragrunt" + chmod +x "$HOME/.local/bin/terragrunt" + ok "Terragrunt v${TG_VER} installed" +fi + +if ! command -v tflint > /dev/null 2>&1; then + # official installer handles arch + curl -fsSL https://raw.githubusercontent.com/terraform-linters/tflint/master/install_linux.sh | bash + mv -f /tmp/tflint "$HOME/.local/bin/" 2> /dev/null || true + ok "tflint installed" +fi + +if ! command -v tfsec > /dev/null 2>&1; then + TFSEC_VER="1.28.1" + safe_dl "https://github.com/aquasecurity/tfsec/releases/download/v${TFSEC_VER}/tfsec-linux-$(arch)" "$HOME/.local/bin/tfsec" + chmod +x "$HOME/.local/bin/tfsec" + ok "tfsec v${TFSEC_VER} installed" +fi + +if ! command -v terraform-docs > /dev/null 2>&1; then + TFD_VER="0.17.0" + safe_dl "https://github.com/terraform-docs/terraform-docs/releases/download/v${TFD_VER}/terraform-docs-v${TFD_VER}-linux-$(arch).tar.gz" "$SAFE_TMP/terraform-docs.tgz" + tar -xzf "$SAFE_TMP/terraform-docs.tgz" -C "$SAFE_TMP" + install -m 0755 "$SAFE_TMP/terraform-docs" "$HOME/.local/bin/terraform-docs" + ok "terraform-docs v${TFD_VER} installed" +fi + +if ! command -v pre-commit > /dev/null 2>&1; then + if command -v pip3 > /dev/null 2>&1; then + pip3 install --user --quiet pre-commit + ok "pre-commit installed" + elif command -v python3 > /dev/null 2>&1; then + python3 -m pip install --user --quiet pre-commit + ok "pre-commit installed" + else + warn "Python3/pip3 not found; skipping pre-commit" + fi +fi + +# ========================= +# Cloud CLIs (user-space) +# ========================= +echo +log "Installing Cloud CLIs (user-space)" + +# AWS CLI v2 +if [[ "${ENABLE_AWS}" == "true" ]] && ! command -v aws > /dev/null 2>&1; then + safe_dl "https://awscli.amazonaws.com/awscli-exe-linux-$(arch).zip" "$SAFE_TMP/awscliv2.zip" \ + || safe_dl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" "$SAFE_TMP/awscliv2.zip" + unzip -q "$SAFE_TMP/awscliv2.zip" -d "$SAFE_TMP" + "$SAFE_TMP/aws/install" -i "$HOME/.local/aws-cli" -b "$HOME/.local/bin" > /dev/null + ok "AWS CLI installed" +fi + +# Azure CLI +if [[ "${ENABLE_AZURE}" == "true" ]] && ! command -v az > /dev/null 2>&1; then + if command -v pip3 > /dev/null 2>&1; then + pip3 install --user --quiet azure-cli && ok "Azure CLI installed" + elif command -v python3 > /dev/null 2>&1; then + python3 -m pip install --user --quiet azure-cli && ok "Azure CLI installed" + else + warn "Python/pip not found; cannot install Azure CLI" + fi +fi + +# Google Cloud SDK +if [[ "${ENABLE_GCP}" == "true" ]] && ! command -v gcloud > /dev/null 2>&1; then + GSDK_ARCH="$([[ "$(arch)" == amd64 ]] && echo x86_64 || echo arm)" + safe_dl "https://dl.google.com/dl/cloudsdk/channels/rapid/downloads/google-cloud-cli-linux-${GSDK_ARCH}.tar.gz" "$SAFE_TMP/gcloud.tgz" + tar -xzf "$SAFE_TMP/gcloud.tgz" -C "$HOME" + mv "$HOME/google-cloud-sdk" "$HOME/.local/google-cloud-sdk" + ln -sf "$HOME/.local/google-cloud-sdk/bin/"{gcloud,gsutil,bq} "$HOME/.local/bin/" || true + "$HOME/.local/google-cloud-sdk/install.sh" --quiet --rc-path /dev/null --path-update=false || true + ok "Google Cloud SDK installed" +fi + +# ========================= +# Container & K8s tools +# ========================= +echo +log "Installing container & Kubernetes tools" + +# Docker CLI (client only) +if ! command -v docker > /dev/null 2>&1; then + DOCKER_VER="25.0.5" + safe_dl "https://download.docker.com/linux/static/stable/$(docker_tar_arch)/docker-${DOCKER_VER}.tgz" "$SAFE_TMP/docker.tgz" + tar -xzf "$SAFE_TMP/docker.tgz" -C "$SAFE_TMP" + install -m 0755 "$SAFE_TMP/docker/docker" "$HOME/.local/bin/docker" + ok "Docker client installed" +fi + +# kubectl +if ! command -v kubectl > /dev/null 2>&1; then + KREL="$(curl -fsSL https://dl.k8s.io/release/stable.txt)" + safe_dl "https://dl.k8s.io/release/${KREL}/bin/linux/$(arch)/kubectl" "$SAFE_TMP/kubectl" + install -m 0755 "$SAFE_TMP/kubectl" "$HOME/.local/bin/kubectl" + ok "kubectl ${KREL} installed" +fi + +# Helm +if ! command -v helm > /dev/null 2>&1; then + curl -fsSL https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | USE_SUDO=false HELM_INSTALL_DIR="$HOME/.local/bin" bash + ok "Helm installed" +fi + +# jq / yq +if ! command -v jq > /dev/null 2>&1; then + safe_dl "https://github.com/jqlang/jq/releases/download/jq-1.7.1/jq-linux-$(arch)" "$HOME/.local/bin/jq" + chmod +x "$HOME/.local/bin/jq" + ok "jq installed" +fi + +if ! command -v yq > /dev/null 2>&1; then + safe_dl "https://github.com/mikefarah/yq/releases/latest/download/yq_linux_$(arch)" "$HOME/.local/bin/yq" + chmod +x "$HOME/.local/bin/yq" + ok "yq installed" +fi + +# ========================= +# Cloud runtime auth (optional) +# ========================= +echo +log "Configuring runtime cloud auth (if provided)" + +# AWS keys (override IRSA if present) +if [[ "${ENABLE_AWS}" == "true" ]] && [[ -n "$AWS_ACCESS_KEY_ID" ]]; then + mkdir -p "$HOME/.aws" + { + echo "[default]" + echo "aws_access_key_id=${AWS_ACCESS_KEY_ID}" + echo "aws_secret_access_key=${AWS_SECRET_ACCESS_KEY:-}" + [[ -n "$AWS_SESSION_TOKEN" ]] && echo "aws_session_token=${AWS_SESSION_TOKEN}" + } > "$HOME/.aws/credentials" || { warn "Failed to write AWS credentials"; } + if [[ -n "$AWS_REGION" ]]; then + { + echo "[default]" + echo "region=${AWS_REGION}" + } > "$HOME/.aws/config" + fi + ok "AWS runtime creds configured${AWS_REGION:+ (region ${AWS_REGION})}" +else + skip "AWS runtime creds not set" +fi + +# Azure SP (client secret path; federated handled by helper) +if [[ "${ENABLE_AZURE}" == "true" ]] && [[ -n "$AZURE_CLIENT_ID" && -n "$AZURE_TENANT_ID" ]]; then + if command -v az > /dev/null 2>&1; then + if [[ -n "$AZURE_FEDERATED_TOKEN_FILE" && -f "$AZURE_FEDERATED_TOKEN_FILE" ]]; then + az login --service-principal --username "$AZURE_CLIENT_ID" \ + --tenant "$AZURE_TENANT_ID" \ + --federated-token "$(cat "$AZURE_FEDERATED_TOKEN_FILE")" \ + --allow-no-subscriptions > /dev/null + ok "Azure federated login complete" + elif [[ -n "$AZURE_CLIENT_SECRET" ]]; then + az login --service-principal -u "$AZURE_CLIENT_ID" -p "$AZURE_CLIENT_SECRET" --tenant "$AZURE_TENANT_ID" > /dev/null + ok "Azure SP login complete" + else + skip "Azure creds not provided (need federated token file or client secret)" + fi + else + warn "Azure CLI not found; skipping login" + fi +else + skip "Azure runtime auth not configured" +fi + +# GCP SA JSON +if [[ "${ENABLE_GCP}" == "true" ]] && [[ -n "$GCP_SERVICE_ACCOUNT" ]]; then + if command -v gcloud > /dev/null 2>&1; then + echo "$GCP_SERVICE_ACCOUNT" > /tmp/gcp.json || { warn "Failed to write GCP credentials"; } + export GOOGLE_APPLICATION_CREDENTIALS=/tmp/gcp.json + gcloud auth activate-service-account --key-file=/tmp/gcp.json > /dev/null || { warn "GCP auth failed"; } + [[ -n "$GCP_PROJECT_ID" ]] && gcloud config set project "$GCP_PROJECT_ID" --quiet || true + ok "GCP SA auth complete" + else + warn "gcloud not found; skipping GCP auth" + fi +else + skip "GCP runtime auth not configured" +fi + +# ========================= +# Git identity & bootstrap +# ========================= +echo +log "Preparing workspace directory" + +# Git identity +if [[ -n "$GIT_AUTHOR_NAME" ]]; then + git config --global user.name "$GIT_AUTHOR_NAME" +fi +if [[ -n "$GIT_AUTHOR_EMAIL" ]]; then + git config --global user.email "$GIT_AUTHOR_EMAIL" +fi + +mkdir -p "$WORKDIR" + +# Clone or init +if [[ -n "$REPO_URL" ]]; then + URL="$REPO_URL" + if [[ -n "$GITHUB_TOKEN" && "$URL" =~ ^https://github.com/ ]]; then + URL="${URL/https:\/\//https:\/\/${GITHUB_TOKEN}@}" || { warn "Failed to modify URL"; } + warn "Using GITHUB_TOKEN for private repo clone" + fi + if [[ ! -d "$WORKDIR/.git" ]]; then + log "Cloning ${REPO_URL} into ${WORKDIR}" + git clone "$URL" "$WORKDIR" || { warn "Failed to clone repository"; } + pushd "$WORKDIR" > /dev/null + git checkout "$DEFAULT_BRANCH" || git checkout -b "$DEFAULT_BRANCH" + popd > /dev/null + ok "Repository ready @ ${DEFAULT_BRANCH}" + else + ok "Repo already present at ${WORKDIR}" + fi +else + if [[ ! -d "$WORKDIR/.git" ]]; then + log "Initializing empty repository in ${WORKDIR}" + git init -q "$WORKDIR" + pushd "$WORKDIR" > /dev/null + git checkout -b "$DEFAULT_BRANCH" > /dev/null 2>&1 || true + popd > /dev/null + fi + ok "Workspace ready at ${WORKDIR}" +fi + +# ========================= +# Company Terraform skeleton +# ========================= +echo +log "Creating company Terraform skeleton (optional)" +mkdir -p "$WORKDIR/terraform"/{environments/{dev,staging,prod},modules,policies,shared} +cat > "$WORKDIR/terraform/README.md" << 'EOREADME' +# Company Terraform Project +- `environments/` contains per-env stacks. +- `modules/` reusable infra modules. +- `policies/` sentinel/policy-as-code. +- `shared/` backend, providers, etc. +EOREADME +ok "Skeleton present at $WORKDIR/terraform" + +# ========================= +# PATH persistence tip +# ========================= +if ! grep -q 'export PATH="$HOME/.local/bin:$PATH"' "$HOME/.bashrc" 2> /dev/null; then + echo "export PATH=\"\$HOME/.local/bin:\$PATH\"" >> "$HOME/.bashrc" +fi + +echo +ok "Workspace ready!" +echo " â€ĸ IaC tool: ${IAC_TOOL}" +echo " â€ĸ AWS enabled: ${ENABLE_AWS}" +echo " â€ĸ Azure enabled: ${ENABLE_AZURE}" +echo " â€ĸ GCP enabled: ${ENABLE_GCP}" +[[ -d "$WORKDIR/.git" ]] && echo " â€ĸ Repo: ${REPO_URL:-} @ ${DEFAULT_BRANCH}" +echo " â€ĸ Auth helpers: source ~/workspace/cloud-auth.sh" diff --git a/registry/nboyers/templates/cloud-dev/variables.tf b/registry/nboyers/templates/cloud-dev/variables.tf new file mode 100644 index 00000000..5fce229b --- /dev/null +++ b/registry/nboyers/templates/cloud-dev/variables.tf @@ -0,0 +1,120 @@ +# --- Host cluster (where the workspace runs) --- +variable "host_cluster_name" { + description = "EKS cluster name" + type = string + + validation { + condition = can(regex("^[0-9A-Za-z][0-9A-Za-z_-]*$", trimspace(var.host_cluster_name))) + error_message = "Cluster name must match ^[0-9A-Za-z][0-9A-Za-z_-]*$ (no leading space)." + } +} + + +# --- Admin: IaC tool & toggles --- +variable "iac_tool" { + description = "Infrastructure as Code tool" + type = string + default = "terraform" + validation { + condition = contains(["terraform", "cdk", "pulumi"], var.iac_tool) + error_message = "Must be one of: terraform, cdk, pulumi" + } +} + + +variable "enable_aws" { + type = bool + default = true +} + +variable "enable_azure" { + type = bool + default = false +} + +variable "enable_gcp" { + type = bool + default = false +} + +# --- AWS --- +variable "aws_region" { + type = string + default = "us-west-2" +} + +variable "aws_role_arn" { + type = string + default = "" # IRSA optional +} + +variable "aws_access_key_id" { + type = string + default = "" + sensitive = true +} + +variable "aws_secret_access_key" { + type = string + default = "" + sensitive = true +} + +variable "aws_session_token" { + description = "Optional STS session token" + type = string + default = "" + sensitive = true +} + +variable "repo_url" { + description = "Git repository to clone into the workspace (optional)" + type = string + default = "" +} + +variable "default_branch" { + description = "Default branch name to use (if repo is empty or for initial checkout)" + type = string + default = "main" +} + + +# --- Azure --- +variable "azure_subscription_id" { + type = string + default = "" +} + +variable "azure_tenant_id" { + type = string + default = "" + sensitive = true +} + +variable "azure_client_id" { + type = string + default = "" + sensitive = true +} + +variable "azure_client_secret" { + type = string + default = "" + sensitive = true +} + +# --- GCP --- +variable "gcp_project_id" { + type = string + default = "" +} + +variable "gcp_service_account" { + description = "Service Account JSON (paste full JSON) — leave empty if using WIF" + type = string + default = "" + sensitive = true +} + + diff --git a/registry/umair/.images/avatar.jpeg b/registry/umair/.images/avatar.jpeg index 6495306b..5afcd706 100644 Binary files a/registry/umair/.images/avatar.jpeg and b/registry/umair/.images/avatar.jpeg differ