diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
new file mode 100644
index 0000000..960cd03
--- /dev/null
+++ b/.github/workflows/ci.yaml
@@ -0,0 +1,40 @@
+name: ci
+
+on:
+ push:
+ branches:
+ - main
+
+ pull_request:
+ workflow_dispatch:
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: oven-sh/setup-bun@v1
+ with:
+ bun-version: latest
+ - name: Setup
+ run: bun install
+ - run: bun test
+ pretty:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: oven-sh/setup-bun@v1
+ with:
+ bun-version: latest
+ - name: Setup
+ run: bun install
+ - name: Format
+ run: bun fmt:ci
+ - name: typos-action
+ uses: crate-ci/typos@v1.17.2
+ - name: Lint
+ run: bun lint
diff --git a/.github/workflows/update-readme.yaml b/.github/workflows/update-readme.yaml
new file mode 100644
index 0000000..0d0e226
--- /dev/null
+++ b/.github/workflows/update-readme.yaml
@@ -0,0 +1,42 @@
+name: Update README on Tag
+
+on:
+ workflow_dispatch:
+ push:
+ tags:
+ - 'v*'
+
+jobs:
+ update-readme:
+ permissions:
+ contents: write
+ pull-requests: write
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Get the latest tag
+ id: get-latest-tag
+ run: echo "TAG=$(git describe --tags --abbrev=0 | sed 's/^v//')" >> $GITHUB_OUTPUT
+
+ - name: Run update script
+ run: ./update-version.sh
+
+ - name: Create Pull Request
+ id: create-pr
+ uses: peter-evans/create-pull-request@v5
+ with:
+ commit-message: 'chore: bump version to ${{ env.TAG }} in README.md files'
+ title: 'chore: bump version to ${{ env.TAG }} in README.md files'
+ body: 'This is an auto-generated PR to update README.md files of all modules with the new tag ${{ env.TAG }}'
+ branch: 'update-readme-branch'
+ base: 'main'
+ env:
+ TAG: ${{ steps.get-latest-tag.outputs.TAG }}
+
+ - name: Auto-approve
+ uses: hmarr/auto-approve-action@v4
+ if: github.ref == 'refs/heads/update-readme-branch'
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..6d6f5a2
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,4 @@
+.terraform*
+node_modules
+*.tfstate
+*.tfstate.lock.info
\ No newline at end of file
diff --git a/.icons/airflow.svg b/.icons/airflow.svg
new file mode 100644
index 0000000..46300fe
--- /dev/null
+++ b/.icons/airflow.svg
@@ -0,0 +1,19 @@
+
diff --git a/.icons/coder-white.svg b/.icons/coder-white.svg
new file mode 100644
index 0000000..3bb941d
--- /dev/null
+++ b/.icons/coder-white.svg
@@ -0,0 +1,8 @@
+
diff --git a/.icons/dotfiles.svg b/.icons/dotfiles.svg
new file mode 100644
index 0000000..c57ef85
--- /dev/null
+++ b/.icons/dotfiles.svg
@@ -0,0 +1,10 @@
+
diff --git a/.icons/exoscale.svg b/.icons/exoscale.svg
new file mode 100644
index 0000000..c56a615
--- /dev/null
+++ b/.icons/exoscale.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/.icons/filebrowser.svg b/.icons/filebrowser.svg
new file mode 100644
index 0000000..5e78ecc
--- /dev/null
+++ b/.icons/filebrowser.svg
@@ -0,0 +1,147 @@
+
+
\ No newline at end of file
diff --git a/.icons/git.svg b/.icons/git.svg
new file mode 100644
index 0000000..ceef116
--- /dev/null
+++ b/.icons/git.svg
@@ -0,0 +1,3 @@
+
\ No newline at end of file
diff --git a/.icons/github.svg b/.icons/github.svg
new file mode 100644
index 0000000..d5e6491
--- /dev/null
+++ b/.icons/github.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/.icons/jfrog.svg b/.icons/jfrog.svg
index 1852abf..e137700 100644
--- a/.icons/jfrog.svg
+++ b/.icons/jfrog.svg
@@ -1 +1,3 @@
-
\ No newline at end of file
+
diff --git a/.icons/node.svg b/.icons/node.svg
new file mode 100644
index 0000000..e33a588
--- /dev/null
+++ b/.icons/node.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/.icons/personalize.svg b/.icons/personalize.svg
new file mode 100644
index 0000000..76bc678
--- /dev/null
+++ b/.icons/personalize.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/.icons/slack.svg b/.icons/slack.svg
new file mode 100644
index 0000000..fb55f72
--- /dev/null
+++ b/.icons/slack.svg
@@ -0,0 +1,6 @@
+
diff --git a/.icons/vault.svg b/.icons/vault.svg
new file mode 100644
index 0000000..c90525c
--- /dev/null
+++ b/.icons/vault.svg
@@ -0,0 +1,2 @@
+
+
\ No newline at end of file
diff --git a/.images/airflow.png b/.images/airflow.png
new file mode 100644
index 0000000..bdd5798
Binary files /dev/null and b/.images/airflow.png differ
diff --git a/.images/aws-custom.png b/.images/aws-custom.png
new file mode 100644
index 0000000..e43f616
Binary files /dev/null and b/.images/aws-custom.png differ
diff --git a/.images/aws-exclude.png b/.images/aws-exclude.png
new file mode 100644
index 0000000..d4c60f2
Binary files /dev/null and b/.images/aws-exclude.png differ
diff --git a/.images/aws-regions.png b/.images/aws-regions.png
new file mode 100644
index 0000000..4a79efe
Binary files /dev/null and b/.images/aws-regions.png differ
diff --git a/.images/azure-custom.png b/.images/azure-custom.png
new file mode 100644
index 0000000..47be4fe
Binary files /dev/null and b/.images/azure-custom.png differ
diff --git a/.images/azure-default.png b/.images/azure-default.png
new file mode 100644
index 0000000..963ce09
Binary files /dev/null and b/.images/azure-default.png differ
diff --git a/.images/azure-exclude.png b/.images/azure-exclude.png
new file mode 100644
index 0000000..8a4cd01
Binary files /dev/null and b/.images/azure-exclude.png differ
diff --git a/.images/coder-login.png b/.images/coder-login.png
new file mode 100644
index 0000000..a085450
Binary files /dev/null and b/.images/coder-login.png differ
diff --git a/.images/exoscale-custom.png b/.images/exoscale-custom.png
new file mode 100644
index 0000000..3646c8c
Binary files /dev/null and b/.images/exoscale-custom.png differ
diff --git a/.images/exoscale-exclude.png b/.images/exoscale-exclude.png
new file mode 100644
index 0000000..40683c1
Binary files /dev/null and b/.images/exoscale-exclude.png differ
diff --git a/.images/exoscale-instance-custom.png b/.images/exoscale-instance-custom.png
new file mode 100644
index 0000000..04373e3
Binary files /dev/null and b/.images/exoscale-instance-custom.png differ
diff --git a/.images/exoscale-instance-exclude.png b/.images/exoscale-instance-exclude.png
new file mode 100644
index 0000000..1261b24
Binary files /dev/null and b/.images/exoscale-instance-exclude.png differ
diff --git a/.images/exoscale-instance-types.png b/.images/exoscale-instance-types.png
new file mode 100644
index 0000000..9158830
Binary files /dev/null and b/.images/exoscale-instance-types.png differ
diff --git a/.images/exoscale-zones.png b/.images/exoscale-zones.png
new file mode 100644
index 0000000..b78cd01
Binary files /dev/null and b/.images/exoscale-zones.png differ
diff --git a/.images/filebrowser.png b/.images/filebrowser.png
new file mode 100644
index 0000000..8a8bbf3
Binary files /dev/null and b/.images/filebrowser.png differ
diff --git a/.images/flyio-basic.png b/.images/flyio-basic.png
new file mode 100644
index 0000000..4cd21a2
Binary files /dev/null and b/.images/flyio-basic.png differ
diff --git a/.images/flyio-custom.png b/.images/flyio-custom.png
new file mode 100644
index 0000000..4ca25a4
Binary files /dev/null and b/.images/flyio-custom.png differ
diff --git a/.images/flyio-filtered.png b/.images/flyio-filtered.png
new file mode 100644
index 0000000..f7b0711
Binary files /dev/null and b/.images/flyio-filtered.png differ
diff --git a/.images/gcp-regions.png b/.images/gcp-regions.png
new file mode 100644
index 0000000..1e0c362
Binary files /dev/null and b/.images/gcp-regions.png differ
diff --git a/.images/git-config-params.png b/.images/git-config-params.png
new file mode 100644
index 0000000..55f24a7
Binary files /dev/null and b/.images/git-config-params.png differ
diff --git a/.images/hcp-vault-secrets-credentials.png b/.images/hcp-vault-secrets-credentials.png
new file mode 100644
index 0000000..e5e9cf2
Binary files /dev/null and b/.images/hcp-vault-secrets-credentials.png differ
diff --git a/.images/jetbrains-gateway.png b/.images/jetbrains-gateway.png
new file mode 100644
index 0000000..75807f6
Binary files /dev/null and b/.images/jetbrains-gateway.png differ
diff --git a/.images/jfrog-oauth.png b/.images/jfrog-oauth.png
new file mode 100644
index 0000000..cd897fc
Binary files /dev/null and b/.images/jfrog-oauth.png differ
diff --git a/.images/jfrog.png b/.images/jfrog.png
new file mode 100644
index 0000000..330dad2
Binary files /dev/null and b/.images/jfrog.png differ
diff --git a/.images/jupyter-notebook.png b/.images/jupyter-notebook.png
new file mode 100644
index 0000000..dad85cc
Binary files /dev/null and b/.images/jupyter-notebook.png differ
diff --git a/.images/jupyterlab.png b/.images/jupyterlab.png
new file mode 100644
index 0000000..3a0451c
Binary files /dev/null and b/.images/jupyterlab.png differ
diff --git a/.images/vault-login.png b/.images/vault-login.png
new file mode 100644
index 0000000..f2814b3
Binary files /dev/null and b/.images/vault-login.png differ
diff --git a/.images/vscode-desktop.png b/.images/vscode-desktop.png
new file mode 100644
index 0000000..e3e40a4
Binary files /dev/null and b/.images/vscode-desktop.png differ
diff --git a/.images/vscode-web.gif b/.images/vscode-web.gif
new file mode 100644
index 0000000..dcc563c
Binary files /dev/null and b/.images/vscode-web.gif differ
diff --git a/.sample/README.md b/.sample/README.md
new file mode 100644
index 0000000..e8754f1
--- /dev/null
+++ b/.sample/README.md
@@ -0,0 +1,69 @@
+---
+display_name: MODULE_NAME
+description: Describe what this module does
+icon: ../.icons/.svg
+maintainer_github: GITHUB_USERNAME
+verified: false
+tags: [helper]
+---
+
+# MODULE_NAME
+
+
+
+```tf
+module "MODULE_NAME" {
+ source = "registry.coder.com/modules/MODULE_NAME/coder"
+ version = "1.0.2"
+}
+```
+
+
+
+## Examples
+
+### Example 1
+
+Install the Dracula theme from [OpenVSX](https://open-vsx.org/):
+
+```tf
+module "MODULE_NAME" {
+ source = "registry.coder.com/modules/MODULE_NAME/coder"
+ version = "1.0.2"
+ agent_id = coder_agent.example.id
+ extensions = [
+ "dracula-theme.theme-dracula"
+ ]
+}
+```
+
+Enter the `.` into the extensions array and code-server will automatically install on start.
+
+### Example 2
+
+Configure VS Code's [settings.json](https://code.visualstudio.com/docs/getstarted/settings#_settingsjson) file:
+
+```tf
+module "MODULE_NAME" {
+ source = "registry.coder.com/modules/MODULE_NAME/coder"
+ version = "1.0.2"
+ agent_id = coder_agent.example.id
+ extensions = [ "dracula-theme.theme-dracula" ]
+ settings = {
+ "workbench.colorTheme" = "Dracula"
+ }
+}
+```
+
+### Example 3
+
+Run code-server in the background, don't fetch it from GitHub:
+
+```tf
+module "MODULE_NAME" {
+ source = "registry.coder.com/modules/MODULE_NAME/coder"
+ version = "1.0.2"
+ agent_id = coder_agent.example.id
+ offline = true
+}
+```
diff --git a/.sample/main.tf b/.sample/main.tf
new file mode 100644
index 0000000..910320e
--- /dev/null
+++ b/.sample/main.tf
@@ -0,0 +1,108 @@
+terraform {
+ required_version = ">= 1.0"
+
+ required_providers {
+ coder = {
+ source = "coder/coder"
+ version = ">= 0.17"
+ }
+ }
+}
+
+locals {
+ # A built-in icon like "/icon/code.svg" or a full URL of icon
+ icon_url = "https://raw.githubusercontent.com/coder/coder/main/site/static/icon/code.svg"
+ # a map of all possible values
+ options = {
+ "Option 1" = {
+ "name" = "Option 1",
+ "value" = "1"
+ "icon" = "/emojis/1.png"
+ }
+ "Option 2" = {
+ "name" = "Option 2",
+ "value" = "2"
+ "icon" = "/emojis/2.png"
+ }
+ }
+}
+
+# Add required variables for your modules and remove any unneeded variables
+variable "agent_id" {
+ type = string
+ description = "The ID of a Coder agent."
+}
+
+variable "log_path" {
+ type = string
+ description = "The path to log MODULE_NAME to."
+ default = "/tmp/MODULE_NAME.log"
+}
+
+variable "port" {
+ type = number
+ description = "The port to run MODULE_NAME on."
+ default = 19999
+}
+
+variable "mutable" {
+ type = bool
+ description = "Whether the parameter is mutable."
+ default = true
+}
+
+variable "order" {
+ type = number
+ description = "The order determines the position of app in the UI presentation. The lowest order is shown first and apps with equal order are sorted by name (ascending order)."
+ default = null
+}
+# Add other variables here
+
+
+resource "coder_script" "MODULE_NAME" {
+ agent_id = var.agent_id
+ display_name = "MODULE_NAME"
+ icon = local.icon_url
+ script = templatefile("${path.module}/run.sh", {
+ LOG_PATH : var.log_path,
+ })
+ run_on_start = true
+ run_on_stop = false
+}
+
+resource "coder_app" "MODULE_NAME" {
+ agent_id = var.agent_id
+ slug = "MODULE_NAME"
+ display_name = "MODULE_NAME"
+ url = "http://localhost:${var.port}"
+ icon = local.icon_url
+ subdomain = false
+ share = "owner"
+ order = var.order
+
+ # Remove if the app does not have a healthcheck endpoint
+ healthcheck {
+ url = "http://localhost:${var.port}/healthz"
+ interval = 5
+ threshold = 6
+ }
+}
+
+data "coder_parameter" "MODULE_NAME" {
+ type = "list(string)"
+ name = "MODULE_NAME"
+ display_name = "MODULE_NAME"
+ icon = local.icon_url
+ mutable = var.mutable
+ default = local.options["Option 1"]["value"]
+
+ dynamic "option" {
+ for_each = local.options
+ content {
+ icon = option.value.icon
+ name = option.value.name
+ value = option.value.value
+ }
+ }
+}
+
diff --git a/.sample/run.sh b/.sample/run.sh
new file mode 100755
index 0000000..f50f6ba
--- /dev/null
+++ b/.sample/run.sh
@@ -0,0 +1,26 @@
+#!/usr/bin/env sh
+
+# Convert templated variables to shell variables
+# shellcheck disable=SC2269
+LOG_PATH=${LOG_PATH}
+
+# shellcheck disable=SC2034
+BOLD='\033[0;1m'
+
+# shellcheck disable=SC2059
+printf "$${BOLD}Installing MODULE_NAME ...\n\n"
+
+# Add code here
+# Use varibles from the templatefile function in main.tf
+# e.g. LOG_PATH, PORT, etc.
+
+printf "🥳 Installation comlete!\n\n"
+
+printf "👷 Starting MODULE_NAME in background...\n\n"
+# Start the app in here
+# 1. Use & to run it in background
+# 2. redirct stdout and stderr to log files
+
+./app > "$${LOG_PATH}" 2>&1 &
+
+printf "check logs at %s\n\n" "$${LOG_PATH}"
diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 0000000..1c5485b
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,6 @@
+{
+ "files.exclude": {
+ "**/terraform.tfstate": true,
+ "**/.terraform": true
+ }
+}
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..557171e
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,30 @@
+# Contributing
+
+To create a new module, clone this repository and run:
+
+```shell
+./new.sh MODULE_NAME
+```
+
+## Testing a Module
+
+A suite of test-helpers exists to run `terraform apply` on modules with variables, and test script output against containers.
+
+The testing suite must be able to run docker containers with the `--network=host` flag, which typically requires running the tests on Linux as this flag does not apply to Docker Desktop for MacOS and Windows. MacOS users can work around this by using something like [colima](https://github.com/abiosoft/colima) or [Orbstack](https://orbstack.dev/) instead of Docker Desktop.
+
+Reference existing `*.test.ts` files for implementation.
+
+```shell
+# Run tests for a specific module!
+$ bun test -t ''
+```
+
+You can test a module locally by updating the source as follows
+
+```tf
+module "example" {
+ source = "git::https://github.com//.git//?ref="
+}
+```
+
+> **Note:** This is the responsibility of the module author to implement tests for their module. and test the module locally before submitting a PR.
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..f49a4e1
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,201 @@
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
\ No newline at end of file
diff --git a/README.md b/README.md
index 308a45e..4b67594 100644
--- a/README.md
+++ b/README.md
@@ -14,10 +14,11 @@ Modules extend Templates to create reusable components for your development envi
e.g.
-```hcl
+```tf
module "code-server" {
- source = "https://registry.coder.com/modules/code-server"
- agent_id = coder_agent.main.id
+ source = "registry.coder.com/modules/code-server/coder"
+ version = "1.0.2"
+ agent_id = coder_agent.main.id
}
```
@@ -32,30 +33,4 @@ Check out the [Coder Registry](https://registry.coder.com) for instructions to i
## Contributing a Module
-To quickly start contributing with a new module, clone this repository and run:
-
-```sh
-./new.sh
-```
-
-Test a module by running an instance of Coder on your local machine:
-
-```bash
-coder server --in-memory
-```
-
-Create a template and edit it to include your development module:
-
-> *Info*
-> The Docker starter template is recommended for quick-iteration!
-
-```tf
-module "testing" {
- source = "/home/user/coder/modules/my-new-module"
-}
-```
-
-Build a workspace and your module will be consumed! 🥳
-
-Open a pull-request with your module, a member of the Coder team will
-manually test it, and after-merge it will appear on the Registry.
+See [CONTRIBUTING.md](./CONTRIBUTING.md) for instructions on how to construct and publish a module to the [Coder Registry](https://registry.coder.com).
diff --git a/apache-airflow/README.md b/apache-airflow/README.md
new file mode 100644
index 0000000..194cceb
--- /dev/null
+++ b/apache-airflow/README.md
@@ -0,0 +1,23 @@
+---
+display_name: airflow
+description: A module that adds Apache Airflow in your Coder template
+icon: ../.icons/airflow.svg
+maintainer_github: coder
+partner_github: nataindata
+verified: true
+tags: [airflow, idea, web, helper]
+---
+
+# airflow
+
+A module that adds Apache Airflow in your Coder template.
+
+```tf
+module "airflow" {
+ source = "registry.coder.com/modules/apache-airflow/coder"
+ version = "1.0.13"
+ agent_id = coder_agent.main.id
+}
+```
+
+
diff --git a/apache-airflow/main.tf b/apache-airflow/main.tf
new file mode 100644
index 0000000..91b6682
--- /dev/null
+++ b/apache-airflow/main.tf
@@ -0,0 +1,65 @@
+terraform {
+ required_version = ">= 1.0"
+
+ required_providers {
+ coder = {
+ source = "coder/coder"
+ version = ">= 0.17"
+ }
+ }
+}
+
+# Add required variables for your modules and remove any unneeded variables
+variable "agent_id" {
+ type = string
+ description = "The ID of a Coder agent."
+}
+
+variable "log_path" {
+ type = string
+ description = "The path to log airflow to."
+ default = "/tmp/airflow.log"
+}
+
+variable "port" {
+ type = number
+ description = "The port to run airflow on."
+ default = 8080
+}
+
+variable "share" {
+ type = string
+ default = "owner"
+ validation {
+ condition = var.share == "owner" || var.share == "authenticated" || var.share == "public"
+ error_message = "Incorrect value. Please set either 'owner', 'authenticated', or 'public'."
+ }
+}
+
+variable "order" {
+ type = number
+ description = "The order determines the position of app in the UI presentation. The lowest order is shown first and apps with equal order are sorted by name (ascending order)."
+ default = null
+}
+
+resource "coder_script" "airflow" {
+ agent_id = var.agent_id
+ display_name = "airflow"
+ icon = "/icon/apache-guacamole.svg"
+ script = templatefile("${path.module}/run.sh", {
+ LOG_PATH : var.log_path,
+ PORT : var.port
+ })
+ run_on_start = true
+}
+
+resource "coder_app" "airflow" {
+ agent_id = var.agent_id
+ slug = "airflow"
+ display_name = "airflow"
+ url = "http://localhost:${var.port}"
+ icon = "/icon/apache-guacamole.svg"
+ subdomain = true
+ share = var.share
+ order = var.order
+}
diff --git a/apache-airflow/run.sh b/apache-airflow/run.sh
new file mode 100644
index 0000000..d881260
--- /dev/null
+++ b/apache-airflow/run.sh
@@ -0,0 +1,19 @@
+#!/usr/bin/env sh
+
+BOLD='\033[0;1m'
+
+PATH=$PATH:~/.local/bin
+pip install --upgrade apache-airflow
+
+filename=~/airflow/airflow.db
+if ! [ -f $filename ] || ! [ -s $filename ]; then
+ airflow db init
+fi
+
+export AIRFLOW__CORE__LOAD_EXAMPLES=false
+
+airflow webserver > ${LOG_PATH} 2>&1 &
+
+airflow scheduler >> /tmp/airflow_scheduler.log 2>&1 &
+
+airflow users create -u admin -p admin -r Admin -e admin@admin.com -f Coder -l User
diff --git a/aws-region/README.md b/aws-region/README.md
index ecd27ac..4d363c3 100644
--- a/aws-region/README.md
+++ b/aws-region/README.md
@@ -4,7 +4,7 @@ description: A parameter with human region names and icons
icon: ../.icons/aws.svg
maintainer_github: coder
verified: true
-tags: [helper, parameter]
+tags: [helper, parameter, regions, aws]
---
# AWS Region
@@ -12,54 +12,71 @@ tags: [helper, parameter]
A parameter with all AWS regions. This allows developers to select
the region closest to them.
-## Examples
-
-### Default Region
-
Customize the preselected parameter value:
-```hcl
+```tf
module "aws-region" {
- source = "https://registry.coder.com/modules/aws-region"
- default = "us-east-1"
+ source = "registry.coder.com/modules/aws-region/coder"
+ version = "1.0.12"
+ default = "us-east-1"
}
provider "aws" {
- region = module.aws_region.value
+ region = module.aws_region.value
}
```
-### Customize Regions
+
-Change the display name and icon for a region:
+## Examples
-```hcl
+### Customize regions
+
+Change the display name and icon for a region using the corresponding maps:
+
+```tf
module "aws-region" {
- source = "https://registry.coder.com/modules/aws-region"
- custom_names = {
- "fra": "Awesome Germany!"
- }
- custom_icons = {
- "fra": "/icons/smiley.svg"
- }
+ source = "registry.coder.com/modules/aws-region/coder"
+ version = "1.0.12"
+ default = "ap-south-1"
+
+ custom_names = {
+ "ap-south-1" : "Awesome Mumbai!"
+ }
+
+ custom_icons = {
+ "ap-south-1" : "/emojis/1f33a.png"
+ }
}
provider "aws" {
- region = module.aws_region.value
+ region = module.aws_region.value
}
```
-### Exclude Regions
+
+
+### Exclude regions
-Hide the `fra` region:
+Hide the Asia Pacific regions Seoul and Osaka:
-```hcl
+```tf
module "aws-region" {
- source = "https://registry.coder.com/modules/aws-region"
- exclude = [ "fra" ]
+ source = "registry.coder.com/modules/aws-region/coder"
+ version = "1.0.12"
+ exclude = ["ap-northeast-2", "ap-northeast-3"]
}
provider "aws" {
- region = module.aws_region.value
+ region = module.aws_region.value
}
```
+
+
+
+## Related templates
+
+For a complete AWS EC2 template, see the following examples in the [Coder Registry](https://registry.coder.com/).
+
+- [AWS EC2 (Linux)](https://registry.coder.com/templates/aws-linux)
+- [AWS EC2 (Windows)](https://registry.coder.com/templates/aws-windows)
diff --git a/aws-region/main.test.ts b/aws-region/main.test.ts
new file mode 100644
index 0000000..0693e65
--- /dev/null
+++ b/aws-region/main.test.ts
@@ -0,0 +1,34 @@
+import { describe, expect, it } from "bun:test";
+import {
+ executeScriptInContainer,
+ runTerraformApply,
+ runTerraformInit,
+ testRequiredVariables,
+} from "../test";
+
+describe("aws-region", async () => {
+ await runTerraformInit(import.meta.dir);
+
+ testRequiredVariables(import.meta.dir, {});
+
+ it("default output", async () => {
+ const state = await runTerraformApply(import.meta.dir, {});
+ expect(state.outputs.value.value).toBe("");
+ });
+
+ it("customized default", async () => {
+ const state = await runTerraformApply(import.meta.dir, {
+ default: "us-west-2",
+ });
+ expect(state.outputs.value.value).toBe("us-west-2");
+ });
+
+ it("set custom order for coder_parameter", async () => {
+ const order = 99;
+ const state = await runTerraformApply(import.meta.dir, {
+ coder_parameter_order: order.toString(),
+ });
+ expect(state.resources).toHaveLength(1);
+ expect(state.resources[0].instances[0].attributes.order).toBe(order);
+ });
+});
diff --git a/aws-region/main.tf b/aws-region/main.tf
index 11f43b5..12a01fe 100644
--- a/aws-region/main.tf
+++ b/aws-region/main.tf
@@ -10,45 +10,51 @@ terraform {
}
variable "display_name" {
- default = "AWS Region"
- description = "The display name of the parameter."
- type = string
+ default = "AWS Region"
+ description = "The display name of the parameter."
+ type = string
}
variable "description" {
- default = "The region to deploy workspace infrastructure."
- description = "The description of the parameter."
- type = string
+ default = "The region to deploy workspace infrastructure."
+ description = "The description of the parameter."
+ type = string
}
variable "default" {
- default = "us-east-1"
- description = "The default region to use if no region is specified."
- type = string
+ default = ""
+ description = "The default region to use if no region is specified."
+ type = string
}
variable "mutable" {
- default = false
- description = "Whether the parameter can be changed after creation."
- type = bool
+ default = false
+ description = "Whether the parameter can be changed after creation."
+ type = bool
}
variable "custom_names" {
- default = {}
- description = "A map of custom display names for region IDs."
- type = map(string)
+ default = {}
+ description = "A map of custom display names for region IDs."
+ type = map(string)
}
variable "custom_icons" {
- default = {}
- description = "A map of custom icons for region IDs."
- type = map(string)
+ default = {}
+ description = "A map of custom icons for region IDs."
+ type = map(string)
}
variable "exclude" {
- default = []
- description = "A list of region IDs to exclude."
- type = list(string)
+ default = []
+ description = "A list of region IDs to exclude."
+ type = list(string)
+}
+
+variable "coder_parameter_order" {
+ type = number
+ description = "The order determines the position of a template parameter in the UI/CLI presentation. The lowest order is shown first and parameters with equal order are sorted by name (ascending order)."
+ default = null
}
locals {
@@ -56,93 +62,138 @@ locals {
# frequently and including the `aws_regions` data source requires
# the provider, which requires a region.
regions = {
+ "af-south-1" = {
+ name = "Africa (Cape Town)"
+ icon = "/emojis/1f1ff-1f1e6.png"
+ }
+ "ap-east-1" = {
+ name = "Asia Pacific (Hong Kong)"
+ icon = "/emojis/1f1ed-1f1f0.png"
+ }
"ap-northeast-1" = {
- name = "Asia Pacific (Tokyo)"
- icon = "/emojis/1f1ef-1f1f5.png"
- }
- "ap-northeast-2" = {
- name = "Asia Pacific (Seoul)"
- icon = "/emojis/1f1f0-1f1f7.png"
- }
- "ap-northeast-3" = {
- name = "Asia Pacific (Osaka)"
- icon = "/emojis/1f1ef-1f1f5.png"
- }
- "ap-south-1" = {
- name = "Asia Pacific (Mumbai)"
- icon = "/emojis/1f1ee-1f1f3.png"
- }
- "ap-southeast-1" = {
- name = "Asia Pacific (Singapore)"
- icon = "/emojis/1f1f8-1f1ec.png"
- }
- "ap-southeast-2" = {
- name = "Asia Pacific (Sydney)"
- icon = "/emojis/1f1e6-1f1fa.png"
- }
- "ca-central-1" = {
- name = "Canada (Central)"
- icon = "/emojis/1f1e8-1f1e6.png"
- }
- "eu-central-1" = {
- name = "EU (Frankfurt)"
- icon = "/emojis/1f1ea-1f1fa.png"
- }
- "eu-north-1" = {
- name = "EU (Stockholm)"
- icon = "/emojis/1f1ea-1f1fa.png"
- }
- "eu-west-1" = {
- name = "EU (Ireland)"
- icon = "/emojis/1f1ea-1f1fa.png"
- }
- "eu-west-2" = {
- name = "EU (London)"
- icon = "/emojis/1f1ea-1f1fa.png"
- }
- "eu-west-3" = {
- name = "EU (Paris)"
- icon = "/emojis/1f1ea-1f1fa.png"
- }
- "sa-east-1" = {
- name = "South America (São Paulo)"
- icon = "/emojis/1f1e7-1f1f7.png"
- }
- "us-east-1" = {
- name = "US East (N. Virginia)"
- icon = "/emojis/1f1fa-1f1f8.png"
- }
- "us-east-2" = {
- name = "US East (Ohio)"
- icon = "/emojis/1f1fa-1f1f8.png"
- }
- "us-west-1" = {
- name = "US West (N. California)"
- icon = "/emojis/1f1fa-1f1f8.png"
- }
- "us-west-2" = {
- name = "US West (Oregon)"
- icon = "/emojis/1f1fa-1f1f8.png"
- }
+ name = "Asia Pacific (Tokyo)"
+ icon = "/emojis/1f1ef-1f1f5.png"
+ }
+ "ap-northeast-2" = {
+ name = "Asia Pacific (Seoul)"
+ icon = "/emojis/1f1f0-1f1f7.png"
+ }
+ "ap-northeast-3" = {
+ name = "Asia Pacific (Osaka)"
+ icon = "/emojis/1f1ef-1f1f5.png"
+ }
+ "ap-south-1" = {
+ name = "Asia Pacific (Mumbai)"
+ icon = "/emojis/1f1ee-1f1f3.png"
+ }
+ "ap-south-2" = {
+ name = "Asia Pacific (Hyderabad)"
+ icon = "/emojis/1f1ee-1f1f3.png"
+ }
+ "ap-southeast-1" = {
+ name = "Asia Pacific (Singapore)"
+ icon = "/emojis/1f1f8-1f1ec.png"
+ }
+ "ap-southeast-2" = {
+ name = "Asia Pacific (Sydney)"
+ icon = "/emojis/1f1e6-1f1fa.png"
+ }
+ "ap-southeast-3" = {
+ name = "Asia Pacific (Jakarta)"
+ icon = "/emojis/1f1ee-1f1e9.png"
+ }
+ "ap-southeast-4" = {
+ name = "Asia Pacific (Melbourne)"
+ icon = "/emojis/1f1e6-1f1fa.png"
+ }
+ "ca-central-1" = {
+ name = "Canada (Central)"
+ icon = "/emojis/1f1e8-1f1e6.png"
+ }
+ "ca-west-1" = {
+ name = "Canada West (Calgary)"
+ icon = "/emojis/1f1e8-1f1e6.png"
+ }
+ "eu-central-1" = {
+ name = "EU (Frankfurt)"
+ icon = "/emojis/1f1ea-1f1fa.png"
+ }
+ "eu-central-2" = {
+ name = "Europe (Zurich)"
+ icon = "/emojis/1f1ea-1f1fa.png"
+ }
+ "eu-north-1" = {
+ name = "EU (Stockholm)"
+ icon = "/emojis/1f1ea-1f1fa.png"
+ }
+ "eu-south-1" = {
+ name = "Europe (Milan)"
+ icon = "/emojis/1f1ea-1f1fa.png"
+ }
+ "eu-south-2" = {
+ name = "Europe (Spain)"
+ icon = "/emojis/1f1ea-1f1fa.png"
+ }
+ "eu-west-1" = {
+ name = "EU (Ireland)"
+ icon = "/emojis/1f1ea-1f1fa.png"
+ }
+ "eu-west-2" = {
+ name = "EU (London)"
+ icon = "/emojis/1f1ea-1f1fa.png"
+ }
+ "eu-west-3" = {
+ name = "EU (Paris)"
+ icon = "/emojis/1f1ea-1f1fa.png"
+ }
+ "il-central-1" = {
+ name = "Israel (Tel Aviv)"
+ icon = "/emojis/1f1ee-1f1f1.png"
+ }
+ "me-south-1" = {
+ name = "Middle East (Bahrain)"
+ icon = "/emojis/1f1e7-1f1ed.png"
+ }
+ "sa-east-1" = {
+ name = "South America (São Paulo)"
+ icon = "/emojis/1f1e7-1f1f7.png"
+ }
+ "us-east-1" = {
+ name = "US East (N. Virginia)"
+ icon = "/emojis/1f1fa-1f1f8.png"
+ }
+ "us-east-2" = {
+ name = "US East (Ohio)"
+ icon = "/emojis/1f1fa-1f1f8.png"
+ }
+ "us-west-1" = {
+ name = "US West (N. California)"
+ icon = "/emojis/1f1fa-1f1f8.png"
+ }
+ "us-west-2" = {
+ name = "US West (Oregon)"
+ icon = "/emojis/1f1fa-1f1f8.png"
+ }
}
}
data "coder_parameter" "region" {
- name = "aws_region"
- display_name = var.display_name
- description = var.description
- default = var.default
- mutable = var.mutable
- dynamic "option" {
- for_each = { for k, v in local.regions : k => v if !(contains(var.exclude, k)) }
- content {
- name = try(var.custom_names[option.key], option.value.name)
- icon = try(var.custom_icons[option.key], option.value.icon)
- value = option.key
- }
+ name = "aws_region"
+ display_name = var.display_name
+ description = var.description
+ default = var.default == "" ? null : var.default
+ order = var.coder_parameter_order
+ mutable = var.mutable
+ dynamic "option" {
+ for_each = { for k, v in local.regions : k => v if !(contains(var.exclude, k)) }
+ content {
+ name = try(var.custom_names[option.key], option.value.name)
+ icon = try(var.custom_icons[option.key], option.value.icon)
+ value = option.key
}
+ }
}
output "value" {
- value = data.coder_parameter.region.value
-}
+ value = data.coder_parameter.region.value
+}
\ No newline at end of file
diff --git a/azure-region/README.md b/azure-region/README.md
index f21c0b7..cd0efd3 100644
--- a/azure-region/README.md
+++ b/azure-region/README.md
@@ -4,60 +4,78 @@ description: A parameter with human region names and icons
icon: ../.icons/azure.svg
maintainer_github: coder
verified: true
-tags: [helper, parameter, azure]
+tags: [helper, parameter, azure, regions]
---
# Azure Region
-This module adds a parameter with all Azure regions. This allows developers to select the region closest to them.
+This module adds a parameter with all Azure regions, allowing developers to select the region closest to them.
-## Examples
-
-### Default region
-
-```hcl
+```tf
module "azure_region" {
- source = "https://registry.coder.com/modules/azure-region"
- default = "eastus"
+ source = "registry.coder.com/modules/azure-region/coder"
+ version = "1.0.12"
+ default = "eastus"
}
-provider "azure" {
- region = module.azure_region.value
- ...
+resource "azurem_resource_group" "example" {
+ location = module.azure_region.value
}
```
+
+
+## Examples
+
### Customize existing regions
-Change the display name for a region:
+Change the display name and icon for a region using the corresponding maps:
-```hcl
+```tf
module "azure-region" {
- source = "https://registry.coder.com/modules/azure-region"
- custom_names = {
- "eastus": "Eastern United States!"
- }
- custom_icons = {
- "eastus": "/icons/smiley.svg"
- }
+ source = "registry.coder.com/modules/azure-region/coder"
+ version = "1.0.12"
+ custom_names = {
+ "australia" : "Go Australia!"
+ }
+ custom_icons = {
+ "australia" : "/icons/smiley.svg"
+ }
}
-provider "aws" {
- region = module.aws_region.value
+resource "azurerm_resource_group" "example" {
+ location = module.azure_region.value
}
```
+
+
### Exclude Regions
-Hide the `westus2` region:
+Hide all regions in Australia except australiacentral:
-```hcl
-module "aws-region" {
- source = "https://registry.coder.com/modules/aws-region"
- exclude = [ "westus2" ]
+```tf
+module "azure-region" {
+ source = "registry.coder.com/modules/azure-region/coder"
+ version = "1.0.12"
+ exclude = [
+ "australia",
+ "australiacentral2",
+ "australiaeast",
+ "australiasoutheast"
+ ]
}
-provider "aws" {
- region = module.aws_region.value
+resource "azurerm_resource_group" "example" {
+ location = module.azure_region.value
}
-```
\ No newline at end of file
+```
+
+
+
+## Related templates
+
+For a complete Azure template, see the following examples in the [Coder Registry](https://registry.coder.com/).
+
+- [Azure VM (Linux)](https://registry.coder.com/templates/azure-linux)
+- [Azure VM (Windows)](https://registry.coder.com/templates/azure-windows)
diff --git a/azure-region/main.test.ts b/azure-region/main.test.ts
new file mode 100644
index 0000000..bebc0c9
--- /dev/null
+++ b/azure-region/main.test.ts
@@ -0,0 +1,34 @@
+import { describe, expect, it } from "bun:test";
+import {
+ executeScriptInContainer,
+ runTerraformApply,
+ runTerraformInit,
+ testRequiredVariables,
+} from "../test";
+
+describe("azure-region", async () => {
+ await runTerraformInit(import.meta.dir);
+
+ testRequiredVariables(import.meta.dir, {});
+
+ it("default output", async () => {
+ const state = await runTerraformApply(import.meta.dir, {});
+ expect(state.outputs.value.value).toBe("");
+ });
+
+ it("customized default", async () => {
+ const state = await runTerraformApply(import.meta.dir, {
+ default: "westus",
+ });
+ expect(state.outputs.value.value).toBe("westus");
+ });
+
+ it("set custom order for coder_parameter", async () => {
+ const order = 99;
+ const state = await runTerraformApply(import.meta.dir, {
+ coder_parameter_order: order.toString(),
+ });
+ expect(state.resources).toHaveLength(1);
+ expect(state.resources[0].instances[0].attributes.order).toBe(order);
+ });
+});
diff --git a/azure-region/main.tf b/azure-region/main.tf
index 434ada4..3d1c2f1 100644
--- a/azure-region/main.tf
+++ b/azure-region/main.tf
@@ -10,171 +10,324 @@ terraform {
}
variable "display_name" {
- default = "Azure Region"
- description = "The display name of the Coder parameter."
- type = string
+ default = "Azure Region"
+ description = "The display name of the Coder parameter."
+ type = string
}
variable "description" {
- default = "The region where your workspace will live."
+ default = "The region where your workspace will live."
description = "Description of the Coder parameter."
}
variable "default" {
- default = "eastus"
- description = "The default region to use if no region is specified."
- type = string
+ default = ""
+ description = "The default region to use if no region is specified."
+ type = string
}
variable "mutable" {
- default = false
- description = "Whether the parameter can be changed after creation."
- type = bool
+ default = false
+ description = "Whether the parameter can be changed after creation."
+ type = bool
}
variable "custom_names" {
- default = {}
- description = "A map of custom display names for region IDs."
- type = map(string)
+ default = {}
+ description = "A map of custom display names for region IDs."
+ type = map(string)
}
variable "custom_icons" {
- default = {}
- description = "A map of custom icons for region IDs."
- type = map(string)
+ default = {}
+ description = "A map of custom icons for region IDs."
+ type = map(string)
}
variable "exclude" {
- default = []
- description = "A list of region IDs to exclude."
- type = list(string)
+ default = []
+ description = "A list of region IDs to exclude."
+ type = list(string)
+}
+
+variable "coder_parameter_order" {
+ type = number
+ description = "The order determines the position of a template parameter in the UI/CLI presentation. The lowest order is shown first and parameters with equal order are sorted by name (ascending order)."
+ default = null
}
locals {
- all_regions = {
- "eastus" = {
- name = "US (Virginia)"
- icon = "/emojis/1f1fa-1f1f8.png"
- }
- "eastus2" = {
- name = "US (Virginia) 2"
- icon = "/emojis/1f1fa-1f1f8.png"
- }
- "southcentralus" = {
- name = "US (Texas)"
- icon = "/emojis/1f1fa-1f1f8.png"
- }
- "westus2" = {
- name = "US (Washington)"
- icon = "/emojis/1f1fa-1f1f8.png"
- }
- "westus3" = {
- name = "US (Arizona)"
- icon = "/emojis/1f1fa-1f1f8.png"
- }
- "centralus" = {
- name = "US (Iowa)"
- icon = "/emojis/1f1fa-1f1f8.png"
- }
- "canadacentral" = {
- name = "Canada (Toronto)"
- icon = "/emojis/1f1e8-1f1e6.png"
- }
- "brazilsouth" = {
- name = "Brazil (Sao Paulo)"
- icon = "/emojis/1f1e7-1f1f7.png"
- }
- "eastasia" = {
- name = "East Asia (Hong Kong)"
- icon = "/emojis/1f1f0-1f1f7.png"
- }
- "southeastasia" = {
- name = "Southeast Asia (Singapore)"
- icon = "/emojis/1f1f0-1f1f7.png"
- }
- "australiaeast" = {
- name = "Australia (New South Wales)"
- icon = "/emojis/1f1e6-1f1fa.png"
- }
- "chinanorth3" = {
- name = "China (Hebei)"
- icon = "/emojis/1f1e8-1f1f3.png"
- }
- "centralindia" = {
- name = "India (Pune)"
- icon = "/emojis/1f1ee-1f1f3.png"
- }
- "japaneast" = {
- name = "Japan (Tokyo)"
- icon = "/emojis/1f1ef-1f1f5.png"
- }
- "koreacentral" = {
- name = "Korea (Seoul)"
- icon = "/emojis/1f1f0-1f1f7.png"
- }
- "northeurope" = {
- name = "Europe (Ireland)"
- icon = "/emojis/1f1ea-1f1fa.png"
- }
- "westeurope" = {
- name = "Europe (Netherlands)"
- icon = "/emojis/1f1ea-1f1fa.png"
- }
- "francecentral" = {
- name = "France (Paris)"
- icon = "/emojis/1f1eb-1f1f7.png"
- }
- "germanywestcentral" = {
- name = "Germany (Frankfurt)"
- icon = "/emojis/1f1e9-1f1ea.png"
- }
- "norwayeast" = {
- name = "Norway (Oslo)"
- icon = "/emojis/1f1f3-1f1f4.png"
- }
- "swedencentral" = {
- name = "Sweden (Gävle)"
- icon = "/emojis/1f1f8-1f1ea.png"
- }
- "switzerlandnorth" = {
- name = "Switzerland (Zurich)"
- icon = "/emojis/1f1e8-1f1ed.png"
- }
- "qatarcentral" = {
- name = "Qatar (Doha)"
- icon = "/emojis/1f1f6-1f1e6.png"
- }
- "uaenorth" = {
- name = "UAE (Dubai)"
- icon = "/emojis/1f1e6-1f1ea.png"
- }
- "southafricanorth" = {
- name = "South Africa (Johannesburg)"
- icon = "/emojis/1f1ff-1f1e6.png"
- }
- "uksouth" = {
- name = "UK (London)"
- icon = "/emojis/1f1ec-1f1e7.png"
- }
+ # Note: Options are limited to 64 regions, some redundant regions have been removed.
+ all_regions = {
+ "australia" = {
+ name = "Australia"
+ icon = "/emojis/1f1e6-1f1fa.png"
+ }
+ "australiacentral" = {
+ name = "Australia Central"
+ icon = "/emojis/1f1e6-1f1fa.png"
+ }
+ "australiacentral2" = {
+ name = "Australia Central 2"
+ icon = "/emojis/1f1e6-1f1fa.png"
+ }
+ "australiaeast" = {
+ name = "Australia (New South Wales)"
+ icon = "/emojis/1f1e6-1f1fa.png"
+ }
+ "australiasoutheast" = {
+ name = "Australia Southeast"
+ icon = "/emojis/1f1e6-1f1fa.png"
+ }
+ "brazil" = {
+ name = "Brazil"
+ icon = "/emojis/1f1e7-1f1f7.png"
+ }
+ "brazilsouth" = {
+ name = "Brazil (Sao Paulo)"
+ icon = "/emojis/1f1e7-1f1f7.png"
+ }
+ "brazilsoutheast" = {
+ name = "Brazil Southeast"
+ icon = "/emojis/1f1e7-1f1f7.png"
+ }
+ "brazilus" = {
+ name = "Brazil US"
+ icon = "/emojis/1f1e7-1f1f7.png"
+ }
+ "canada" = {
+ name = "Canada"
+ icon = "/emojis/1f1e8-1f1e6.png"
+ }
+ "canadacentral" = {
+ name = "Canada (Toronto)"
+ icon = "/emojis/1f1e8-1f1e6.png"
+ }
+ "canadaeast" = {
+ name = "Canada East"
+ icon = "/emojis/1f1e8-1f1e6.png"
+ }
+ "centralindia" = {
+ name = "India (Pune)"
+ icon = "/emojis/1f1ee-1f1f3.png"
+ }
+ "centralus" = {
+ name = "US (Iowa)"
+ icon = "/emojis/1f1fa-1f1f8.png"
+ }
+ "eastasia" = {
+ name = "East Asia (Hong Kong)"
+ icon = "/emojis/1f1f0-1f1f7.png"
+ }
+ "eastus" = {
+ name = "US (Virginia)"
+ icon = "/emojis/1f1fa-1f1f8.png"
+ }
+ "eastus2" = {
+ name = "US (Virginia) 2"
+ icon = "/emojis/1f1fa-1f1f8.png"
+ }
+ "europe" = {
+ name = "Europe"
+ icon = "/emojis/1f30d.png"
+ }
+ "france" = {
+ name = "France"
+ icon = "/emojis/1f1eb-1f1f7.png"
+ }
+ "francecentral" = {
+ name = "France (Paris)"
+ icon = "/emojis/1f1eb-1f1f7.png"
+ }
+ "francesouth" = {
+ name = "France South"
+ icon = "/emojis/1f1eb-1f1f7.png"
+ }
+ "germany" = {
+ name = "Germany"
+ icon = "/emojis/1f1e9-1f1ea.png"
+ }
+ "germanynorth" = {
+ name = "Germany North"
+ icon = "/emojis/1f1e9-1f1ea.png"
+ }
+ "germanywestcentral" = {
+ name = "Germany (Frankfurt)"
+ icon = "/emojis/1f1e9-1f1ea.png"
+ }
+ "india" = {
+ name = "India"
+ icon = "/emojis/1f1ee-1f1f3.png"
+ }
+ "japan" = {
+ name = "Japan"
+ icon = "/emojis/1f1ef-1f1f5.png"
+ }
+ "japaneast" = {
+ name = "Japan (Tokyo)"
+ icon = "/emojis/1f1ef-1f1f5.png"
+ }
+ "japanwest" = {
+ name = "Japan West"
+ icon = "/emojis/1f1ef-1f1f5.png"
+ }
+ "jioindiacentral" = {
+ name = "Jio India Central"
+ icon = "/emojis/1f1ee-1f1f3.png"
+ }
+ "jioindiawest" = {
+ name = "Jio India West"
+ icon = "/emojis/1f1ee-1f1f3.png"
+ }
+ "koreacentral" = {
+ name = "Korea (Seoul)"
+ icon = "/emojis/1f1f0-1f1f7.png"
+ }
+ "koreasouth" = {
+ name = "Korea South"
+ icon = "/emojis/1f1f0-1f1f7.png"
}
+ "northcentralus" = {
+ name = "North Central US"
+ icon = "/emojis/1f1fa-1f1f8.png"
+ }
+ "northeurope" = {
+ name = "Europe (Ireland)"
+ icon = "/emojis/1f1ea-1f1fa.png"
+ }
+ "norway" = {
+ name = "Norway"
+ icon = "/emojis/1f1f3-1f1f4.png"
+ }
+ "norwayeast" = {
+ name = "Norway (Oslo)"
+ icon = "/emojis/1f1f3-1f1f4.png"
+ }
+ "norwaywest" = {
+ name = "Norway West"
+ icon = "/emojis/1f1f3-1f1f4.png"
+ }
+ "qatarcentral" = {
+ name = "Qatar (Doha)"
+ icon = "/emojis/1f1f6-1f1e6.png"
+ }
+ "singapore" = {
+ name = "Singapore"
+ icon = "/emojis/1f1f8-1f1ec.png"
+ }
+ "southafrica" = {
+ name = "South Africa"
+ icon = "/emojis/1f1ff-1f1e6.png"
+ }
+ "southafricanorth" = {
+ name = "South Africa (Johannesburg)"
+ icon = "/emojis/1f1ff-1f1e6.png"
+ }
+ "southafricawest" = {
+ name = "South Africa West"
+ icon = "/emojis/1f1ff-1f1e6.png"
+ }
+ "southcentralus" = {
+ name = "US (Texas)"
+ icon = "/emojis/1f1fa-1f1f8.png"
+ }
+ "southeastasia" = {
+ name = "Southeast Asia (Singapore)"
+ icon = "/emojis/1f1f0-1f1f7.png"
+ }
+ "southindia" = {
+ name = "South India"
+ icon = "/emojis/1f1ee-1f1f3.png"
+ }
+ "swedencentral" = {
+ name = "Sweden (Gävle)"
+ icon = "/emojis/1f1f8-1f1ea.png"
+ }
+ "switzerland" = {
+ name = "Switzerland"
+ icon = "/emojis/1f1e8-1f1ed.png"
+ }
+ "switzerlandnorth" = {
+ name = "Switzerland (Zurich)"
+ icon = "/emojis/1f1e8-1f1ed.png"
+ }
+ "switzerlandwest" = {
+ name = "Switzerland West"
+ icon = "/emojis/1f1e8-1f1ed.png"
+ }
+ "uae" = {
+ name = "United Arab Emirates"
+ icon = "/emojis/1f1e6-1f1ea.png"
+ }
+ "uaecentral" = {
+ name = "UAE Central"
+ icon = "/emojis/1f1e6-1f1ea.png"
+ }
+ "uaenorth" = {
+ name = "UAE (Dubai)"
+ icon = "/emojis/1f1e6-1f1ea.png"
+ }
+ "uk" = {
+ name = "United Kingdom"
+ icon = "/emojis/1f1ec-1f1e7.png"
+ }
+ "uksouth" = {
+ name = "UK (London)"
+ icon = "/emojis/1f1ec-1f1e7.png"
+ }
+ "ukwest" = {
+ name = "UK West"
+ icon = "/emojis/1f1ec-1f1e7.png"
+ }
+ "unitedstates" = {
+ name = "United States"
+ icon = "/emojis/1f1fa-1f1f8.png"
+ }
+ "westcentralus" = {
+ name = "West Central US"
+ icon = "/emojis/1f1fa-1f1f8.png"
+ }
+ "westeurope" = {
+ name = "Europe (Netherlands)"
+ icon = "/emojis/1f1ea-1f1fa.png"
+ }
+ "westindia" = {
+ name = "West India"
+ icon = "/emojis/1f1ee-1f1f3.png"
+ }
+ "westus" = {
+ name = "West US"
+ icon = "/emojis/1f1fa-1f1f8.png"
+ }
+ "westus2" = {
+ name = "US (Washington)"
+ icon = "/emojis/1f1fa-1f1f8.png"
+ }
+ "westus3" = {
+ name = "US (Arizona)"
+ icon = "/emojis/1f1fa-1f1f8.png"
+ }
+ }
}
data "coder_parameter" "region" {
- name = "azure_region"
- display_name = var.display_name
- description = var.description
- default = var.default
- mutable = var.mutable
- dynamic "option" {
- for_each = { for k, v in local.all_regions : k => v if !(contains(var.exclude, k)) }
- content {
- name = try(var.custom_names[option.key], option.value.name)
- icon = try(var.custom_icons[option.key], option.value.icon)
- value = option.key
- }
+ name = "azure_region"
+ display_name = var.display_name
+ description = var.description
+ default = var.default == "" ? null : var.default
+ order = var.coder_parameter_order
+ mutable = var.mutable
+ icon = "/icon/azure.png"
+ dynamic "option" {
+ for_each = { for k, v in local.all_regions : k => v if !(contains(var.exclude, k)) }
+ content {
+ name = try(var.custom_names[option.key], option.value.name)
+ icon = try(var.custom_icons[option.key], option.value.icon)
+ value = option.key
}
+ }
}
output "value" {
- value = data.coder_parameter.region.value
+ value = data.coder_parameter.region.value
}
diff --git a/bun.lockb b/bun.lockb
new file mode 100755
index 0000000..d3e2214
Binary files /dev/null and b/bun.lockb differ
diff --git a/bunfig.toml b/bunfig.toml
new file mode 100644
index 0000000..7bb903b
--- /dev/null
+++ b/bunfig.toml
@@ -0,0 +1,2 @@
+[test]
+preload = ["./setup.ts"]
\ No newline at end of file
diff --git a/code-server/README.md b/code-server/README.md
index 6f068ed..463fb22 100644
--- a/code-server/README.md
+++ b/code-server/README.md
@@ -4,17 +4,18 @@ description: VS Code in the browser
icon: ../.icons/code.svg
maintainer_github: coder
verified: true
-tags: [helper, ide]
+tags: [helper, ide, web]
---
# code-server
Automatically install [code-server](https://github.com/coder/code-server) in a workspace, create an app to access it via the dashboard, install extensions, and pre-configure editor settings.
-```hcl
+```tf
module "code-server" {
- source = "https://registry.coder.com/modules/code-server"
- agent_id = coder_agent.example.id
+ source = "registry.coder.com/modules/code-server/coder"
+ version = "1.0.14"
+ agent_id = coder_agent.example.id
}
```
@@ -22,17 +23,29 @@ module "code-server" {
## Examples
+### Pin Versions
+
+```tf
+module "code-server" {
+ source = "registry.coder.com/modules/code-server/coder"
+ version = "1.0.14"
+ agent_id = coder_agent.example.id
+ install_version = "4.8.3"
+}
+```
+
### Pre-install Extensions
Install the Dracula theme from [OpenVSX](https://open-vsx.org/):
-```hcl
+```tf
module "code-server" {
- source = "https://registry.coder.com/modules/code-server"
- agent_id = coder_agent.example.id
- extensions = [
- "dracula-theme.theme-dracula"
- ]
+ source = "registry.coder.com/modules/code-server/coder"
+ version = "1.0.14"
+ agent_id = coder_agent.example.id
+ extensions = [
+ "dracula-theme.theme-dracula"
+ ]
}
```
@@ -42,25 +55,54 @@ Enter the `.` into the extensions array and code-server will autom
Configure VS Code's [settings.json](https://code.visualstudio.com/docs/getstarted/settings#_settingsjson) file:
-```hcl
-module "settings" {
- source = "https://registry.coder.com/modules/code-server"
- agent_id = coder_agent.example.id
- extensions = [ "dracula-theme.theme-dracula" ]
- settings = {
- "workbench.colorTheme" = "Dracula"
- }
+```tf
+module "code-server" {
+ source = "registry.coder.com/modules/code-server/coder"
+ version = "1.0.14"
+ agent_id = coder_agent.example.id
+ extensions = ["dracula-theme.theme-dracula"]
+ settings = {
+ "workbench.colorTheme" = "Dracula"
+ }
}
```
-### Offline Mode
+### Install multiple extensions
Just run code-server in the background, don't fetch it from GitHub:
-```hcl
-module "settings" {
- source = "https://registry.coder.com/modules/code-server"
- agent_id = coder_agent.example.id
- offline = true
+```tf
+module "code-server" {
+ source = "registry.coder.com/modules/code-server/coder"
+ version = "1.0.14"
+ agent_id = coder_agent.example.id
+ extensions = ["dracula-theme.theme-dracula", "ms-azuretools.vscode-docker"]
}
-```
\ No newline at end of file
+```
+
+### Offline and Use Cached Modes
+
+By default the module looks for code-server at `/tmp/code-server` but this can be changed with `install_prefix`.
+
+Run an existing copy of code-server if found, otherwise download from GitHub:
+
+```tf
+module "code-server" {
+ source = "registry.coder.com/modules/code-server/coder"
+ version = "1.0.14"
+ agent_id = coder_agent.example.id
+ use_cached = true
+ extensions = ["dracula-theme.theme-dracula", "ms-azuretools.vscode-docker"]
+}
+```
+
+Just run code-server in the background, don't fetch it from GitHub:
+
+```tf
+module "code-server" {
+ source = "registry.coder.com/modules/code-server/coder"
+ version = "1.0.14"
+ agent_id = coder_agent.example.id
+ offline = true
+}
+```
diff --git a/code-server/main.test.ts b/code-server/main.test.ts
new file mode 100644
index 0000000..1d6da5e
--- /dev/null
+++ b/code-server/main.test.ts
@@ -0,0 +1,38 @@
+import { describe, expect, it } from "bun:test";
+import {
+ runTerraformApply,
+ runTerraformInit,
+ testRequiredVariables,
+} from "../test";
+
+describe("code-server", async () => {
+ await runTerraformInit(import.meta.dir);
+
+ testRequiredVariables(import.meta.dir, {
+ agent_id: "foo",
+ });
+
+ it("use_cached and offline can not be used together", () => {
+ const t = async () => {
+ await runTerraformApply(import.meta.dir, {
+ agent_id: "foo",
+ use_cached: "true",
+ offline: "true",
+ });
+ };
+ expect(t).toThrow("Offline and Use Cached can not be used together");
+ });
+
+ it("offline and extensions can not be used together", () => {
+ const t = async () => {
+ await runTerraformApply(import.meta.dir, {
+ agent_id: "foo",
+ offline: "true",
+ extensions: '["1", "2"]',
+ });
+ };
+ expect(t).toThrow("Offline mode does not allow extensions to be installed");
+ });
+
+ // More tests depend on shebang refactors
+});
diff --git a/code-server/main.tf b/code-server/main.tf
index 1608799..c186c14 100644
--- a/code-server/main.tf
+++ b/code-server/main.tf
@@ -4,75 +4,152 @@ terraform {
required_providers {
coder = {
source = "coder/coder"
- version = ">= 0.12"
+ version = ">= 0.17"
}
}
}
variable "agent_id" {
- type = string
- description = "The ID of a Coder agent."
+ type = string
+ description = "The ID of a Coder agent."
}
variable "extensions" {
- type = list(string)
- description = "A list of extensions to install."
- default = [ ]
+ type = list(string)
+ description = "A list of extensions to install."
+ default = []
}
variable "port" {
- type = number
- description = "The port to run code-server on."
- default = 13337
+ type = number
+ description = "The port to run code-server on."
+ default = 13337
+}
+
+variable "display_name" {
+ type = string
+ description = "The display name for the code-server application."
+ default = "code-server"
+}
+
+variable "slug" {
+ type = string
+ description = "The slug for the code-server application."
+ default = "code-server"
}
variable "settings" {
- type = map(string)
- description = "A map of settings to apply to code-server."
- default = {}
+ type = map(string)
+ description = "A map of settings to apply to code-server."
+ default = {}
}
variable "folder" {
- type = string
- description = "The folder to open in code-server."
- default = ""
+ type = string
+ description = "The folder to open in code-server."
+ default = ""
}
variable "install_prefix" {
- type = string
- description = "The prefix to install code-server to."
- default = "/tmp/code-server"
+ type = string
+ description = "The prefix to install code-server to."
+ default = "/tmp/code-server"
}
variable "log_path" {
- type = string
- description = "The path to log code-server to."
- default = "/tmp/code-server.log"
+ type = string
+ description = "The path to log code-server to."
+ default = "/tmp/code-server.log"
+}
+
+variable "install_version" {
+ type = string
+ description = "The version of code-server to install."
+ default = ""
+}
+
+variable "share" {
+ type = string
+ default = "owner"
+ validation {
+ condition = var.share == "owner" || var.share == "authenticated" || var.share == "public"
+ error_message = "Incorrect value. Please set either 'owner', 'authenticated', or 'public'."
+ }
+}
+
+variable "order" {
+ type = number
+ description = "The order determines the position of app in the UI presentation. The lowest order is shown first and apps with equal order are sorted by name (ascending order)."
+ default = null
+}
+
+variable "offline" {
+ type = bool
+ description = "Just run code-server in the background, don't fetch it from GitHub"
+ default = false
+}
+
+variable "use_cached" {
+ type = bool
+ description = "Uses cached copy code-server in the background, otherwise fetched it from GitHub"
+ default = false
+}
+
+variable "extensions_dir" {
+ type = string
+ description = "Override the directory to store extensions in."
+ default = ""
+}
+
+variable "auto_install_extensions" {
+ type = bool
+ description = "Automatically install recommended extensions when code-server starts."
+ default = false
}
resource "coder_script" "code-server" {
- agent_id = var.agent_id
- display_name = "code-server"
- icon = "/icon/code.svg"
- script = templatefile("${path.module}/run.sh", {
- EXTENSIONS: join(",", var.extensions),
- PORT: var.port,
- LOG_PATH: var.log_path,
- INSTALL_PREFIX: var.install_prefix,
- // This is necessary otherwise the quotes are stripped!
- SETTINGS: replace(jsonencode(var.settings), "\"", "\\\""),
- })
- run_on_start = true
+ agent_id = var.agent_id
+ display_name = "code-server"
+ icon = "/icon/code.svg"
+ script = templatefile("${path.module}/run.sh", {
+ VERSION : var.install_version,
+ EXTENSIONS : join(",", var.extensions),
+ APP_NAME : var.display_name,
+ PORT : var.port,
+ LOG_PATH : var.log_path,
+ INSTALL_PREFIX : var.install_prefix,
+ // This is necessary otherwise the quotes are stripped!
+ SETTINGS : replace(jsonencode(var.settings), "\"", "\\\""),
+ OFFLINE : var.offline,
+ USE_CACHED : var.use_cached,
+ EXTENSIONS_DIR : var.extensions_dir,
+ FOLDER : var.folder,
+ AUTO_INSTALL_EXTENSIONS : var.auto_install_extensions,
+ })
+ run_on_start = true
+
+ lifecycle {
+ precondition {
+ condition = !var.offline || length(var.extensions) == 0
+ error_message = "Offline mode does not allow extensions to be installed"
+ }
+
+ precondition {
+ condition = !var.offline || !var.use_cached
+ error_message = "Offline and Use Cached can not be used together"
+ }
+ }
}
resource "coder_app" "code-server" {
agent_id = var.agent_id
- slug = "code-server"
- display_name = "code-server"
- url = "http://localhost:${var.port}/?folder=${var.folder}"
+ slug = var.slug
+ display_name = var.display_name
+ url = "http://localhost:${var.port}/${var.folder != "" ? "?folder=${urlencode(var.folder)}" : ""}"
icon = "/icon/code.svg"
subdomain = false
- share = "owner"
+ share = var.share
+ order = var.order
healthcheck {
url = "http://localhost:${var.port}/healthz"
diff --git a/code-server/run.sh b/code-server/run.sh
index 116af8c..26a1c6e 100755
--- a/code-server/run.sh
+++ b/code-server/run.sh
@@ -1,40 +1,93 @@
-#!/usr/bin/env sh
+#!/usr/bin/env bash
EXTENSIONS=("${EXTENSIONS}")
BOLD='\033[0;1m'
CODE='\033[36;40;1m'
RESET='\033[0m'
+CODE_SERVER="${INSTALL_PREFIX}/bin/code-server"
+
+# Set extension directory
+EXTENSION_ARG=""
+if [ -n "${EXTENSIONS_DIR}" ]; then
+ EXTENSION_ARG="--extensions-dir=${EXTENSIONS_DIR}"
+fi
+
+function run_code_server() {
+ echo "👷 Running code-server in the background..."
+ echo "Check logs at ${LOG_PATH}!"
+ $CODE_SERVER "$EXTENSION_ARG" --auth none --port "${PORT}" --app-name "${APP_NAME}" > "${LOG_PATH}" 2>&1 &
+}
+
+# Check if the settings file exists...
+if [ ! -f ~/.local/share/code-server/User/settings.json ]; then
+ echo "⚙️ Creating settings file..."
+ mkdir -p ~/.local/share/code-server/User
+ echo "${SETTINGS}" > ~/.local/share/code-server/User/settings.json
+fi
+
+# Check if code-server is already installed for offline or cached mode
+if [ -f "$CODE_SERVER" ]; then
+ if [ "${OFFLINE}" = true ] || [ "${USE_CACHED}" = true ]; then
+ echo "🥳 Found a copy of code-server"
+ run_code_server
+ exit 0
+ fi
+fi
+# Offline mode always expects a copy of code-server to be present
+if [ "${OFFLINE}" = true ]; then
+ echo "Failed to find a copy of code-server"
+ exit 1
+fi
printf "$${BOLD}Installing code-server!\n"
-output=$(curl -fsSL https://code-server.dev/install.sh | sh -s -- --method=standalone --prefix=${INSTALL_PREFIX})
+
+ARGS=(
+ "--method=standalone"
+ "--prefix=${INSTALL_PREFIX}"
+)
+if [ -n "${VERSION}" ]; then
+ ARGS+=("--version=${VERSION}")
+fi
+
+output=$(curl -fsSL https://code-server.dev/install.sh | sh -s -- "$${ARGS[@]}")
if [ $? -ne 0 ]; then
echo "Failed to install code-server: $output"
exit 1
fi
printf "🥳 code-server has been installed in ${INSTALL_PREFIX}\n\n"
-CODE_SERVER="${INSTALL_PREFIX}/bin/code-server"
-
# Install each extension...
-for extension in "$${EXTENSIONS[@]}"; do
+IFS=',' read -r -a EXTENSIONLIST <<< "$${EXTENSIONS}"
+for extension in "$${EXTENSIONLIST[@]}"; do
if [ -z "$extension" ]; then
continue
fi
printf "🧩 Installing extension $${CODE}$extension$${RESET}...\n"
- output=$($CODE_SERVER --install-extension "$extension")
+ output=$($CODE_SERVER "$EXTENSION_ARG" --install-extension "$extension")
if [ $? -ne 0 ]; then
echo "Failed to install extension: $extension: $output"
exit 1
fi
done
-# Check if the settings file exists...
-if [ ! -f ~/.local/share/code-server/User/settings.json ]; then
- echo "⚙️ Creating settings file..."
- mkdir -p ~/.local/share/code-server/User
- echo "${SETTINGS}" > ~/.local/share/code-server/User/settings.json
+if [ "${AUTO_INSTALL_EXTENSIONS}" = true ]; then
+ if ! command -v jq > /dev/null; then
+ echo "jq is required to install extensions from a workspace file."
+ exit 0
+ fi
+
+ WORKSPACE_DIR="$HOME"
+ if [ -n "${FOLDER}" ]; then
+ WORKSPACE_DIR="${FOLDER}"
+ fi
+
+ if [ -f "$WORKSPACE_DIR/.vscode/extensions.json" ]; then
+ printf "🧩 Installing extensions from %s/.vscode/extensions.json...\n" "$WORKSPACE_DIR"
+ extensions=$(jq -r '.recommendations[]' "$WORKSPACE_DIR"/.vscode/extensions.json)
+ for extension in $extensions; do
+ $CODE_SERVER "$EXTENSION_ARG" --install-extension "$extension"
+ done
+ fi
fi
-echo "👷 Running code-server in the background..."
-echo "Check logs at ${LOG_PATH}!"
-$CODE_SERVER --auth none --port ${PORT} >${LOG_PATH} 2>&1 &
\ No newline at end of file
+run_code_server
diff --git a/coder-login/README.md b/coder-login/README.md
new file mode 100644
index 0000000..d68e088
--- /dev/null
+++ b/coder-login/README.md
@@ -0,0 +1,22 @@
+---
+display_name: Coder Login
+description: Automatically logs the user into Coder on their workspace
+icon: ../.icons/coder-white.svg
+maintainer_github: coder
+verified: true
+tags: [helper]
+---
+
+# Coder Login
+
+Automatically logs the user into Coder when creating their workspace.
+
+```tf
+module "coder-login" {
+ source = "registry.coder.com/modules/coder-login/coder"
+ version = "1.0.2"
+ agent_id = coder_agent.example.id
+}
+```
+
+
diff --git a/coder-login/main.test.ts b/coder-login/main.test.ts
new file mode 100644
index 0000000..d8fba35
--- /dev/null
+++ b/coder-login/main.test.ts
@@ -0,0 +1,15 @@
+import { describe, expect, it } from "bun:test";
+import {
+ executeScriptInContainer,
+ runTerraformApply,
+ runTerraformInit,
+ testRequiredVariables,
+} from "../test";
+
+describe("coder-login", async () => {
+ await runTerraformInit(import.meta.dir);
+
+ testRequiredVariables(import.meta.dir, {
+ agent_id: "foo",
+ });
+});
diff --git a/coder-login/main.tf b/coder-login/main.tf
new file mode 100644
index 0000000..58d1bf0
--- /dev/null
+++ b/coder-login/main.tf
@@ -0,0 +1,30 @@
+terraform {
+ required_version = ">= 1.0"
+
+ required_providers {
+ coder = {
+ source = "coder/coder"
+ version = ">= 0.12"
+ }
+ }
+}
+
+variable "agent_id" {
+ type = string
+ description = "The ID of a Coder agent."
+}
+
+data "coder_workspace" "me" {}
+
+resource "coder_script" "coder-login" {
+ agent_id = var.agent_id
+ script = templatefile("${path.module}/run.sh", {
+ CODER_USER_TOKEN : data.coder_workspace.me.owner_session_token,
+ CODER_DEPLOYMENT_URL : data.coder_workspace.me.access_url
+ })
+ display_name = "Coder Login"
+ icon = "/icon/coder.svg"
+ run_on_start = true
+ start_blocks_login = true
+}
+
diff --git a/coder-login/run.sh b/coder-login/run.sh
new file mode 100644
index 0000000..c91eb1e
--- /dev/null
+++ b/coder-login/run.sh
@@ -0,0 +1,15 @@
+#!/usr/bin/env sh
+
+# Automatically authenticate the user if they are not
+# logged in to another deployment
+
+BOLD='\033[0;1m'
+
+printf "$${BOLD}Logging into Coder...\n\n$${RESET}"
+
+if ! coder list > /dev/null 2>&1; then
+ set +x
+ coder login --token="${CODER_USER_TOKEN}" --url="${CODER_DEPLOYMENT_URL}"
+else
+ echo "You are already authenticated with coder."
+fi
diff --git a/dotfiles/README.md b/dotfiles/README.md
new file mode 100644
index 0000000..4e3312f
--- /dev/null
+++ b/dotfiles/README.md
@@ -0,0 +1,78 @@
+---
+display_name: Dotfiles
+description: Allow developers to optionally bring their own dotfiles repository to customize their shell and IDE settings!
+icon: ../.icons/dotfiles.svg
+maintainer_github: coder
+verified: true
+tags: [helper]
+---
+
+# Dotfiles
+
+Allow developers to optionally bring their own [dotfiles repository](https://dotfiles.github.io).
+
+This will prompt the user for their dotfiles repository URL on template creation using a `coder_parameter`.
+
+Under the hood, this module uses the [coder dotfiles](https://coder.com/docs/v2/latest/dotfiles) command.
+
+```tf
+module "dotfiles" {
+ source = "registry.coder.com/modules/dotfiles/coder"
+ version = "1.0.14"
+ agent_id = coder_agent.example.id
+}
+```
+
+## Examples
+
+### Apply dotfiles as the current user
+
+```tf
+module "dotfiles" {
+ source = "registry.coder.com/modules/dotfiles/coder"
+ version = "1.0.14"
+ agent_id = coder_agent.example.id
+}
+```
+
+### Apply dotfiles as another user (only works if sudo is passwordless)
+
+```tf
+module "dotfiles" {
+ source = "registry.coder.com/modules/dotfiles/coder"
+ version = "1.0.14"
+ agent_id = coder_agent.example.id
+ user = "root"
+}
+```
+
+### Apply the same dotfiles as the current user and root (the root dotfiles can only be applied if sudo is passwordless)
+
+```tf
+module "dotfiles" {
+ source = "registry.coder.com/modules/dotfiles/coder"
+ version = "1.0.14"
+ agent_id = coder_agent.example.id
+}
+
+module "dotfiles-root" {
+ source = "registry.coder.com/modules/dotfiles/coder"
+ version = "1.0.14"
+ agent_id = coder_agent.example.id
+ user = "root"
+ dotfiles_uri = module.dotfiles.dotfiles_uri
+}
+```
+
+## Setting a default dotfiles repository
+
+You can set a default dotfiles repository for all users by setting the `default_dotfiles_uri` variable:
+
+```tf
+module "dotfiles" {
+ source = "registry.coder.com/modules/dotfiles/coder"
+ version = "1.0.14"
+ agent_id = coder_agent.example.id
+ default_dotfiles_uri = "https://github.com/coder/dotfiles"
+}
+```
diff --git a/dotfiles/main.test.ts b/dotfiles/main.test.ts
new file mode 100644
index 0000000..6026719
--- /dev/null
+++ b/dotfiles/main.test.ts
@@ -0,0 +1,40 @@
+import { describe, expect, it } from "bun:test";
+import {
+ runTerraformApply,
+ runTerraformInit,
+ testRequiredVariables,
+} from "../test";
+
+describe("dotfiles", async () => {
+ await runTerraformInit(import.meta.dir);
+
+ testRequiredVariables(import.meta.dir, {
+ agent_id: "foo",
+ });
+
+ it("default output", async () => {
+ const state = await runTerraformApply(import.meta.dir, {
+ agent_id: "foo",
+ });
+ expect(state.outputs.dotfiles_uri.value).toBe("");
+ });
+
+ it("set a default dotfiles_uri", async () => {
+ const default_dotfiles_uri = "foo";
+ const state = await runTerraformApply(import.meta.dir, {
+ agent_id: "foo",
+ default_dotfiles_uri,
+ });
+ expect(state.outputs.dotfiles_uri.value).toBe(default_dotfiles_uri);
+ });
+
+ it("set custom order for coder_parameter", async () => {
+ const order = 99;
+ const state = await runTerraformApply(import.meta.dir, {
+ agent_id: "foo",
+ coder_parameter_order: order.toString(),
+ });
+ expect(state.resources).toHaveLength(2);
+ expect(state.resources[0].instances[0].attributes.order).toBe(order);
+ });
+});
diff --git a/dotfiles/main.tf b/dotfiles/main.tf
new file mode 100644
index 0000000..950cb9b
--- /dev/null
+++ b/dotfiles/main.tf
@@ -0,0 +1,74 @@
+terraform {
+ required_version = ">= 1.0"
+
+ required_providers {
+ coder = {
+ source = "coder/coder"
+ version = ">= 0.12"
+ }
+ }
+}
+
+variable "agent_id" {
+ type = string
+ description = "The ID of a Coder agent."
+}
+
+variable "default_dotfiles_uri" {
+ type = string
+ description = "The default dotfiles URI if the workspace user does not provide one"
+ default = ""
+}
+
+variable "dotfiles_uri" {
+ type = string
+ description = "The URL to a dotfiles repository. (optional, when set, the user isn't prompted for their dotfiles)"
+
+ default = null
+}
+
+variable "user" {
+ type = string
+ description = "The name of the user to apply the dotfiles to. (optional, applies to the current user by default)"
+ default = null
+}
+
+variable "coder_parameter_order" {
+ type = number
+ description = "The order determines the position of a template parameter in the UI/CLI presentation. The lowest order is shown first and parameters with equal order are sorted by name (ascending order)."
+ default = null
+}
+
+data "coder_parameter" "dotfiles_uri" {
+ count = var.dotfiles_uri == null ? 1 : 0
+
+ type = "string"
+ name = "dotfiles_uri"
+ display_name = "Dotfiles URL (optional)"
+ order = var.coder_parameter_order
+ default = var.default_dotfiles_uri
+ description = "Enter a URL for a [dotfiles repository](https://dotfiles.github.io) to personalize your workspace"
+ mutable = true
+ icon = "/icon/dotfiles.svg"
+}
+
+locals {
+ dotfiles_uri = var.dotfiles_uri != null ? var.dotfiles_uri : data.coder_parameter.dotfiles_uri[0].value
+ user = var.user != null ? var.user : ""
+}
+
+resource "coder_script" "dotfiles" {
+ agent_id = var.agent_id
+ script = templatefile("${path.module}/run.sh", {
+ DOTFILES_URI : local.dotfiles_uri,
+ DOTFILES_USER : local.user
+ })
+ display_name = "Dotfiles"
+ icon = "/icon/dotfiles.svg"
+ run_on_start = true
+}
+
+output "dotfiles_uri" {
+ description = "Dotfiles URI"
+ value = local.dotfiles_uri
+}
diff --git a/dotfiles/run.sh b/dotfiles/run.sh
new file mode 100644
index 0000000..9463439
--- /dev/null
+++ b/dotfiles/run.sh
@@ -0,0 +1,23 @@
+#!/usr/bin/env bash
+DOTFILES_URI="${DOTFILES_URI}"
+DOTFILES_USER="${DOTFILES_USER}"
+
+if [ -n "$${DOTFILES_URI// }" ]; then
+ if [ -z "$DOTFILES_USER" ]; then
+ DOTFILES_USER="$USER"
+ fi
+
+ echo "✨ Applying dotfiles for user $DOTFILES_USER"
+
+ if [ "$DOTFILES_USER" = "$USER" ]; then
+ coder dotfiles "$DOTFILES_URI" -y 2>&1 | tee ~/.dotfiles.log
+ else
+ # The `eval echo ~"$DOTFILES_USER"` part is used to dynamically get the home directory of the user, see https://superuser.com/a/484280
+ # eval echo ~coder -> "/home/coder"
+ # eval echo ~root -> "/root"
+
+ CODER_BIN=$(which coder)
+ DOTFILES_USER_HOME=$(eval echo ~"$DOTFILES_USER")
+ sudo -u "$DOTFILES_USER" sh -c "'$CODER_BIN' dotfiles '$DOTFILES_URI' -y 2>&1 | tee '$DOTFILES_USER_HOME'/.dotfiles.log"
+ fi
+fi
diff --git a/exoscale-instance-type/README.md b/exoscale-instance-type/README.md
new file mode 100644
index 0000000..4296121
--- /dev/null
+++ b/exoscale-instance-type/README.md
@@ -0,0 +1,114 @@
+---
+display_name: exoscale-instance-type
+description: A parameter with human readable exoscale instance names
+icon: ../.icons/exoscale.svg
+maintainer_github: WhizUs
+verified: false
+tags: [helper, parameter, instances, exoscale]
+---
+
+# exoscale-instance-type
+
+A parameter with all Exoscale instance types. This allows developers to select
+their desired virtual machine for the workspace.
+
+Customize the preselected parameter value:
+
+```tf
+module "exoscale-instance-type" {
+ source = "registry.coder.com/modules/exoscale-instance-type/coder"
+ version = "1.0.12"
+ default = "standard.medium"
+}
+
+resource "exoscale_compute_instance" "instance" {
+ type = module.exoscale-instance-type.value
+ # ...
+}
+
+resource "coder_metadata" "workspace_info" {
+ item {
+ key = "instance type"
+ value = module.exoscale-instance-type.name
+ }
+}
+```
+
+
+
+## Examples
+
+### Customize type
+
+Change the display name a type using the corresponding maps:
+
+```tf
+module "exoscale-instance-type" {
+ source = "registry.coder.com/modules/exoscale-instance-type/coder"
+ version = "1.0.12"
+ default = "standard.medium"
+
+ custom_names = {
+ "standard.medium" : "Mittlere Instanz" # German translation
+ }
+
+ custom_descriptions = {
+ "standard.medium" : "4 GB Arbeitsspeicher, 2 Kerne, 10 - 400 GB Festplatte" # German translation
+ }
+}
+
+resource "exoscale_compute_instance" "instance" {
+ type = module.exoscale-instance-type.value
+ # ...
+}
+
+resource "coder_metadata" "workspace_info" {
+ item {
+ key = "instance type"
+ value = module.exoscale-instance-type.name
+ }
+}
+```
+
+
+
+### Use category and exclude type
+
+Show only gpu1 types
+
+```tf
+module "exoscale-instance-type" {
+ source = "registry.coder.com/modules/exoscale-instance-type/coder"
+ version = "1.0.12"
+ default = "gpu.large"
+ type_category = ["gpu"]
+ exclude = [
+ "gpu2.small",
+ "gpu2.medium",
+ "gpu2.large",
+ "gpu2.huge",
+ "gpu3.small",
+ "gpu3.medium",
+ "gpu3.large",
+ "gpu3.huge"
+ ]
+}
+
+resource "exoscale_compute_instance" "instance" {
+ type = module.exoscale-instance-type.value
+ # ...
+}
+
+resource "coder_metadata" "workspace_info" {
+ item {
+ key = "instance type"
+ value = module.exoscale-instance-type.name
+ }
+}
+```
+
+
+
+## Related templates
+
+A related exoscale template will be provided soon.
diff --git a/exoscale-instance-type/main.test.ts b/exoscale-instance-type/main.test.ts
new file mode 100644
index 0000000..e4b998b
--- /dev/null
+++ b/exoscale-instance-type/main.test.ts
@@ -0,0 +1,43 @@
+import { describe, expect, it } from "bun:test";
+import {
+ runTerraformApply,
+ runTerraformInit,
+ testRequiredVariables,
+} from "../test";
+
+describe("exoscale-instance-type", async () => {
+ await runTerraformInit(import.meta.dir);
+
+ testRequiredVariables(import.meta.dir, {});
+
+ it("default output", async () => {
+ const state = await runTerraformApply(import.meta.dir, {});
+ expect(state.outputs.value.value).toBe("");
+ });
+
+ it("customized default", async () => {
+ const state = await runTerraformApply(import.meta.dir, {
+ default: "gpu3.huge",
+ type_category: `["gpu", "cpu"]`,
+ });
+ expect(state.outputs.value.value).toBe("gpu3.huge");
+ });
+
+ it("fails because of wrong categroy definition", async () => {
+ expect(async () => {
+ await runTerraformApply(import.meta.dir, {
+ default: "gpu3.huge",
+ // type_category: ["standard"] is standard
+ });
+ }).toThrow('default value "gpu3.huge" must be defined as one of options');
+ });
+
+ it("set custom order for coder_parameter", async () => {
+ const order = 99;
+ const state = await runTerraformApply(import.meta.dir, {
+ coder_parameter_order: order.toString(),
+ });
+ expect(state.resources).toHaveLength(1);
+ expect(state.resources[0].instances[0].attributes.order).toBe(order);
+ });
+});
diff --git a/exoscale-instance-type/main.tf b/exoscale-instance-type/main.tf
new file mode 100644
index 0000000..65d3729
--- /dev/null
+++ b/exoscale-instance-type/main.tf
@@ -0,0 +1,286 @@
+terraform {
+ required_version = ">= 1.0"
+
+ required_providers {
+ coder = {
+ source = "coder/coder"
+ version = ">= 0.12"
+ }
+ }
+}
+
+variable "display_name" {
+ default = "Exoscale instance type"
+ description = "The display name of the parameter."
+ type = string
+}
+
+variable "description" {
+ default = "Select the exoscale instance type to use for the workspace. Check out the pricing page for more information: https://www.exoscale.com/pricing"
+ description = "The description of the parameter."
+ type = string
+}
+
+variable "default" {
+ default = ""
+ description = "The default instance type to use if no type is specified. One of [\"standard.micro\", \"standard.tiny\", \"standard.small\", \"standard.medium\", \"standard.large\", \"standard.extra\", \"standard.huge\", \"standard.mega\", \"standard.titan\", \"standard.jumbo\", \"standard.colossus\", \"cpu.extra\", \"cpu.huge\", \"cpu.mega\", \"cpu.titan\", \"memory.extra\", \"memory.huge\", \"memory.mega\", \"memory.titan\", \"storage.extra\", \"storage.huge\", \"storage.mega\", \"storage.titan\", \"storage.jumbo\", \"gpu.small\", \"gpu.medium\", \"gpu.large\", \"gpu.huge\", \"gpu2.small\", \"gpu2.medium\", \"gpu2.large\", \"gpu2.huge\", \"gpu3.small\", \"gpu3.medium\", \"gpu3.large\", \"gpu3.huge\"]"
+ type = string
+}
+
+variable "mutable" {
+ default = false
+ description = "Whether the parameter can be changed after creation."
+ type = bool
+}
+
+variable "custom_names" {
+ default = {}
+ description = "A map of custom display names for instance type IDs."
+ type = map(string)
+}
+variable "custom_descriptions" {
+ default = {}
+ description = "A map of custom descriptions for instance type IDs."
+ type = map(string)
+}
+
+variable "type_category" {
+ default = ["standard"]
+ description = "A list of instance type categories the user is allowed to choose. One of [\"standard\", \"cpu\", \"memory\", \"storage\", \"gpu\"]"
+ type = list(string)
+}
+
+variable "exclude" {
+ default = []
+ description = "A list of instance type IDs to exclude. One of [\"standard.micro\", \"standard.tiny\", \"standard.small\", \"standard.medium\", \"standard.large\", \"standard.extra\", \"standard.huge\", \"standard.mega\", \"standard.titan\", \"standard.jumbo\", \"standard.colossus\", \"cpu.extra\", \"cpu.huge\", \"cpu.mega\", \"cpu.titan\", \"memory.extra\", \"memory.huge\", \"memory.mega\", \"memory.titan\", \"storage.extra\", \"storage.huge\", \"storage.mega\", \"storage.titan\", \"storage.jumbo\", \"gpu.small\", \"gpu.medium\", \"gpu.large\", \"gpu.huge\", \"gpu2.small\", \"gpu2.medium\", \"gpu2.large\", \"gpu2.huge\", \"gpu3.small\", \"gpu3.medium\", \"gpu3.large\", \"gpu3.huge\"]"
+ type = list(string)
+}
+
+variable "coder_parameter_order" {
+ type = number
+ description = "The order determines the position of a template parameter in the UI/CLI presentation. The lowest order is shown first and parameters with equal order are sorted by name (ascending order)."
+ default = null
+}
+
+locals {
+ # https://www.exoscale.com/pricing/
+
+ standard_instances = [
+ {
+ value = "standard.micro",
+ name = "Standard Micro",
+ description = "512 MB RAM, 1 Core, 10 - 200 GB Disk"
+ },
+ {
+ value = "standard.tiny",
+ name = "Standard Tiny",
+ description = "1 GB RAM, 1 Core, 10 - 400 GB Disk"
+ },
+ {
+ value = "standard.small",
+ name = "Standard Small",
+ description = "2 GB RAM, 2 Cores, 10 - 400 GB Disk"
+ },
+ {
+ value = "standard.medium",
+ name = "Standard Medium",
+ description = "4 GB RAM, 2 Cores, 10 - 400 GB Disk"
+ },
+ {
+ value = "standard.large",
+ name = "Standard Large",
+ description = "8 GB RAM, 4 Cores, 10 - 400 GB Disk"
+ },
+ {
+ value = "standard.extra",
+ name = "Standard Extra",
+ description = "rge",
+ description = "16 GB RAM, 4 Cores, 10 - 800 GB Disk"
+ },
+ {
+ value = "standard.huge",
+ name = "Standard Huge",
+ description = "32 GB RAM, 8 Cores, 10 - 800 GB Disk"
+ },
+ {
+ value = "standard.mega",
+ name = "Standard Mega",
+ description = "64 GB RAM, 12 Cores, 10 - 800 GB Disk"
+ },
+ {
+ value = "standard.titan",
+ name = "Standard Titan",
+ description = "128 GB RAM, 16 Cores, 10 - 1.6 TB Disk"
+ },
+ {
+ value = "standard.jumbo",
+ name = "Standard Jumbo",
+ description = "256 GB RAM, 24 Cores, 10 - 1.6 TB Disk"
+ },
+ {
+ value = "standard.colossus",
+ name = "Standard Colossus",
+ description = "320 GB RAM, 40 Cores, 10 - 1.6 TB Disk"
+ }
+ ]
+ cpu_instances = [
+ {
+ value = "cpu.extra",
+ name = "CPU Extra-Large",
+ description = "16 GB RAM, 8 Cores, 10 - 800 GB Disk"
+ },
+ {
+ value = "cpu.huge",
+ name = "CPU Huge",
+ description = "32 GB RAM, 16 Cores, 10 - 800 GB Disk"
+ },
+ {
+ value = "cpu.mega",
+ name = "CPU Mega",
+ description = "64 GB RAM, 32 Cores, 10 - 800 GB Disk"
+ },
+ {
+ value = "cpu.titan",
+ name = "CPU Titan",
+ description = "128 GB RAM, 40 Cores, 0.1 - 1.6 TB Disk"
+ }
+ ]
+ memory_instances = [
+ {
+ value = "memory.extra",
+ name = "Memory Extra-Large",
+ description = "16 GB RAM, 2 Cores, 10 - 800 GB Disk"
+ },
+ {
+ value = "memory.huge",
+ name = "Memory Huge",
+ description = "32 GB RAM, 4 Cores, 10 - 800 GB Disk"
+ },
+ {
+ value = "memory.mega",
+ name = "Memory Mega",
+ description = "64 GB RAM, 8 Cores, 10 - 800 GB Disk"
+ },
+ {
+ value = "memory.titan",
+ name = "Memory Titan",
+ description = "128 GB RAM, 12 Cores, 0.1 - 1.6 TB Disk"
+ }
+ ]
+ storage_instances = [
+ {
+ value = "storage.extra",
+ name = "Storage Extra-Large",
+ description = "16 GB RAM, 4 Cores, 1 - 2 TB Disk"
+ },
+ {
+ value = "storage.huge",
+ name = "Storage Huge",
+ description = "32 GB RAM, 8 Cores, 2 - 3 TB Disk"
+ },
+ {
+ value = "storage.mega",
+ name = "Storage Mega",
+ description = "64 GB RAM, 12 Cores, 3 - 5 TB Disk"
+ },
+ {
+ value = "storage.titan",
+ name = "Storage Titan",
+ description = "128 GB RAM, 16 Cores, 5 - 10 TB Disk"
+ },
+ {
+ value = "storage.jumbo",
+ name = "Storage Jumbo",
+ description = "225 GB RAM, 24 Cores, 10 - 15 TB Disk"
+ }
+ ]
+ gpu_instances = [
+ {
+ value = "gpu.small",
+ name = "GPU1 Small",
+ description = "56 GB RAM, 12 Cores, 1 GPU, 100 - 800 GB Disk"
+ },
+ {
+ value = "gpu.medium",
+ name = "GPU1 Medium",
+ description = "90 GB RAM, 16 Cores, 2 GPU, 0.1 - 1.2 TB Disk"
+ },
+ {
+ value = "gpu.large",
+ name = "GPU1 Large",
+ description = "120 GB RAM, 24 Cores, 3 GPU, 0.1 - 1.6 TB Disk"
+ },
+ {
+ value = "gpu.huge",
+ name = "GPU1 Huge",
+ description = "225 GB RAM, 48 Cores, 4 GPU, 0.1 - 1.6 TB Disk"
+ },
+ {
+ value = "gpu2.small",
+ name = "GPU2 Small",
+ description = "56 GB RAM, 12 Cores, 1 GPU, 100 - 800 GB Disk"
+ },
+ {
+ value = "gpu2.medium",
+ name = "GPU2 Medium",
+ description = "90 GB RAM, 16 Cores, 2 GPU, 0.1 - 1.2 TB Disk"
+ },
+ {
+ value = "gpu2.large",
+ name = "GPU2 Large",
+ description = "120 GB RAM, 24 Cores, 3 GPU, 0.1 - 1.6 TB Disk"
+ },
+ {
+ value = "gpu2.huge",
+ name = "GPU2 Huge",
+ description = "225 GB RAM, 48 Cores, 4 GPU, 0.1 - 1.6 TB Disk"
+ },
+ {
+ value = "gpu3.small",
+ name = "GPU3 Small",
+ description = "56 GB RAM, 12 Cores, 1 GPU, 100 - 800 GB Disk"
+ },
+ {
+ value = "gpu3.medium",
+ name = "GPU3 Medium",
+ description = "120 GB RAM, 24 Cores, 2 GPU, 0.1 - 1.2 TB Disk"
+ },
+ {
+ value = "gpu3.large",
+ name = "GPU3 Large",
+ description = "224 GB RAM, 48 Cores, 4 GPU, 0.1 - 1.6 TB Disk"
+ },
+ {
+ value = "gpu3.huge",
+ name = "GPU3 Huge",
+ description = "448 GB RAM, 96 Cores, 8 GPU, 0.1 - 1.6 TB Disk"
+ }
+ ]
+}
+
+data "coder_parameter" "instance_type" {
+ name = "exoscale_instance_type"
+ display_name = var.display_name
+ description = var.description
+ default = var.default == "" ? null : var.default
+ order = var.coder_parameter_order
+ mutable = var.mutable
+ dynamic "option" {
+ for_each = [for k, v in concat(
+ contains(var.type_category, "standard") ? local.standard_instances : [],
+ contains(var.type_category, "cpu") ? local.cpu_instances : [],
+ contains(var.type_category, "memory") ? local.memory_instances : [],
+ contains(var.type_category, "storage") ? local.storage_instances : [],
+ contains(var.type_category, "gpu") ? local.gpu_instances : []
+ ) : v if !(contains(var.exclude, v.value))]
+ content {
+ name = try(var.custom_names[option.value.value], option.value.name)
+ description = try(var.custom_descriptions[option.value.value], option.value.description)
+ value = option.value.value
+ }
+ }
+}
+
+output "value" {
+ value = data.coder_parameter.instance_type.value
+}
diff --git a/exoscale-zone/README.md b/exoscale-zone/README.md
new file mode 100644
index 0000000..0f4353e
--- /dev/null
+++ b/exoscale-zone/README.md
@@ -0,0 +1,98 @@
+---
+display_name: exoscale-zone
+description: A parameter with human zone names and icons
+icon: ../.icons/exoscale.svg
+maintainer_github: WhizUs
+verified: false
+tags: [helper, parameter, zones, regions, exoscale]
+---
+
+# exoscale-zone
+
+A parameter with all Exoscale zones. This allows developers to select
+the zone closest to them.
+
+Customize the preselected parameter value:
+
+```tf
+module "exoscale-zone" {
+ source = "registry.coder.com/modules/exoscale-zone/coder"
+ version = "1.0.12"
+ default = "ch-dk-2"
+}
+
+
+data "exoscale_compute_template" "my_template" {
+ zone = module.exoscale-zone.value
+ name = "Linux Ubuntu 22.04 LTS 64-bit"
+}
+
+resource "exoscale_compute_instance" "instance" {
+ zone = module.exoscale-zone.value
+ # ...
+}
+```
+
+
+
+## Examples
+
+### Customize zones
+
+Change the display name and icon for a zone using the corresponding maps:
+
+```tf
+module "exoscale-zone" {
+ source = "registry.coder.com/modules/exoscale-zone/coder"
+ version = "1.0.12"
+ default = "at-vie-1"
+
+ custom_names = {
+ "at-vie-1" : "Home Vienna"
+ }
+
+ custom_icons = {
+ "at-vie-1" : "/emojis/1f3e0.png"
+ }
+}
+
+data "exoscale_compute_template" "my_template" {
+ zone = module.exoscale-zone.value
+ name = "Linux Ubuntu 22.04 LTS 64-bit"
+}
+
+resource "exoscale_compute_instance" "instance" {
+ zone = module.exoscale-zone.value
+ # ...
+}
+```
+
+
+
+### Exclude regions
+
+Hide the Switzerland zones Geneva and Zurich
+
+```tf
+module "exoscale-zone" {
+ source = "registry.coder.com/modules/exoscale-zone/coder"
+ version = "1.0.12"
+ exclude = ["ch-gva-2", "ch-dk-2"]
+}
+
+data "exoscale_compute_template" "my_template" {
+ zone = module.exoscale-zone.value
+ name = "Linux Ubuntu 22.04 LTS 64-bit"
+}
+
+resource "exoscale_compute_instance" "instance" {
+ zone = module.exoscale-zone.value
+ # ...
+}
+```
+
+
+
+## Related templates
+
+An exoscale sample template will be delivered soon.
diff --git a/exoscale-zone/main.test.ts b/exoscale-zone/main.test.ts
new file mode 100644
index 0000000..ca8eeb7
--- /dev/null
+++ b/exoscale-zone/main.test.ts
@@ -0,0 +1,34 @@
+import { describe, expect, it } from "bun:test";
+import {
+ executeScriptInContainer,
+ runTerraformApply,
+ runTerraformInit,
+ testRequiredVariables,
+} from "../test";
+
+describe("exoscale-zone", async () => {
+ await runTerraformInit(import.meta.dir);
+
+ testRequiredVariables(import.meta.dir, {});
+
+ it("default output", async () => {
+ const state = await runTerraformApply(import.meta.dir, {});
+ expect(state.outputs.value.value).toBe("");
+ });
+
+ it("customized default", async () => {
+ const state = await runTerraformApply(import.meta.dir, {
+ default: "at-vie-1",
+ });
+ expect(state.outputs.value.value).toBe("at-vie-1");
+ });
+
+ it("set custom order for coder_parameter", async () => {
+ const order = 99;
+ const state = await runTerraformApply(import.meta.dir, {
+ coder_parameter_order: order.toString(),
+ });
+ expect(state.resources).toHaveLength(1);
+ expect(state.resources[0].instances[0].attributes.order).toBe(order);
+ });
+});
diff --git a/exoscale-zone/main.tf b/exoscale-zone/main.tf
new file mode 100644
index 0000000..090acb4
--- /dev/null
+++ b/exoscale-zone/main.tf
@@ -0,0 +1,116 @@
+terraform {
+ required_version = ">= 1.0"
+
+ required_providers {
+ coder = {
+ source = "coder/coder"
+ version = ">= 0.12"
+ }
+ }
+}
+
+variable "display_name" {
+ default = "Exoscale Region"
+ description = "The display name of the parameter."
+ type = string
+}
+
+variable "description" {
+ default = "The region to deploy workspace infrastructure."
+ description = "The description of the parameter."
+ type = string
+}
+
+variable "default" {
+ default = ""
+ description = "The default region to use if no region is specified."
+ type = string
+}
+
+variable "mutable" {
+ default = false
+ description = "Whether the parameter can be changed after creation."
+ type = bool
+}
+
+variable "custom_names" {
+ default = {}
+ description = "A map of custom display names for region IDs."
+ type = map(string)
+}
+
+variable "custom_icons" {
+ default = {}
+ description = "A map of custom icons for region IDs."
+ type = map(string)
+}
+
+variable "exclude" {
+ default = []
+ description = "A list of region IDs to exclude."
+ type = list(string)
+}
+
+variable "coder_parameter_order" {
+ type = number
+ description = "The order determines the position of a template parameter in the UI/CLI presentation. The lowest order is shown first and parameters with equal order are sorted by name (ascending order)."
+ default = null
+}
+
+locals {
+ # This is a static list because the zones don't change _that_
+ # frequently and including the `exoscale_zones` data source requires
+ # the provider, which requires a zone.
+ # https://www.exoscale.com/datacenters/
+ zones = {
+ "de-fra-1" = {
+ name = "Frankfurt - Germany"
+ icon = "/emojis/1f1e9-1f1ea.png"
+ }
+ "at-vie-1" = {
+ name = "Vienna 1 - Austria"
+ icon = "/emojis/1f1e6-1f1f9.png"
+ }
+ "at-vie-2" = {
+ name = "Vienna 2 - Austria"
+ icon = "/emojis/1f1e6-1f1f9.png"
+ }
+ "ch-gva-2" = {
+ name = "Geneva - Switzerland"
+ icon = "/emojis/1f1e8-1f1ed.png"
+ }
+ "ch-dk-2" = {
+ name = "Zurich - Switzerland"
+ icon = "/emojis/1f1e8-1f1ed.png"
+ }
+ "bg-sof-1" = {
+ name = "Sofia - Bulgaria"
+ icon = "/emojis/1f1e7-1f1ec.png"
+ }
+ "de-muc-1" = {
+ name = "Munich - Germany"
+ icon = "/emojis/1f1e9-1f1ea.png"
+ }
+ }
+}
+
+data "coder_parameter" "zone" {
+ name = "exoscale_zone"
+ display_name = var.display_name
+ description = var.description
+ default = var.default == "" ? null : var.default
+ order = var.coder_parameter_order
+ mutable = var.mutable
+ dynamic "option" {
+ for_each = { for k, v in local.zones : k => v if !(contains(var.exclude, k)) }
+ content {
+ name = try(var.custom_names[option.key], option.value.name)
+ icon = try(var.custom_icons[option.key], option.value.icon)
+ value = option.key
+ }
+ }
+}
+
+output "value" {
+ value = data.coder_parameter.zone.value
+}
\ No newline at end of file
diff --git a/filebrowser/README.md b/filebrowser/README.md
new file mode 100644
index 0000000..2881376
--- /dev/null
+++ b/filebrowser/README.md
@@ -0,0 +1,46 @@
+---
+display_name: File Browser
+description: A file browser for your workspace
+icon: ../.icons/filebrowser.svg
+maintainer_github: coder
+verified: true
+tags: [helper, filebrowser]
+---
+
+# File Browser
+
+A file browser for your workspace.
+
+```tf
+module "filebrowser" {
+ source = "registry.coder.com/modules/filebrowser/coder"
+ version = "1.0.8"
+ agent_id = coder_agent.example.id
+}
+```
+
+
+
+## Examples
+
+### Serve a specific directory
+
+```tf
+module "filebrowser" {
+ source = "registry.coder.com/modules/filebrowser/coder"
+ version = "1.0.8"
+ agent_id = coder_agent.example.id
+ folder = "/home/coder/project"
+}
+```
+
+### Specify location of `filebrowser.db`
+
+```tf
+module "filebrowser" {
+ source = "registry.coder.com/modules/filebrowser/coder"
+ version = "1.0.8"
+ agent_id = coder_agent.example.id
+ database_path = ".config/filebrowser.db"
+}
+```
diff --git a/filebrowser/main.test.ts b/filebrowser/main.test.ts
new file mode 100644
index 0000000..79dd99d
--- /dev/null
+++ b/filebrowser/main.test.ts
@@ -0,0 +1,91 @@
+import { describe, expect, it } from "bun:test";
+import {
+ executeScriptInContainer,
+ runTerraformApply,
+ runTerraformInit,
+ testRequiredVariables,
+} from "../test";
+
+describe("filebrowser", async () => {
+ await runTerraformInit(import.meta.dir);
+
+ testRequiredVariables(import.meta.dir, {
+ agent_id: "foo",
+ });
+
+ it("fails with wrong database_path", async () => {
+ const state = await runTerraformApply(import.meta.dir, {
+ agent_id: "foo",
+ database_path: "nofb",
+ }).catch((e) => {
+ if (!e.message.startsWith("\nError: Invalid value for variable")) {
+ throw e;
+ }
+ });
+ });
+
+ it("runs with default", async () => {
+ const state = await runTerraformApply(import.meta.dir, {
+ agent_id: "foo",
+ });
+ const output = await executeScriptInContainer(state, "alpine");
+ expect(output.exitCode).toBe(0);
+ expect(output.stdout).toEqual([
+ "\u001b[0;1mInstalling filebrowser ",
+ "",
+ "🥳 Installation complete! ",
+ "",
+ "👷 Starting filebrowser in background... ",
+ "",
+ "📂 Serving /root at http://localhost:13339 ",
+ "",
+ "Running 'filebrowser --noauth --root /root --port 13339' ",
+ "",
+ "📝 Logs at /tmp/filebrowser.log",
+ ]);
+ });
+
+ it("runs with database_path var", async () => {
+ const state = await runTerraformApply(import.meta.dir, {
+ agent_id: "foo",
+ database_path: ".config/filebrowser.db",
+ });
+ const output = await executeScriptInContainer(state, "alpine");
+ expect(output.exitCode).toBe(0);
+ expect(output.stdout).toEqual([
+ "\u001b[0;1mInstalling filebrowser ",
+ "",
+ "🥳 Installation complete! ",
+ "",
+ "👷 Starting filebrowser in background... ",
+ "",
+ "📂 Serving /root at http://localhost:13339 ",
+ "",
+ "Running 'filebrowser --noauth --root /root --port 13339 -d .config/filebrowser.db' ",
+ "",
+ "📝 Logs at /tmp/filebrowser.log",
+ ]);
+ });
+
+ it("runs with folder var", async () => {
+ const state = await runTerraformApply(import.meta.dir, {
+ agent_id: "foo",
+ folder: "/home/coder/project",
+ });
+ const output = await executeScriptInContainer(state, "alpine");
+ expect(output.exitCode).toBe(0);
+ expect(output.stdout).toEqual([
+ "\u001B[0;1mInstalling filebrowser ",
+ "",
+ "🥳 Installation complete! ",
+ "",
+ "👷 Starting filebrowser in background... ",
+ "",
+ "📂 Serving /home/coder/project at http://localhost:13339 ",
+ "",
+ "Running 'filebrowser --noauth --root /home/coder/project --port 13339' ",
+ "",
+ "📝 Logs at /tmp/filebrowser.log",
+ ]);
+ });
+});
diff --git a/filebrowser/main.tf b/filebrowser/main.tf
new file mode 100644
index 0000000..a07072b
--- /dev/null
+++ b/filebrowser/main.tf
@@ -0,0 +1,84 @@
+terraform {
+ required_version = ">= 1.0"
+
+ required_providers {
+ coder = {
+ source = "coder/coder"
+ version = ">= 0.17"
+ }
+ }
+}
+
+variable "agent_id" {
+ type = string
+ description = "The ID of a Coder agent."
+}
+
+variable "database_path" {
+ type = string
+ description = "The path to the filebrowser database."
+ default = "filebrowser.db"
+ validation {
+ # Ensures path leads to */filebrowser.db
+ condition = can(regex(".*filebrowser\\.db$", var.database_path))
+ error_message = "The database_path must end with 'filebrowser.db'."
+ }
+}
+
+variable "log_path" {
+ type = string
+ description = "The path to log filebrowser to."
+ default = "/tmp/filebrowser.log"
+}
+
+variable "port" {
+ type = number
+ description = "The port to run filebrowser on."
+ default = 13339
+}
+
+variable "folder" {
+ type = string
+ description = "--root value for filebrowser."
+ default = "~"
+}
+
+variable "share" {
+ type = string
+ default = "owner"
+ validation {
+ condition = var.share == "owner" || var.share == "authenticated" || var.share == "public"
+ error_message = "Incorrect value. Please set either 'owner', 'authenticated', or 'public'."
+ }
+}
+
+variable "order" {
+ type = number
+ description = "The order determines the position of app in the UI presentation. The lowest order is shown first and apps with equal order are sorted by name (ascending order)."
+ default = null
+}
+
+resource "coder_script" "filebrowser" {
+ agent_id = var.agent_id
+ display_name = "File Browser"
+ icon = "https://raw.githubusercontent.com/filebrowser/logo/master/icon_raw.svg"
+ script = templatefile("${path.module}/run.sh", {
+ LOG_PATH : var.log_path,
+ PORT : var.port,
+ FOLDER : var.folder,
+ LOG_PATH : var.log_path,
+ DB_PATH : var.database_path
+ })
+ run_on_start = true
+}
+
+resource "coder_app" "filebrowser" {
+ agent_id = var.agent_id
+ slug = "filebrowser"
+ display_name = "File Browser"
+ url = "http://localhost:${var.port}"
+ icon = "https://raw.githubusercontent.com/filebrowser/logo/master/icon_raw.svg"
+ subdomain = true
+ share = var.share
+ order = var.order
+}
diff --git a/filebrowser/run.sh b/filebrowser/run.sh
new file mode 100644
index 0000000..8744edb
--- /dev/null
+++ b/filebrowser/run.sh
@@ -0,0 +1,26 @@
+#!/usr/bin/env bash
+
+BOLD='\033[0;1m'
+printf "$${BOLD}Installing filebrowser \n\n"
+
+curl -fsSL https://raw.githubusercontent.com/filebrowser/get/master/get.sh | bash
+
+printf "🥳 Installation complete! \n\n"
+
+printf "👷 Starting filebrowser in background... \n\n"
+
+ROOT_DIR=${FOLDER}
+ROOT_DIR=$${ROOT_DIR/\~/$HOME}
+
+DB_FLAG=""
+if [ "${DB_PATH}" != "filebrowser.db" ]; then
+ DB_FLAG=" -d ${DB_PATH}"
+fi
+
+printf "📂 Serving $${ROOT_DIR} at http://localhost:${PORT} \n\n"
+
+printf "Running 'filebrowser --noauth --root $ROOT_DIR --port ${PORT}$${DB_FLAG}' \n\n"
+
+filebrowser --noauth --root $ROOT_DIR --port ${PORT}$${DB_FLAG} > ${LOG_PATH} 2>&1 &
+
+printf "📝 Logs at ${LOG_PATH} \n\n"
diff --git a/fly-region/README.md b/fly-region/README.md
index 96babc3..e5f446e 100644
--- a/fly-region/README.md
+++ b/fly-region/README.md
@@ -4,13 +4,64 @@ description: A parameter with human region names and icons
icon: ../.icons/fly.svg
maintainer_github: coder
verified: true
-tags: [helper, parameter, fly]
+tags: [helper, parameter, fly.io, regions]
---
# Fly.io Region
-A parameter with all fly.io regions. This allows developers to select the region closest to them.
+This module adds Fly.io regions to your Coder template. Regions can be whitelisted using the `regions` argument and given custom names and custom icons with their respective map arguments (`custom_names`, `custom_icons`).
+
+We can use the simplest format here, only adding a default selection as the `atl` region.
+
+```tf
+module "fly-region" {
+ source = "registry.coder.com/modules/fly-region/coder"
+ version = "1.0.2"
+ default = "atl"
+}
+```
+
+
## Examples
-TODO
\ No newline at end of file
+### Using region whitelist
+
+The regions argument can be used to display only the desired regions in the Coder parameter.
+
+```tf
+module "fly-region" {
+ source = "registry.coder.com/modules/fly-region/coder"
+ version = "1.0.2"
+ default = "ams"
+ regions = ["ams", "arn", "atl"]
+}
+```
+
+
+
+### Using custom icons and names
+
+Set custom icons and names with their respective maps.
+
+```tf
+module "fly-region" {
+ source = "registry.coder.com/modules/fly-region/coder"
+ version = "1.0.2"
+ default = "ams"
+
+ custom_icons = {
+ "ams" = "/emojis/1f90e.png"
+ }
+
+ custom_names = {
+ "ams" = "We love the Netherlands!"
+ }
+}
+```
+
+
+
+## Associated template
+
+Also see the Coder template registry for a [Fly.io template](https://registry.coder.com/templates/fly-docker-image) that provisions workspaces as Fly.io machines.
diff --git a/fly-region/main.test.ts b/fly-region/main.test.ts
new file mode 100644
index 0000000..7e72586
--- /dev/null
+++ b/fly-region/main.test.ts
@@ -0,0 +1,32 @@
+import { describe, expect, it } from "bun:test";
+import {
+ runTerraformApply,
+ runTerraformInit,
+ testRequiredVariables,
+} from "../test";
+
+describe("fly-region", async () => {
+ await runTerraformInit(import.meta.dir);
+
+ testRequiredVariables(import.meta.dir, {});
+
+ it("default output", async () => {
+ const state = await runTerraformApply(import.meta.dir, {});
+ expect(state.outputs.value.value).toBe("");
+ });
+
+ it("customized default", async () => {
+ const state = await runTerraformApply(import.meta.dir, {
+ default: "atl",
+ });
+ expect(state.outputs.value.value).toBe("atl");
+ });
+
+ it("region filter", async () => {
+ const state = await runTerraformApply(import.meta.dir, {
+ default: "atl",
+ regions: '["arn", "ams", "bos"]',
+ });
+ expect(state.outputs.value.value).toBe("");
+ });
+});
diff --git a/fly-region/main.tf b/fly-region/main.tf
new file mode 100644
index 0000000..ff6a9e3
--- /dev/null
+++ b/fly-region/main.tf
@@ -0,0 +1,287 @@
+terraform {
+ required_version = ">= 1.0"
+
+ required_providers {
+ coder = {
+ source = "coder/coder"
+ version = ">= 0.12"
+ }
+ }
+}
+
+variable "display_name" {
+ default = "Fly.io Region"
+ description = "The display name of the parameter."
+ type = string
+}
+
+variable "description" {
+ default = "The region to deploy workspace infrastructure."
+ description = "The description of the parameter."
+ type = string
+}
+
+variable "default" {
+ default = null
+ description = "The default region to use if no region is specified."
+ type = string
+}
+
+variable "mutable" {
+ default = false
+ description = "Whether the parameter can be changed after creation."
+ type = bool
+}
+
+variable "custom_names" {
+ default = {}
+ description = "A map of custom display names for region IDs."
+ type = map(string)
+}
+
+variable "custom_icons" {
+ default = {}
+ description = "A map of custom icons for region IDs."
+ type = map(string)
+}
+
+variable "regions" {
+ default = []
+ description = "List of regions to include for region selection."
+ type = list(string)
+}
+
+locals {
+ regions = {
+ "ams" = {
+ name = "Amsterdam, Netherlands"
+ gateway = true
+ paid_only = false
+ icon = "/emojis/1f1f3-1f1f1.png"
+ }
+ "arn" = {
+ name = "Stockholm, Sweden"
+ gateway = false
+ paid_only = false
+ icon = "/emojis/1f1f8-1f1ea.png"
+ }
+ "atl" = {
+ name = "Atlanta, Georgia (US)"
+ gateway = false
+ paid_only = false
+ icon = "/emojis/1f1fa-1f1f8.png"
+ }
+ "bog" = {
+ name = "Bogotá, Colombia"
+ gateway = false
+ paid_only = false
+ icon = "/emojis/1f1e8-1f1f4.png"
+ }
+ "bom" = {
+ name = "Mumbai, India"
+ gateway = true
+ paid_only = true
+ icon = "/emojis/1f1ee-1f1f3.png"
+ }
+ "bos" = {
+ name = "Boston, Massachusetts (US)"
+ gateway = false
+ paid_only = false
+ icon = "/emojis/1f1fa-1f1f8.png"
+ }
+ "cdg" = {
+ name = "Paris, France"
+ gateway = true
+ paid_only = false
+ icon = "/emojis/1f1eb-1f1f7.png"
+ }
+ "den" = {
+ name = "Denver, Colorado (US)"
+ gateway = false
+ paid_only = false
+ icon = "/emojis/1f1fa-1f1f8.png"
+ }
+ "dfw" = {
+ name = "Dallas, Texas (US)"
+ gateway = true
+ paid_only = false
+ icon = "/emojis/1f1fa-1f1f8.png"
+ }
+ "ewr" = {
+ name = "Secaucus, NJ (US)"
+ gateway = false
+ paid_only = false
+ icon = "/emojis/1f1fa-1f1f8.png"
+ }
+ "eze" = {
+ name = "Ezeiza, Argentina"
+ gateway = false
+ paid_only = false
+ icon = "/emojis/1f1e6-1f1f7.png"
+ }
+ "fra" = {
+ name = "Frankfurt, Germany"
+ gateway = true
+ paid_only = true
+ icon = "/emojis/1f1e9-1f1ea.png"
+ }
+ "gdl" = {
+ name = "Guadalajara, Mexico"
+ gateway = false
+ paid_only = false
+ icon = "/emojis/1f1f2-1f1fd.png"
+ }
+ "gig" = {
+ name = "Rio de Janeiro, Brazil"
+ gateway = false
+ paid_only = false
+ icon = "/emojis/1f1e7-1f1f7.png"
+ }
+ "gru" = {
+ name = "Sao Paulo, Brazil"
+ gateway = false
+ paid_only = false
+ icon = "/emojis/1f1e7-1f1f7.png"
+ }
+ "hkg" = {
+ name = "Hong Kong, Hong Kong"
+ gateway = true
+ paid_only = false
+ icon = "/emojis/1f1ed-1f1f0.png"
+ }
+ "iad" = {
+ name = "Ashburn, Virginia (US)"
+ gateway = true
+ paid_only = false
+ icon = "/emojis/1f1fa-1f1f8.png"
+ }
+ "jnb" = {
+ name = "Johannesburg, South Africa"
+ gateway = false
+ paid_only = false
+ icon = "/emojis/1f1ff-1f1e6.png"
+ }
+ "lax" = {
+ name = "Los Angeles, California (US)"
+ gateway = true
+ paid_only = false
+ icon = "/emojis/1f1fa-1f1f8.png"
+ }
+ "lhr" = {
+ name = "London, United Kingdom"
+ gateway = true
+ paid_only = false
+ icon = "/emojis/1f1ec-1f1e7.png"
+ }
+ "mad" = {
+ name = "Madrid, Spain"
+ gateway = false
+ paid_only = false
+ icon = "/emojis/1f1ea-1f1f8.png"
+ }
+ "mia" = {
+ name = "Miami, Florida (US)"
+ gateway = false
+ paid_only = false
+ icon = "/emojis/1f1fa-1f1f8.png"
+ }
+ "nrt" = {
+ name = "Tokyo, Japan"
+ gateway = true
+ paid_only = false
+ icon = "/emojis/1f1ef-1f1f5.png"
+ }
+ "ord" = {
+ name = "Chicago, Illinois (US)"
+ gateway = true
+ paid_only = false
+ icon = "/emojis/1f1fa-1f1f8.png"
+ }
+ "otp" = {
+ name = "Bucharest, Romania"
+ gateway = false
+ paid_only = false
+ icon = "/emojis/1f1f7-1f1f4.png"
+ }
+ "phx" = {
+ name = "Phoenix, Arizona (US)"
+ gateway = false
+ paid_only = false
+ icon = "/emojis/1f1fa-1f1f8.png"
+ }
+ "qro" = {
+ name = "Querétaro, Mexico"
+ gateway = false
+ paid_only = false
+ icon = "/emojis/1f1f2-1f1fd.png"
+ }
+ "scl" = {
+ name = "Santiago, Chile"
+ gateway = true
+ paid_only = false
+ icon = "/emojis/1f1e8-1f1f1.png"
+ }
+ "sea" = {
+ name = "Seattle, Washington (US)"
+ gateway = true
+ paid_only = false
+ icon = "/emojis/1f1fa-1f1f8.png"
+ }
+ "sin" = {
+ name = "Singapore, Singapore"
+ gateway = true
+ paid_only = false
+ icon = "/emojis/1f1f8-1f1ec.png"
+ }
+ "sjc" = {
+ name = "San Jose, California (US)"
+ gateway = true
+ paid_only = false
+ icon = "/emojis/1f1fa-1f1f8.png"
+ }
+ "syd" = {
+ name = "Sydney, Australia"
+ gateway = true
+ paid_only = false
+ icon = "/emojis/1f1e6-1f1fa.png"
+ }
+ "waw" = {
+ name = "Warsaw, Poland"
+ gateway = false
+ paid_only = false
+ icon = "/emojis/1f1f5-1f1f1.png"
+ }
+ "yul" = {
+ name = "Montreal, Canada"
+ gateway = false
+ paid_only = false
+ icon = "/emojis/1f1e8-1f1e6.png"
+ }
+ "yyz" = {
+ name = "Toronto, Canada"
+ gateway = true
+ paid_only = false
+ icon = "/emojis/1f1e8-1f1e6.png"
+ }
+ }
+}
+
+data "coder_parameter" "fly_region" {
+ name = "flyio_region"
+ display_name = var.display_name
+ description = var.description
+ default = (var.default != null && var.default != "") && ((var.default != null ? contains(var.regions, var.default) : false) || length(var.regions) == 0) ? var.default : null
+ mutable = var.mutable
+ dynamic "option" {
+ for_each = { for k, v in local.regions : k => v if anytrue([for d in var.regions : k == d]) || length(var.regions) == 0 }
+ content {
+ name = try(var.custom_names[option.key], option.value.name)
+ icon = try(var.custom_icons[option.key], option.value.icon)
+ value = option.key
+ }
+ }
+}
+
+output "value" {
+ value = data.coder_parameter.fly_region.value
+}
\ No newline at end of file
diff --git a/gcp-region/README.md b/gcp-region/README.md
index 9407845..776d638 100644
--- a/gcp-region/README.md
+++ b/gcp-region/README.md
@@ -1,32 +1,77 @@
---
-display_name: GCP Regions
+display_name: GCP Region
description: Add Google Cloud Platform regions to your Coder template.
icon: ../.icons/gcp.svg
maintainer_github: coder
verified: true
-tags: [gcp, regions, zones, helper]
+tags: [gcp, regions, parameter, helper]
---
+
# Google Cloud Platform Regions
This module adds Google Cloud Platform regions to your Coder template.
+```tf
+module "gcp_region" {
+ source = "registry.coder.com/modules/gcp-region/coder"
+ version = "1.0.12"
+ regions = ["us", "europe"]
+}
+
+resource "google_compute_instance" "example" {
+ zone = module.gcp_region.value
+}
+```
+
+
+
## Examples
-1. Add only GPU zones in the US West 1 region:
+### Add only GPU zones in the US West 1 region
+
+Note: setting `gpu_only = true` and using a default region without GPU support, the default will be set to `null`.
+
+```tf
+module "gcp_region" {
+ source = "registry.coder.com/modules/gcp-region/coder"
+ version = "1.0.12"
+ default = ["us-west1-a"]
+ regions = ["us-west1"]
+ gpu_only = false
+}
+
+resource "google_compute_instance" "example" {
+ zone = module.gcp_region.value
+}
+```
+
+### Add all zones in the Europe West region
+
+```tf
+module "gcp_region" {
+ source = "registry.coder.com/modules/gcp-region/coder"
+ version = "1.0.12"
+ regions = ["europe-west"]
+ single_zone_per_region = false
+}
+
+resource "google_compute_instance" "example" {
+ zone = module.gcp_region.value
+}
+```
- ```hcl
- module "regions" {
- source = "https://registry.coder.com/modules/gcp-regions"
- default = ["us-west1"]
- gpu_only = true
- }
- ```
+### Add a single zone from each region in US and Europe that has GPUs
-2. Add all zones in the Europe West region:
+```tf
+module "gcp_region" {
+ source = "registry.coder.com/modules/gcp-region/coder"
+ version = "1.0.12"
+ regions = ["us", "europe"]
+ gpu_only = true
+ single_zone_per_region = true
+}
- ```hcl
- module "regions" {
- source = "https://registry.coder.com/modules/gcp-regions"
- default = ["europe-west"]
- }
- ```
+resource "google_compute_instance" "example" {
+ zone = module.gcp_region.value
+}
+```
diff --git a/gcp-region/main.test.ts b/gcp-region/main.test.ts
new file mode 100644
index 0000000..bf01c2b
--- /dev/null
+++ b/gcp-region/main.test.ts
@@ -0,0 +1,52 @@
+import { describe, expect, it } from "bun:test";
+import {
+ runTerraformApply,
+ runTerraformInit,
+ testRequiredVariables,
+} from "../test";
+
+describe("gcp-region", async () => {
+ await runTerraformInit(import.meta.dir);
+
+ testRequiredVariables(import.meta.dir, {});
+
+ it("default output", async () => {
+ const state = await runTerraformApply(import.meta.dir, {});
+ expect(state.outputs.value.value).toBe("");
+ });
+
+ it("customized default", async () => {
+ const state = await runTerraformApply(import.meta.dir, {
+ regions: '["asia"]',
+ default: "asia-east1-a",
+ });
+ expect(state.outputs.value.value).toBe("asia-east1-a");
+ });
+
+ it("gpu only invalid default", async () => {
+ const state = await runTerraformApply(import.meta.dir, {
+ regions: '["us-west2"]',
+ default: "us-west2-a",
+ gpu_only: "true",
+ });
+ expect(state.outputs.value.value).toBe("");
+ });
+
+ it("gpu only valid default", async () => {
+ const state = await runTerraformApply(import.meta.dir, {
+ regions: '["us-west2"]',
+ default: "us-west2-b",
+ gpu_only: "true",
+ });
+ expect(state.outputs.value.value).toBe("us-west2-b");
+ });
+
+ it("set custom order for coder_parameter", async () => {
+ const order = 99;
+ const state = await runTerraformApply(import.meta.dir, {
+ coder_parameter_order: order.toString(),
+ });
+ expect(state.resources).toHaveLength(1);
+ expect(state.resources[0].instances[0].attributes.order).toBe(order);
+ });
+});
diff --git a/gcp-region/main.tf b/gcp-region/main.tf
index 1114466..0a75924 100644
--- a/gcp-region/main.tf
+++ b/gcp-region/main.tf
@@ -22,6 +22,12 @@ variable "description" {
}
variable "default" {
+ default = null
+ description = "Default zone"
+ type = string
+}
+
+variable "regions" {
description = "List of GCP regions to include."
type = list(string)
default = ["us-central1"]
@@ -51,6 +57,18 @@ variable "custom_icons" {
type = map(string)
}
+variable "single_zone_per_region" {
+ default = true
+ description = "Whether to only include a single zone per region."
+ type = bool
+}
+
+variable "coder_parameter_order" {
+ type = number
+ description = "The order determines the position of a template parameter in the UI/CLI presentation. The lowest order is shown first and parameters with equal order are sorted by name (ascending order)."
+ default = null
+}
+
locals {
zones = {
# US Central
@@ -343,17 +361,17 @@ locals {
"europe-west2-a" = {
gpu = true
name = "London, England (a)"
- icon = "/emojis/1f173-1f1ff.png"
+ icon = "/emojis/1f1ec-1f1e7.png"
}
"europe-west2-b" = {
gpu = true
name = "London, England (b)"
- icon = "/emojis/1f173-1f1ff.png"
+ icon = "/emojis/1f1ec-1f1e7.png"
}
"europe-west2-c" = {
gpu = false
name = "London, England (c)"
- icon = "/emojis/1f173-1f1ff.png"
+ icon = "/emojis/1f1ec-1f1e7.png"
}
"europe-west3-b" = {
@@ -702,14 +720,17 @@ data "coder_parameter" "region" {
description = var.description
icon = "/icon/gcp.png"
mutable = var.mutable
+ default = var.default != null && var.default != "" && (!var.gpu_only || try(local.zones[var.default].gpu, false)) ? var.default : null
+ order = var.coder_parameter_order
dynamic "option" {
for_each = {
for k, v in local.zones : k => v
- if anytrue([for d in var.default : startswith(k, d)]) && (!var.gpu_only || v.gpu)
+ if anytrue([for d in var.regions : startswith(k, d)]) && (!var.gpu_only || v.gpu) && (!var.single_zone_per_region || endswith(k, "-a"))
}
content {
- icon = try(var.custom_icons[option.key], option.value.icon)
- name = try(var.custom_names[option.key], option.value.name)
+ icon = try(var.custom_icons[option.key], option.value.icon)
+ # if single_zone_per_region is true, remove the zone letter from the name
+ name = try(var.custom_names[option.key], var.single_zone_per_region ? substr(option.value.name, 0, length(option.value.name) - 4) : option.value.name)
description = option.key
value = option.key
}
@@ -717,5 +738,11 @@ data "coder_parameter" "region" {
}
output "value" {
- value = data.coder_parameter.region.value
+ description = "GCP zone identifier."
+ value = data.coder_parameter.region.value
+}
+
+output "region" {
+ description = "GCP region identifier."
+ value = substr(data.coder_parameter.region.value, 0, length(data.coder_parameter.region.value) - 2)
}
diff --git a/git-clone/README.md b/git-clone/README.md
new file mode 100644
index 0000000..255b3f1
--- /dev/null
+++ b/git-clone/README.md
@@ -0,0 +1,155 @@
+---
+display_name: Git Clone
+description: Clone a Git repository by URL and skip if it exists.
+icon: ../.icons/git.svg
+maintainer_github: coder
+verified: true
+tags: [git, helper]
+---
+
+# Git Clone
+
+This module allows you to automatically clone a repository by URL and skip if it exists in the base directory provided.
+
+```tf
+module "git-clone" {
+ source = "registry.coder.com/modules/git-clone/coder"
+ version = "1.0.12"
+ agent_id = coder_agent.example.id
+ url = "https://github.com/coder/coder"
+}
+```
+
+## Examples
+
+### Custom Path
+
+```tf
+module "git-clone" {
+ source = "registry.coder.com/modules/git-clone/coder"
+ version = "1.0.12"
+ agent_id = coder_agent.example.id
+ url = "https://github.com/coder/coder"
+ base_dir = "~/projects/coder"
+}
+```
+
+### Git Authentication
+
+To use with [Git Authentication](https://coder.com/docs/v2/latest/admin/git-providers), add the provider by ID to your template:
+
+```tf
+module "git-clone" {
+ source = "registry.coder.com/modules/git-clone/coder"
+ version = "1.0.12"
+ agent_id = coder_agent.example.id
+ url = "https://github.com/coder/coder"
+}
+
+data "coder_git_auth" "github" {
+ id = "github"
+}
+```
+
+## GitHub clone with branch name
+
+To GitHub clone with a specific branch like `feat/example`
+
+```tf
+# Prompt the user for the git repo URL
+data "coder_parameter" "git_repo" {
+ name = "git_repo"
+ display_name = "Git repository"
+ default = "https://github.com/coder/coder/tree/feat/example"
+}
+
+# Clone the repository for branch `feat/example`
+module "git_clone" {
+ source = "registry.coder.com/modules/git-clone/coder"
+ version = "1.0.12"
+ 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" {
+ source = "registry.coder.com/modules/code-server/coder"
+ version = "1.0.12"
+ agent_id = coder_agent.example.id
+ order = 1
+ folder = "/home/${local.username}/${module.git_clone.folder_name}"
+}
+
+# Create a Coder app for the website
+resource "coder_app" "website" {
+ agent_id = coder_agent.example.id
+ order = 2
+ slug = "website"
+ external = true
+ display_name = module.git_clone.folder_name
+ url = module.git_clone.web_url
+ icon = module.git_clone.git_provider != "" ? "/icon/${module.git_clone.git_provider}.svg" : "/icon/git.svg"
+ count = module.git_clone.web_url != "" ? 1 : 0
+}
+```
+
+Configuring `git-clone` for a self-hosted GitHub Enterprise Server running at `github.example.com`
+
+```tf
+module "git-clone" {
+ source = "registry.coder.com/modules/git-clone/coder"
+ version = "1.0.12"
+ agent_id = coder_agent.example.id
+ url = "https://github.example.com/coder/coder/tree/feat/example"
+ git_providers = {
+ "https://github.example.com/" = {
+ provider = "github"
+ }
+ }
+}
+```
+
+## GitLab clone with branch name
+
+To GitLab clone with a specific branch like `feat/example`
+
+```tf
+module "git-clone" {
+ source = "registry.coder.com/modules/git-clone/coder"
+ version = "1.0.12"
+ agent_id = coder_agent.example.id
+ url = "https://gitlab.com/coder/coder/-/tree/feat/example"
+}
+```
+
+Configuring `git-clone` for a self-hosted GitLab running at `gitlab.example.com`
+
+```tf
+module "git-clone" {
+ source = "registry.coder.com/modules/git-clone/coder"
+ version = "1.0.12"
+ agent_id = coder_agent.example.id
+ url = "https://gitlab.example.com/coder/coder/-/tree/feat/example"
+ git_providers = {
+ "https://gitlab.example.com/" = {
+ provider = "gitlab"
+ }
+ }
+}
+```
+
+## Git clone with branch_name set
+
+Alternatively, you can set the `branch_name` attribute to clone a specific branch.
+
+For example, to clone the `feat/example` branch:
+
+```tf
+module "git-clone" {
+ source = "registry.coder.com/modules/git-clone/coder"
+ version = "1.0.12"
+ agent_id = coder_agent.example.id
+ url = "https://github.com/coder/coder"
+ branch_name = "feat/example"
+}
+```
diff --git a/git-clone/main.test.ts b/git-clone/main.test.ts
new file mode 100644
index 0000000..87b0e4a
--- /dev/null
+++ b/git-clone/main.test.ts
@@ -0,0 +1,231 @@
+import { describe, expect, it } from "bun:test";
+import {
+ executeScriptInContainer,
+ runTerraformApply,
+ runTerraformInit,
+ testRequiredVariables,
+} from "../test";
+
+describe("git-clone", async () => {
+ await runTerraformInit(import.meta.dir);
+
+ testRequiredVariables(import.meta.dir, {
+ agent_id: "foo",
+ url: "foo",
+ });
+
+ it("fails without git", async () => {
+ const state = await runTerraformApply(import.meta.dir, {
+ agent_id: "foo",
+ url: "some-url",
+ });
+ const output = await executeScriptInContainer(state, "alpine");
+ expect(output.exitCode).toBe(1);
+ expect(output.stdout).toEqual(["Git is not installed!"]);
+ });
+
+ it("runs with git", async () => {
+ const state = await runTerraformApply(import.meta.dir, {
+ agent_id: "foo",
+ url: "fake-url",
+ });
+ const output = await executeScriptInContainer(state, "alpine/git");
+ expect(output.exitCode).toBe(128);
+ expect(output.stdout).toEqual([
+ "Creating directory ~/fake-url...",
+ "Cloning fake-url to ~/fake-url...",
+ ]);
+ });
+
+ it("repo_dir should match repo name for https", async () => {
+ const url = "https://github.com/coder/coder.git";
+ const state = await runTerraformApply(import.meta.dir, {
+ agent_id: "foo",
+ base_dir: "/tmp",
+ url,
+ });
+ expect(state.outputs.repo_dir.value).toEqual("/tmp/coder");
+ expect(state.outputs.folder_name.value).toEqual("coder");
+ expect(state.outputs.clone_url.value).toEqual(url);
+ expect(state.outputs.web_url.value).toEqual(url);
+ expect(state.outputs.branch_name.value).toEqual("");
+ });
+
+ it("repo_dir should match repo name for https without .git", async () => {
+ const url = "https://github.com/coder/coder";
+ const state = await runTerraformApply(import.meta.dir, {
+ agent_id: "foo",
+ base_dir: "/tmp",
+ url,
+ });
+ expect(state.outputs.repo_dir.value).toEqual("/tmp/coder");
+ expect(state.outputs.clone_url.value).toEqual(url);
+ expect(state.outputs.web_url.value).toEqual(url);
+ expect(state.outputs.branch_name.value).toEqual("");
+ });
+
+ it("repo_dir should match repo name for ssh", async () => {
+ const url = "git@github.com:coder/coder.git";
+ const state = await runTerraformApply(import.meta.dir, {
+ agent_id: "foo",
+ base_dir: "/tmp",
+ url,
+ });
+ expect(state.outputs.repo_dir.value).toEqual("/tmp/coder");
+ expect(state.outputs.git_provider.value).toEqual("");
+ expect(state.outputs.clone_url.value).toEqual(url);
+ const https_url = "https://github.com/coder/coder.git";
+ expect(state.outputs.web_url.value).toEqual(https_url);
+ expect(state.outputs.branch_name.value).toEqual("");
+ });
+
+ it("branch_name should not include query string", async () => {
+ const state = await runTerraformApply(import.meta.dir, {
+ agent_id: "foo",
+ url: "https://gitlab.com/mike.brew/repo-tests.log/-/tree/feat/branch?ref_type=heads",
+ });
+ expect(state.outputs.repo_dir.value).toEqual("~/repo-tests.log");
+ expect(state.outputs.folder_name.value).toEqual("repo-tests.log");
+ const https_url = "https://gitlab.com/mike.brew/repo-tests.log";
+ expect(state.outputs.clone_url.value).toEqual(https_url);
+ expect(state.outputs.web_url.value).toEqual(https_url);
+ expect(state.outputs.branch_name.value).toEqual("feat/branch");
+ });
+
+ it("branch_name should not include fragments", async () => {
+ const state = await runTerraformApply(import.meta.dir, {
+ agent_id: "foo",
+ base_dir: "/tmp",
+ url: "https://gitlab.com/mike.brew/repo-tests.log/-/tree/feat/branch#name",
+ });
+ expect(state.outputs.repo_dir.value).toEqual("/tmp/repo-tests.log");
+ const https_url = "https://gitlab.com/mike.brew/repo-tests.log";
+ expect(state.outputs.clone_url.value).toEqual(https_url);
+ expect(state.outputs.web_url.value).toEqual(https_url);
+ expect(state.outputs.branch_name.value).toEqual("feat/branch");
+ });
+
+ it("gitlab url with branch should match", async () => {
+ const state = await runTerraformApply(import.meta.dir, {
+ agent_id: "foo",
+ base_dir: "/tmp",
+ url: "https://gitlab.com/mike.brew/repo-tests.log/-/tree/feat/branch",
+ });
+ expect(state.outputs.repo_dir.value).toEqual("/tmp/repo-tests.log");
+ expect(state.outputs.git_provider.value).toEqual("gitlab");
+ const https_url = "https://gitlab.com/mike.brew/repo-tests.log";
+ expect(state.outputs.clone_url.value).toEqual(https_url);
+ expect(state.outputs.web_url.value).toEqual(https_url);
+ expect(state.outputs.branch_name.value).toEqual("feat/branch");
+ });
+
+ it("github url with branch should match", async () => {
+ const state = await runTerraformApply(import.meta.dir, {
+ agent_id: "foo",
+ base_dir: "/tmp",
+ url: "https://github.com/michaelbrewer/repo-tests.log/tree/feat/branch",
+ });
+ expect(state.outputs.repo_dir.value).toEqual("/tmp/repo-tests.log");
+ expect(state.outputs.git_provider.value).toEqual("github");
+ const https_url = "https://github.com/michaelbrewer/repo-tests.log";
+ expect(state.outputs.clone_url.value).toEqual(https_url);
+ expect(state.outputs.web_url.value).toEqual(https_url);
+ expect(state.outputs.branch_name.value).toEqual("feat/branch");
+ });
+
+ it("self-host git url with branch should match", async () => {
+ const state = await runTerraformApply(import.meta.dir, {
+ agent_id: "foo",
+ base_dir: "/tmp",
+ url: "https://git.example.com/example/project/-/tree/feat/example",
+ git_providers: `
+ {
+ "https://git.example.com/" = {
+ provider = "gitlab"
+ }
+ }`,
+ });
+ expect(state.outputs.repo_dir.value).toEqual("/tmp/project");
+ expect(state.outputs.git_provider.value).toEqual("gitlab");
+ const https_url = "https://git.example.com/example/project";
+ expect(state.outputs.clone_url.value).toEqual(https_url);
+ expect(state.outputs.web_url.value).toEqual(https_url);
+ expect(state.outputs.branch_name.value).toEqual("feat/example");
+ });
+
+ it("handle unsupported git provider configuration", async () => {
+ const t = async () => {
+ await runTerraformApply(import.meta.dir, {
+ agent_id: "foo",
+ url: "foo",
+ git_providers: `
+ {
+ "https://git.example.com/" = {
+ provider = "bitbucket"
+ }
+ }`,
+ });
+ };
+ expect(t).toThrow('Allowed values for provider are "github" or "gitlab".');
+ });
+
+ it("handle unknown git provider url", async () => {
+ const url = "https://git.unknown.com/coder/coder";
+ const state = await runTerraformApply(import.meta.dir, {
+ agent_id: "foo",
+ base_dir: "/tmp",
+ url,
+ });
+ expect(state.outputs.repo_dir.value).toEqual("/tmp/coder");
+ expect(state.outputs.clone_url.value).toEqual(url);
+ expect(state.outputs.web_url.value).toEqual(url);
+ expect(state.outputs.branch_name.value).toEqual("");
+ });
+
+ it("runs with github clone with switch to feat/branch", async () => {
+ const state = await runTerraformApply(import.meta.dir, {
+ agent_id: "foo",
+ url: "https://github.com/michaelbrewer/repo-tests.log/tree/feat/branch",
+ });
+ const output = await executeScriptInContainer(state, "alpine/git");
+ expect(output.exitCode).toBe(0);
+ expect(output.stdout).toEqual([
+ "Creating directory ~/repo-tests.log...",
+ "Cloning https://github.com/michaelbrewer/repo-tests.log to ~/repo-tests.log on branch feat/branch...",
+ ]);
+ });
+
+ it("runs with gitlab clone with switch to feat/branch", async () => {
+ const state = await runTerraformApply(import.meta.dir, {
+ agent_id: "foo",
+ url: "https://gitlab.com/mike.brew/repo-tests.log/-/tree/feat/branch",
+ });
+ const output = await executeScriptInContainer(state, "alpine/git");
+ expect(output.exitCode).toBe(0);
+ expect(output.stdout).toEqual([
+ "Creating directory ~/repo-tests.log...",
+ "Cloning https://gitlab.com/mike.brew/repo-tests.log to ~/repo-tests.log on branch feat/branch...",
+ ]);
+ });
+
+ it("runs with github clone with branch_name set to feat/branch", async () => {
+ const url = "https://github.com/michaelbrewer/repo-tests.log";
+ const branch_name = "feat/branch";
+ const state = await runTerraformApply(import.meta.dir, {
+ agent_id: "foo",
+ url,
+ branch_name,
+ });
+ expect(state.outputs.repo_dir.value).toEqual("~/repo-tests.log");
+ expect(state.outputs.clone_url.value).toEqual(url);
+ expect(state.outputs.web_url.value).toEqual(url);
+ expect(state.outputs.branch_name.value).toEqual(branch_name);
+
+ const output = await executeScriptInContainer(state, "alpine/git");
+ expect(output.exitCode).toBe(0);
+ expect(output.stdout).toEqual([
+ "Creating directory ~/repo-tests.log...",
+ "Cloning https://github.com/michaelbrewer/repo-tests.log to ~/repo-tests.log on branch feat/branch...",
+ ]);
+ });
+});
diff --git a/git-clone/main.tf b/git-clone/main.tf
new file mode 100644
index 0000000..4af5000
--- /dev/null
+++ b/git-clone/main.tf
@@ -0,0 +1,115 @@
+terraform {
+ required_version = ">= 1.0"
+
+ required_providers {
+ coder = {
+ source = "coder/coder"
+ version = ">= 0.12"
+ }
+ }
+}
+
+variable "url" {
+ description = "The URL of the Git repository."
+ type = string
+}
+
+variable "base_dir" {
+ default = ""
+ description = "The base directory to clone the repository. Defaults to \"$HOME\"."
+ type = string
+}
+
+variable "agent_id" {
+ description = "The ID of a Coder agent."
+ type = string
+}
+
+variable "git_providers" {
+ type = map(object({
+ provider = string
+ }))
+ description = "A mapping of URLs to their git provider."
+ default = {
+ "https://github.com/" = {
+ provider = "github"
+ },
+ "https://gitlab.com/" = {
+ provider = "gitlab"
+ },
+ }
+ validation {
+ error_message = "Allowed values for provider are \"github\" or \"gitlab\"."
+ condition = alltrue([for provider in var.git_providers : contains(["github", "gitlab"], provider.provider)])
+ }
+}
+
+variable "branch_name" {
+ description = "The branch name to clone. If not provided, the default branch will be cloned."
+ type = string
+ default = ""
+}
+
+locals {
+ # Remove query parameters and fragments from the URL
+ url = replace(replace(var.url, "/\\?.*/", ""), "/#.*/", "")
+
+ # Find the git provider based on the URL and determine the tree path
+ provider_key = try(one([for key in keys(var.git_providers) : key if startswith(local.url, key)]), null)
+ provider = try(lookup(var.git_providers, local.provider_key).provider, "")
+ tree_path = local.provider == "gitlab" ? "/-/tree/" : local.provider == "github" ? "/tree/" : ""
+
+ # Remove tree and branch name from the URL
+ clone_url = var.branch_name == "" && local.tree_path != "" ? replace(local.url, "/${local.tree_path}.*/", "") : local.url
+ # Extract the branch name from the URL
+ branch_name = var.branch_name == "" && local.tree_path != "" ? replace(replace(local.url, local.clone_url, ""), "/.*${local.tree_path}/", "") : var.branch_name
+ # Extract the folder name from the URL
+ folder_name = replace(basename(local.clone_url), ".git", "")
+ # Construct the path to clone the repository
+ clone_path = var.base_dir != "" ? join("/", [var.base_dir, local.folder_name]) : join("/", ["~", local.folder_name])
+ # Construct the web URL
+ web_url = startswith(local.clone_url, "git@") ? replace(replace(local.clone_url, ":", "/"), "git@", "https://") : local.clone_url
+}
+
+output "repo_dir" {
+ value = local.clone_path
+ description = "Full path of cloned repo directory"
+}
+
+output "git_provider" {
+ value = local.provider
+ description = "The git provider of the repository"
+}
+
+output "folder_name" {
+ value = local.folder_name
+ description = "The name of the folder that will be created"
+}
+
+output "clone_url" {
+ value = local.clone_url
+ description = "The exact Git repository URL that will be cloned"
+}
+
+output "web_url" {
+ value = local.web_url
+ description = "Git https repository URL (may be invalid for unsupported providers)"
+}
+
+output "branch_name" {
+ value = local.branch_name
+ description = "Git branch name (may be empty)"
+}
+
+resource "coder_script" "git_clone" {
+ agent_id = var.agent_id
+ script = templatefile("${path.module}/run.sh", {
+ CLONE_PATH = local.clone_path,
+ REPO_URL : local.clone_url,
+ BRANCH_NAME : local.branch_name,
+ })
+ display_name = "Git Clone"
+ icon = "/icon/git.svg"
+ run_on_start = true
+ start_blocks_login = true
+}
diff --git a/git-clone/run.sh b/git-clone/run.sh
new file mode 100755
index 0000000..bd80717
--- /dev/null
+++ b/git-clone/run.sh
@@ -0,0 +1,47 @@
+#!/usr/bin/env bash
+
+REPO_URL="${REPO_URL}"
+CLONE_PATH="${CLONE_PATH}"
+BRANCH_NAME="${BRANCH_NAME}"
+# Expand home if it's specified!
+CLONE_PATH="$${CLONE_PATH/#\~/$${HOME}}"
+
+# Check if the variable is empty...
+if [ -z "$REPO_URL" ]; then
+ echo "No repository specified!"
+ exit 1
+fi
+
+# Check if the variable is empty...
+if [ -z "$CLONE_PATH" ]; then
+ echo "No clone path specified!"
+ exit 1
+fi
+
+# Check if `git` is installed...
+if ! command -v git > /dev/null; then
+ echo "Git is not installed!"
+ exit 1
+fi
+
+# Check if the directory for the cloning exists
+# and if not, create it
+if [ ! -d "$CLONE_PATH" ]; then
+ echo "Creating directory $CLONE_PATH..."
+ mkdir -p "$CLONE_PATH"
+fi
+
+# Check if the directory is empty
+# and if it is, clone the repo, otherwise skip cloning
+if [ -z "$(ls -A "$CLONE_PATH")" ]; then
+ if [ -z "$BRANCH_NAME" ]; then
+ echo "Cloning $REPO_URL to $CLONE_PATH..."
+ git clone "$REPO_URL" "$CLONE_PATH"
+ else
+ echo "Cloning $REPO_URL to $CLONE_PATH on branch $BRANCH_NAME..."
+ git clone "$REPO_URL" -b "$BRANCH_NAME" "$CLONE_PATH"
+ fi
+else
+ echo "$CLONE_PATH already exists and isn't empty, skipping clone!"
+ exit 0
+fi
diff --git a/git-commit-signing/README.md b/git-commit-signing/README.md
new file mode 100644
index 0000000..37633a2
--- /dev/null
+++ b/git-commit-signing/README.md
@@ -0,0 +1,25 @@
+---
+display_name: Git commit signing
+description: Configures Git to sign commits using your Coder SSH key
+icon: ../.icons/git.svg
+maintainer_github: phorcys420
+verified: false
+tags: [helper, git]
+---
+
+# git-commit-signing
+
+This module downloads your SSH key from Coder and uses it to sign commits with Git.
+It requires `curl` and `jq` to be installed inside your workspace.
+
+Please observe that using the SSH key that's part of your Coder account for commit signing, means that in the event of a breach of your Coder account, or a malicious admin, someone could perform commit signing pretending to be you.
+
+This module has a chance of conflicting with the user's dotfiles / the personalize module if one of those has configuration directives that overwrite this module's / each other's git configuration.
+
+```tf
+module "git-commit-signing" {
+ source = "registry.coder.com/modules/git-commit-signing/coder"
+ version = "1.0.11"
+ agent_id = coder_agent.example.id
+}
+```
diff --git a/git-commit-signing/main.tf b/git-commit-signing/main.tf
new file mode 100644
index 0000000..7c8cd3b
--- /dev/null
+++ b/git-commit-signing/main.tf
@@ -0,0 +1,25 @@
+terraform {
+ required_version = ">= 1.0"
+
+ required_providers {
+ coder = {
+ source = "coder/coder"
+ version = ">= 0.12"
+ }
+ }
+}
+
+variable "agent_id" {
+ type = string
+ description = "The ID of a Coder agent."
+}
+
+resource "coder_script" "git-commit-signing" {
+ display_name = "Git commit signing"
+ icon = "/icon/git.svg"
+
+ script = file("${path.module}/run.sh")
+ run_on_start = true
+
+ agent_id = var.agent_id
+}
diff --git a/git-commit-signing/run.sh b/git-commit-signing/run.sh
new file mode 100755
index 0000000..c0e0faa
--- /dev/null
+++ b/git-commit-signing/run.sh
@@ -0,0 +1,42 @@
+#!/usr/bin/env sh
+
+if ! command -v git > /dev/null; then
+ echo "git is not installed"
+ exit 1
+fi
+
+if ! command -v curl > /dev/null; then
+ echo "curl is not installed"
+ exit 1
+fi
+
+if ! command -v jq > /dev/null; then
+ echo "jq is not installed"
+ exit 1
+fi
+
+mkdir -p ~/.ssh/git-commit-signing
+
+echo "Downloading SSH key"
+
+ssh_key=$(curl --request GET \
+ --url "${CODER_AGENT_URL}api/v2/workspaceagents/me/gitsshkey" \
+ --header "Coder-Session-Token: ${CODER_AGENT_TOKEN}" \
+ --silent --show-error)
+
+jq --raw-output ".public_key" > ~/.ssh/git-commit-signing/coder.pub << EOF
+$ssh_key
+EOF
+
+jq --raw-output ".private_key" > ~/.ssh/git-commit-signing/coder << EOF
+$ssh_key
+EOF
+
+chmod -R 600 ~/.ssh/git-commit-signing/coder
+chmod -R 644 ~/.ssh/git-commit-signing/coder.pub
+
+echo "Configuring git to use the SSH key"
+
+git config --global gpg.format ssh
+git config --global commit.gpgsign true
+git config --global user.signingkey ~/.ssh/git-commit-signing/coder
diff --git a/git-config/README.md b/git-config/README.md
new file mode 100644
index 0000000..8a0f3ad
--- /dev/null
+++ b/git-config/README.md
@@ -0,0 +1,49 @@
+---
+display_name: Git Config
+description: Stores Git configuration from Coder credentials
+icon: ../.icons/git.svg
+maintainer_github: coder
+verified: true
+tags: [helper, git]
+---
+
+# git-config
+
+Runs a script that updates git credentials in the workspace to match the user's Coder credentials, optionally allowing to the developer to override the defaults.
+
+```tf
+module "git-config" {
+ source = "registry.coder.com/modules/git-config/coder"
+ version = "1.0.12"
+ agent_id = coder_agent.example.id
+}
+```
+
+TODO: Add screenshot
+
+## Examples
+
+### Allow users to override both username and email
+
+```tf
+module "git-config" {
+ source = "registry.coder.com/modules/git-config/coder"
+ version = "1.0.12"
+ agent_id = coder_agent.example.id
+ allow_email_change = true
+}
+```
+
+TODO: Add screenshot
+
+## Disallowing users from overriding both username and email
+
+```tf
+module "git-config" {
+ source = "registry.coder.com/modules/git-config/coder"
+ version = "1.0.12"
+ agent_id = coder_agent.example.id
+ allow_username_change = false
+ allow_email_change = false
+}
+```
diff --git a/git-config/main.test.ts b/git-config/main.test.ts
new file mode 100644
index 0000000..fe410aa
--- /dev/null
+++ b/git-config/main.test.ts
@@ -0,0 +1,99 @@
+import { describe, expect, it } from "bun:test";
+import {
+ runTerraformApply,
+ runTerraformInit,
+ testRequiredVariables,
+} from "../test";
+
+describe("git-config", async () => {
+ await runTerraformInit(import.meta.dir);
+
+ testRequiredVariables(import.meta.dir, {
+ agent_id: "foo",
+ });
+
+ it("can run apply allow_username_change and allow_email_change disabled", async () => {
+ const state = await runTerraformApply(import.meta.dir, {
+ agent_id: "foo",
+ allow_username_change: "false",
+ allow_email_change: "false",
+ });
+
+ const resources = state.resources;
+ expect(resources).toHaveLength(3);
+ expect(resources).toMatchObject([
+ { type: "coder_workspace", name: "me" },
+ { type: "coder_env", name: "git_author_name" },
+ { type: "coder_env", name: "git_commmiter_name" },
+ ]);
+ });
+
+ it("can run apply allow_email_change enabled", async () => {
+ const state = await runTerraformApply(import.meta.dir, {
+ agent_id: "foo",
+ allow_email_change: "true",
+ });
+
+ const resources = state.resources;
+ expect(resources).toHaveLength(5);
+ expect(resources).toMatchObject([
+ { type: "coder_parameter", name: "user_email" },
+ { type: "coder_parameter", name: "username" },
+ { type: "coder_workspace", name: "me" },
+ { type: "coder_env", name: "git_author_name" },
+ { type: "coder_env", name: "git_commmiter_name" },
+ ]);
+ });
+
+ it("can run apply allow_email_change enabled", async () => {
+ const state = await runTerraformApply(
+ import.meta.dir,
+ {
+ agent_id: "foo",
+ allow_username_change: "false",
+ allow_email_change: "false",
+ },
+ { CODER_WORKSPACE_OWNER_EMAIL: "foo@emai.com" },
+ );
+
+ const resources = state.resources;
+ expect(resources).toHaveLength(5);
+ expect(resources).toMatchObject([
+ { type: "coder_workspace", name: "me" },
+ { type: "coder_env", name: "git_author_email" },
+ { type: "coder_env", name: "git_author_name" },
+ { type: "coder_env", name: "git_commmiter_email" },
+ { type: "coder_env", name: "git_commmiter_name" },
+ ]);
+ });
+
+ it("set custom order for coder_parameter for both fields", async () => {
+ const order = 20;
+ const state = await runTerraformApply(import.meta.dir, {
+ agent_id: "foo",
+ allow_username_change: "true",
+ allow_email_change: "true",
+ coder_parameter_order: order.toString(),
+ });
+ expect(state.resources).toHaveLength(5);
+ // user_email order is the same as the order
+ expect(state.resources[0].instances[0].attributes.order).toBe(order);
+ // username order is incremented by 1
+ // @ts-ignore: Object is possibly 'null'.
+ expect(state.resources[1].instances[0]?.attributes.order).toBe(order + 1);
+ });
+
+ it("set custom order for coder_parameter for just username", async () => {
+ const order = 30;
+ const state = await runTerraformApply(import.meta.dir, {
+ agent_id: "foo",
+ allow_email_change: "false",
+ allow_username_change: "true",
+ coder_parameter_order: order.toString(),
+ });
+ expect(state.resources).toHaveLength(4);
+ // user_email was not created
+ // username order is incremented by 1
+ expect(state.resources[0].instances[0].attributes.order).toBe(order + 1);
+ });
+});
diff --git a/git-config/main.tf b/git-config/main.tf
new file mode 100644
index 0000000..fe19288
--- /dev/null
+++ b/git-config/main.tf
@@ -0,0 +1,83 @@
+terraform {
+ required_version = ">= 1.0"
+
+ required_providers {
+ coder = {
+ source = "coder/coder"
+ version = ">= 0.13"
+ }
+ }
+}
+
+variable "agent_id" {
+ type = string
+ description = "The ID of a Coder agent."
+}
+
+variable "allow_username_change" {
+ type = bool
+ description = "Allow developers to change their git username."
+ default = true
+}
+
+variable "allow_email_change" {
+ type = bool
+ description = "Allow developers to change their git email."
+ default = false
+}
+
+variable "coder_parameter_order" {
+ type = number
+ description = "The order determines the position of a template parameter in the UI/CLI presentation. The lowest order is shown first and parameters with equal order are sorted by name (ascending order)."
+ default = null
+}
+
+data "coder_workspace" "me" {}
+
+data "coder_parameter" "user_email" {
+ count = var.allow_email_change ? 1 : 0
+ name = "user_email"
+ type = "string"
+ default = ""
+ order = var.coder_parameter_order != null ? var.coder_parameter_order + 0 : null
+ description = "Git user.email to be used for commits. Leave empty to default to Coder user's email."
+ display_name = "Git config user.email"
+ mutable = true
+}
+
+data "coder_parameter" "username" {
+ count = var.allow_username_change ? 1 : 0
+ name = "username"
+ type = "string"
+ default = ""
+ order = var.coder_parameter_order != null ? var.coder_parameter_order + 1 : null
+ description = "Git user.name to be used for commits. Leave empty to default to Coder user's Full Name."
+ display_name = "Full Name for Git config"
+ mutable = true
+}
+
+resource "coder_env" "git_author_name" {
+ agent_id = var.agent_id
+ name = "GIT_AUTHOR_NAME"
+ value = coalesce(try(data.coder_parameter.username[0].value, ""), data.coder_workspace.me.owner_name, data.coder_workspace.me.owner)
+}
+
+resource "coder_env" "git_commmiter_name" {
+ agent_id = var.agent_id
+ name = "GIT_COMMITTER_NAME"
+ value = coalesce(try(data.coder_parameter.username[0].value, ""), data.coder_workspace.me.owner_name, data.coder_workspace.me.owner)
+}
+
+resource "coder_env" "git_author_email" {
+ agent_id = var.agent_id
+ name = "GIT_AUTHOR_EMAIL"
+ value = coalesce(try(data.coder_parameter.user_email[0].value, ""), data.coder_workspace.me.owner_email)
+ count = data.coder_workspace.me.owner_email != "" ? 1 : 0
+}
+
+resource "coder_env" "git_commmiter_email" {
+ agent_id = var.agent_id
+ name = "GIT_COMMITTER_EMAIL"
+ value = coalesce(try(data.coder_parameter.user_email[0].value, ""), data.coder_workspace.me.owner_email)
+ count = data.coder_workspace.me.owner_email != "" ? 1 : 0
+}
diff --git a/github-upload-public-key/README.md b/github-upload-public-key/README.md
new file mode 100644
index 0000000..2e1938a
--- /dev/null
+++ b/github-upload-public-key/README.md
@@ -0,0 +1,53 @@
+---
+display_name: Github Upload Public Key
+description: Automates uploading Coder public key to Github so users don't have to.
+icon: ../.icons/github.svg
+maintainer_github: coder
+verified: true
+tags: [helper, git]
+---
+
+# github-upload-public-key
+
+Templates that utilize Github External Auth can automatically ensure that the Coder public key is uploaded to Github so that users can clone repositories without needing to upload the public key themselves.
+
+```tf
+module "github-upload-public-key" {
+ source = "registry.coder.com/modules/github-upload-public-key/coder"
+ version = "1.0.14"
+ agent_id = coder_agent.example.id
+}
+```
+
+# Requirements
+
+This module requires `curl` and `jq` to be installed inside your workspace.
+
+Github External Auth must be enabled in the workspace for this module to work. The Github app that is configured for external auth must have both read and write permissions to "Git SSH keys" in order to upload the public key. Additionally, a Coder admin must also have the `admin:public_key` scope added to the external auth configuration of the Coder deployment. For example:
+
+```
+CODER_EXTERNAL_AUTH_0_ID="USER_DEFINED_ID"
+CODER_EXTERNAL_AUTH_0_TYPE=github
+CODER_EXTERNAL_AUTH_0_CLIENT_ID=xxxxxx
+CODER_EXTERNAL_AUTH_0_CLIENT_SECRET=xxxxxxx
+CODER_EXTERNAL_AUTH_0_SCOPES="repo,workflow,admin:public_key"
+```
+
+Note that the default scopes if not provided are `repo,workflow`. If the module is failing to complete after updating the external auth configuration, instruct users of the module to "Unlink" and "Link" their Github account in the External Auth user settings page to get the new scopes.
+
+# Example
+
+Using a coder github external auth with a non-default id: (default is `github`)
+
+```tf
+data "coder_external_auth" "github" {
+ id = "myauthid"
+}
+
+module "github-upload-public-key" {
+ source = "registry.coder.com/modules/github-upload-public-key/coder"
+ version = "1.0.14"
+ agent_id = coder_agent.example.id
+ external_auth_id = data.coder_external_auth.github.id
+}
+```
diff --git a/github-upload-public-key/main.test.ts b/github-upload-public-key/main.test.ts
new file mode 100644
index 0000000..fb1b977
--- /dev/null
+++ b/github-upload-public-key/main.test.ts
@@ -0,0 +1,128 @@
+import { describe, expect, it } from "bun:test";
+import {
+ createJSONResponse,
+ execContainer,
+ findResourceInstance,
+ runContainer,
+ runTerraformApply,
+ runTerraformInit,
+ testRequiredVariables,
+ writeCoder,
+} from "../test";
+import { Server, serve } from "bun";
+
+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");
+ let exec = await execContainer(id, [
+ "env",
+ "CODER_ACCESS_URL=" + server.url.toString().slice(0, -1),
+ "GITHUB_API_URL=" + server.url.toString().slice(0, -1),
+ "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");
+ let exec = await execContainer(id, [
+ "env",
+ "CODER_ACCESS_URL=" + server.url.toString().slice(0, -1),
+ "GITHUB_API_URL=" + server.url.toString().slice(0, -1),
+ "CODER_OWNER_SESSION_TOKEN=foo",
+ "CODER_EXTERNAL_AUTH_ID=github",
+ "bash",
+ "-c",
+ instance.script,
+ ]);
+ expect(exec.stdout).toContain(
+ "Your Coder public key is already on GitHub!",
+ );
+ expect(exec.exitCode).toBe(0);
+ });
+});
+
+const setupContainer = async (
+ image = "lorello/alpine-bash",
+ vars: Record = {},
+) => {
+ const server = await setupServer();
+ const state = await runTerraformApply(import.meta.dir, {
+ agent_id: "foo",
+ ...vars,
+ });
+ const instance = findResourceInstance(state, "coder_script");
+ const id = await runContainer(image);
+ return { id, instance, server };
+};
+
+const setupServer = async (): Promise => {
+ let url: URL;
+ const fakeSlackHost = serve({
+ fetch: (req) => {
+ url = new URL(req.url);
+ if (url.pathname === "/api/v2/users/me/gitsshkey") {
+ return createJSONResponse({
+ public_key: "exists",
+ });
+ }
+
+ if (url.pathname === "/user/keys") {
+ if (req.method === "POST") {
+ return createJSONResponse(
+ {
+ key: "created",
+ },
+ 201,
+ );
+ }
+
+ // case: key already exists
+ if (req.headers.get("Authorization") == "Bearer findkey") {
+ return createJSONResponse([
+ {
+ key: "foo",
+ },
+ {
+ key: "exists",
+ },
+ ]);
+ }
+
+ // case: key does not exist
+ return createJSONResponse([
+ {
+ key: "foo",
+ },
+ ]);
+ }
+
+ return createJSONResponse(
+ {
+ error: "not_found",
+ },
+ 404,
+ );
+ },
+ port: 0,
+ });
+
+ return fakeSlackHost;
+};
diff --git a/github-upload-public-key/main.tf b/github-upload-public-key/main.tf
new file mode 100644
index 0000000..b35d246
--- /dev/null
+++ b/github-upload-public-key/main.tf
@@ -0,0 +1,42 @@
+terraform {
+ required_version = ">= 1.0"
+
+ required_providers {
+ coder = {
+ source = "coder/coder"
+ version = ">= 0.12"
+ }
+ }
+}
+
+variable "agent_id" {
+ type = string
+ description = "The ID of a Coder agent."
+}
+
+variable "external_auth_id" {
+ type = string
+ description = "The ID of the GitHub external auth."
+ default = "github"
+}
+
+variable "github_api_url" {
+ type = string
+ description = "The URL of the GitHub instance."
+ default = "https://api.github.com"
+}
+
+data "coder_workspace" "me" {}
+
+resource "coder_script" "github_upload_public_key" {
+ agent_id = var.agent_id
+ script = templatefile("${path.module}/run.sh", {
+ CODER_OWNER_SESSION_TOKEN : data.coder_workspace.me.owner_session_token,
+ CODER_ACCESS_URL : data.coder_workspace.me.access_url,
+ CODER_EXTERNAL_AUTH_ID : var.external_auth_id,
+ GITHUB_API_URL : var.github_api_url,
+ })
+ display_name = "Github Upload Public Key"
+ icon = "/icon/github.svg"
+ run_on_start = true
+}
\ No newline at end of file
diff --git a/github-upload-public-key/run.sh b/github-upload-public-key/run.sh
new file mode 100755
index 0000000..a382a40
--- /dev/null
+++ b/github-upload-public-key/run.sh
@@ -0,0 +1,110 @@
+#!/usr/bin/env bash
+
+if [ -z "$CODER_ACCESS_URL" ]; then
+ if [ -z "${CODER_ACCESS_URL}" ]; then
+ echo "CODER_ACCESS_URL is empty!"
+ exit 1
+ fi
+ CODER_ACCESS_URL=${CODER_ACCESS_URL}
+fi
+
+if [ -z "$CODER_OWNER_SESSION_TOKEN" ]; then
+ if [ -z "${CODER_OWNER_SESSION_TOKEN}" ]; then
+ echo "CODER_OWNER_SESSION_TOKEN is empty!"
+ exit 1
+ fi
+ CODER_OWNER_SESSION_TOKEN=${CODER_OWNER_SESSION_TOKEN}
+fi
+
+if [ -z "$CODER_EXTERNAL_AUTH_ID" ]; then
+ if [ -z "${CODER_EXTERNAL_AUTH_ID}" ]; then
+ echo "CODER_EXTERNAL_AUTH_ID is empty!"
+ exit 1
+ fi
+ CODER_EXTERNAL_AUTH_ID=${CODER_EXTERNAL_AUTH_ID}
+fi
+
+if [ -z "$GITHUB_API_URL" ]; then
+ if [ -z "${GITHUB_API_URL}" ]; then
+ echo "GITHUB_API_URL is empty!"
+ exit 1
+ fi
+ GITHUB_API_URL=${GITHUB_API_URL}
+fi
+
+echo "Fetching GitHub token..."
+GITHUB_TOKEN=$(coder external-auth access-token $CODER_EXTERNAL_AUTH_ID)
+if [ $? -ne 0 ]; then
+ printf "Authenticate with Github to automatically upload Coder public key:\n$GITHUB_TOKEN\n"
+ exit 1
+fi
+
+echo "Fetching public key from Coder..."
+PUBLIC_KEY_RESPONSE=$(
+ curl -L -s \
+ -w "\n%%{http_code}" \
+ -H 'accept: application/json' \
+ -H "cookie: coder_session_token=$CODER_OWNER_SESSION_TOKEN" \
+ "$CODER_ACCESS_URL/api/v2/users/me/gitsshkey"
+)
+PUBLIC_KEY_RESPONSE_STATUS=$(tail -n1 <<< "$PUBLIC_KEY_RESPONSE")
+PUBLIC_KEY_BODY=$(sed \$d <<< "$PUBLIC_KEY_RESPONSE")
+
+if [ "$PUBLIC_KEY_RESPONSE_STATUS" -ne 200 ]; then
+ echo "Failed to fetch Coder public SSH key with status code $PUBLIC_KEY_RESPONSE_STATUS!"
+ echo "$PUBLIC_KEY_BODY"
+ exit 1
+fi
+PUBLIC_KEY=$(jq -r '.public_key' <<< "$PUBLIC_KEY_BODY")
+if [ -z "$PUBLIC_KEY" ]; then
+ echo "No Coder public SSH key found!"
+ exit 1
+fi
+
+echo "Fetching public keys from GitHub..."
+GITHUB_KEYS_RESPONSE=$(
+ curl -L -s \
+ -w "\n%%{http_code}" \
+ -H "Accept: application/vnd.github+json" \
+ -H "Authorization: Bearer $GITHUB_TOKEN" \
+ -H "X-GitHub-Api-Version: 2022-11-28" \
+ $GITHUB_API_URL/user/keys
+)
+GITHUB_KEYS_RESPONSE_STATUS=$(tail -n1 <<< "$GITHUB_KEYS_RESPONSE")
+GITHUB_KEYS_RESPONSE_BODY=$(sed \$d <<< "$GITHUB_KEYS_RESPONSE")
+
+if [ "$GITHUB_KEYS_RESPONSE_STATUS" -ne 200 ]; then
+ echo "Failed to fetch Coder public SSH key with status code $GITHUB_KEYS_RESPONSE_STATUS!"
+ echo "$GITHUB_KEYS_RESPONSE_BODY"
+ exit 1
+fi
+
+GITHUB_MATCH=$(jq -r --arg PUBLIC_KEY "$PUBLIC_KEY" '.[] | select(.key == $PUBLIC_KEY) | .key' <<< "$GITHUB_KEYS_RESPONSE_BODY")
+
+if [ "$PUBLIC_KEY" = "$GITHUB_MATCH" ]; then
+ echo "Your Coder public key is already on GitHub!"
+ exit 0
+fi
+
+echo "Your Coder public key is not in GitHub. Adding it now..."
+CODER_PUBLIC_KEY_NAME="$CODER_ACCESS_URL Workspaces"
+UPLOAD_RESPONSE=$(
+ curl -L -s \
+ -X POST \
+ -w "\n%%{http_code}" \
+ -H "Accept: application/vnd.github+json" \
+ -H "Authorization: Bearer $GITHUB_TOKEN" \
+ -H "X-GitHub-Api-Version: 2022-11-28" \
+ $GITHUB_API_URL/user/keys \
+ -d "{\"title\":\"$CODER_PUBLIC_KEY_NAME\",\"key\":\"$PUBLIC_KEY\"}"
+)
+UPLOAD_RESPONSE_STATUS=$(tail -n1 <<< "$UPLOAD_RESPONSE")
+UPLOAD_RESPONSE_BODY=$(sed \$d <<< "$UPLOAD_RESPONSE")
+
+if [ "$UPLOAD_RESPONSE_STATUS" -ne 201 ]; then
+ echo "Failed to upload Coder public SSH key with status code $UPLOAD_RESPONSE_STATUS!"
+ echo "$UPLOAD_RESPONSE_BODY"
+ exit 1
+fi
+
+echo "Your Coder public key has been added to GitHub!"
diff --git a/hcp-vault-secrets/README.md b/hcp-vault-secrets/README.md
new file mode 100644
index 0000000..fc71230
--- /dev/null
+++ b/hcp-vault-secrets/README.md
@@ -0,0 +1,80 @@
+---
+display_name: "HCP Vault Secrets"
+description: "Fetch secrets from HCP Vault"
+icon: ../.icons/vault.svg
+maintainer_github: coder
+partner_github: hashicorp
+verified: true
+tags: [helper, integration, vault, hashicorp, hvs]
+---
+
+# HCP Vault Secrets
+
+This module lets you fetch all or selective secrets from a [HCP Vault Secrets](https://developer.hashicorp.com/hcp/docs/vault-secrets) app into your [Coder](https://coder.com) workspaces. It makes use of the [`hcp_vault_secrets_app`](https://registry.terraform.io/providers/hashicorp/hcp/latest/docs/data-sources/vault_secrets_app) data source from the [HCP provider](https://registry.terraform.io/providers/hashicorp/hcp/latest).
+
+```tf
+module "vault" {
+ source = "registry.coder.com/modules/hcp-vault-secrets/coder"
+ version = "1.0.7"
+ agent_id = coder_agent.example.id
+ app_name = "demo-app"
+ project_id = "aaa-bbb-ccc"
+}
+```
+
+## Configuration
+
+To configure the HCP Vault Secrets module, follow these steps,
+
+1. [Create secrets in HCP Vault Secrets](https://developer.hashicorp.com/vault/tutorials/hcp-vault-secrets-get-started/hcp-vault-secrets-create-secret)
+2. Create an HCP Service Principal from the HCP Vault Secrets app in the HCP console. This will give you the `HCP_CLIENT_ID` and `HCP_CLIENT_SECRET` that you need to authenticate with HCP Vault Secrets.
+ 
+3. Set `HCP_CLIENT_ID` and `HCP_CLIENT_SECRET` variables on the coder provisioner (recommended) or supply them as input to the module.
+4. Set the `project_id`. This is the ID of the project where the HCP Vault Secrets app is running.
+
+> See the [HCP Vault Secrets documentation](https://developer.hashicorp.com/hcp/docs/vault-secrets) for more information.
+
+## Fetch All Secrets
+
+To fetch all secrets from the HCP Vault Secrets app, skip the `secrets` input.
+
+```tf
+module "vault" {
+ source = "registry.coder.com/modules/hcp-vault-secrets/coder"
+ version = "1.0.7"
+ agent_id = coder_agent.example.id
+ app_name = "demo-app"
+ project_id = "aaa-bbb-ccc"
+}
+```
+
+## Fetch Selective Secrets
+
+To fetch selective secrets from the HCP Vault Secrets app, set the `secrets` input.
+
+```tf
+module "vault" {
+ source = "registry.coder.com/modules/hcp-vault-secrets/coder"
+ version = "1.0.7"
+ agent_id = coder_agent.example.id
+ app_name = "demo-app"
+ project_id = "aaa-bbb-ccc"
+ secrets = ["MY_SECRET_1", "MY_SECRET_2"]
+}
+```
+
+## Set Client ID and Client Secret as Inputs
+
+Set `client_id` and `client_secret` as module inputs.
+
+```tf
+module "vault" {
+ source = "registry.coder.com/modules/hcp-vault-secrets/coder"
+ version = "1.0.7"
+ agent_id = coder_agent.example.id
+ app_name = "demo-app"
+ project_id = "aaa-bbb-ccc"
+ client_id = "HCP_CLIENT_ID"
+ client_secret = "HCP_CLIENT_SECRET"
+}
+```
diff --git a/hcp-vault-secrets/main.tf b/hcp-vault-secrets/main.tf
new file mode 100644
index 0000000..9a5e94b
--- /dev/null
+++ b/hcp-vault-secrets/main.tf
@@ -0,0 +1,73 @@
+terraform {
+ required_version = ">= 1.0"
+
+ required_providers {
+ coder = {
+ source = "coder/coder"
+ version = ">= 0.12.4"
+ }
+ hcp = {
+ source = "hashicorp/hcp"
+ version = ">= 0.82.0"
+ }
+ }
+}
+
+provider "hcp" {
+ client_id = var.client_id
+ client_secret = var.client_secret
+ project_id = var.project_id
+}
+
+provider "coder" {}
+
+variable "agent_id" {
+ type = string
+ description = "The ID of a Coder agent."
+}
+
+variable "project_id" {
+ type = string
+ description = "The ID of the HCP project."
+}
+
+variable "client_id" {
+ type = string
+ description = <<-EOF
+ The client ID for the HCP Vault Secrets service principal. (Optional if HCP_CLIENT_ID is set as an environment variable.)
+ EOF
+ default = null
+ sensitive = true
+}
+
+variable "client_secret" {
+ type = string
+ description = <<-EOF
+ The client secret for the HCP Vault Secrets service principal. (Optional if HCP_CLIENT_SECRET is set as an environment variable.)
+ EOF
+ default = null
+ sensitive = true
+}
+
+variable "app_name" {
+ type = string
+ description = "The name of the secrets app in HCP Vault Secrets"
+}
+
+variable "secrets" {
+ type = list(string)
+ description = "The names of the secrets to retrieve from HCP Vault Secrets"
+ default = null
+}
+
+data "hcp_vault_secrets_app" "secrets" {
+ app_name = var.app_name
+}
+
+resource "coder_env" "hvs_secrets" {
+ # https://support.hashicorp.com/hc/en-us/articles/4538432032787-Variable-has-a-sensitive-value-and-cannot-be-used-as-for-each-arguments
+ for_each = var.secrets != null ? toset(var.secrets) : nonsensitive(toset(keys(data.hcp_vault_secrets_app.secrets.secrets)))
+ agent_id = var.agent_id
+ name = each.key
+ value = data.hcp_vault_secrets_app.secrets.secrets[each.key]
+}
\ No newline at end of file
diff --git a/jetbrains-gateway/README.md b/jetbrains-gateway/README.md
index 3b67d22..b2c0e0f 100644
--- a/jetbrains-gateway/README.md
+++ b/jetbrains-gateway/README.md
@@ -4,38 +4,83 @@ description: Add a one-click button to launch JetBrains Gateway IDEs in the dash
icon: ../.icons/gateway.svg
maintainer_github: coder
verified: true
-tags: [ide, jetbrains, gateway, goland, webstorm, intellij, pycharm, phpstorm, clion, rubymine, datagrip, rider]
+tags: [ide, jetbrains, helper, parameter]
---
+
# JetBrains Gateway
-This module adds a JetBrains Gateway IDEs to your Coder template.
+This module adds a JetBrains Gateway Button to open any workspace with a single click.
+
+```tf
+module "jetbrains_gateway" {
+ source = "registry.coder.com/modules/jetbrains-gateway/coder"
+ version = "1.0.13"
+ agent_id = coder_agent.example.id
+ agent_name = "example"
+ folder = "/home/coder/example"
+ jetbrains_ides = ["CL", "GO", "IU", "PY", "WS"]
+ default = "GO"
+}
+```
+
+
+
+## Examples
+
+### Add GoLand and WebStorm as options with the default set to GoLand
-## How to use this module
+```tf
+module "jetbrains_gateway" {
+ source = "registry.coder.com/modules/jetbrains-gateway/coder"
+ version = "1.0.13"
+ agent_id = coder_agent.example.id
+ agent_name = "example"
+ folder = "/home/coder/example"
+ jetbrains_ides = ["GO", "WS"]
+ default = "GO"
+}
+```
+
+### Use the latest release version
+
+```tf
+module "jetbrains_gateway" {
+ source = "registry.coder.com/modules/jetbrains-gateway/coder"
+ version = "1.0.13"
+ agent_id = coder_agent.example.id
+ agent_name = "example"
+ folder = "/home/coder/example"
+ jetbrains_ides = ["GO", "WS"]
+ default = "GO"
+ latest = true
+}
+```
-To use this module, add the following snippet to your template manifest:
+### Use the latest EAP version
-```hcl
+```tf
module "jetbrains_gateway" {
- source = "https://registry.coder.com/modules/jetbrains-gateway"
- agent_id = coder_agent.main.id
- agent_name = "main"
- project_directory = "/home/coder/project"
- gateway_ide_product_code = ["GO","WS"] # A list of JetBrains product codes use ["ALL"] for all products
+ source = "registry.coder.com/modules/jetbrains-gateway/coder"
+ version = "1.0.13"
+ agent_id = coder_agent.example.id
+ agent_name = "example"
+ folder = "/home/coder/example"
+ jetbrains_ides = ["GO", "WS"]
+ default = "GO"
+ latest = true
+ channel = "eap"
}
```
## Supported IDEs
-The following JetBrains IDEs are supported:
+This module and JetBrains Gateway support the following JetBrains IDEs:
- GoLand (`GO`)
- WebStorm (`WS`)
- IntelliJ IDEA Ultimate (`IU`)
-- IntelliJ IDEA Community (`IC`)
- PyCharm Professional (`PY`)
-- PyCharm Community (`PC`)
- PhpStorm (`PS`)
- CLion (`CL`)
- RubyMine (`RM`)
-- DataGrip (`DB`)
- Rider (`RD`)
diff --git a/jetbrains-gateway/main.test.ts b/jetbrains-gateway/main.test.ts
new file mode 100644
index 0000000..b327e41
--- /dev/null
+++ b/jetbrains-gateway/main.test.ts
@@ -0,0 +1,26 @@
+import { it, expect, describe } from "bun:test";
+import {
+ runTerraformInit,
+ testRequiredVariables,
+ runTerraformApply,
+} from "../test";
+
+describe("jetbrains-gateway", async () => {
+ await runTerraformInit(import.meta.dir);
+
+ await testRequiredVariables(import.meta.dir, {
+ agent_id: "foo",
+ agent_name: "foo",
+ folder: "/home/foo",
+ });
+
+ it("default to first ide", async () => {
+ const state = await runTerraformApply(import.meta.dir, {
+ agent_id: "foo",
+ agent_name: "foo",
+ folder: "/home/foo",
+ jetbrains_ides: '["IU", "GO", "PY"]',
+ });
+ expect(state.outputs.identifier.value).toBe("IU");
+ });
+});
diff --git a/jetbrains-gateway/main.tf b/jetbrains-gateway/main.tf
index 993ea41..c96098c 100644
--- a/jetbrains-gateway/main.tf
+++ b/jetbrains-gateway/main.tf
@@ -4,7 +4,11 @@ terraform {
required_providers {
coder = {
source = "coder/coder"
- version = ">= 0.11"
+ version = ">= 0.17"
+ }
+ http = {
+ source = "hashicorp/http"
+ version = ">= 3.0"
}
}
}
@@ -16,103 +20,224 @@ variable "agent_id" {
variable "agent_name" {
type = string
- description = "The name of a Coder agent."
+ description = "Agent name."
}
-variable "project_directory" {
+variable "folder" {
type = string
description = "The directory to open in the IDE. e.g. /home/coder/project"
+ validation {
+ condition = can(regex("^(?:/[^/]+)+$", var.folder))
+ error_message = "The folder must be a full path and must not start with a ~."
+ }
+}
+
+variable "default" {
+ default = ""
+ type = string
+ description = "Default IDE"
+}
+
+variable "order" {
+ type = number
+ description = "The order determines the position of app in the UI presentation. The lowest order is shown first and apps with equal order are sorted by name (ascending order)."
+ default = null
+}
+
+variable "coder_parameter_order" {
+ type = number
+ description = "The order determines the position of a template parameter in the UI/CLI presentation. The lowest order is shown first and parameters with equal order are sorted by name (ascending order)."
+ default = null
+}
+
+variable "latest" {
+ type = bool
+ description = "Whether to fetch the latest version of the IDE."
+ default = false
+}
+
+variable "channel" {
+ type = string
+ description = "JetBrains IDE release channel. Valid values are release and eap."
+ default = "release"
+ validation {
+ condition = can(regex("^(release|eap)$", var.channel))
+ error_message = "The channel must be either release or eap."
+ }
+}
+
+variable "jetbrains_ide_versions" {
+ type = map(object({
+ build_number = string
+ version = string
+ }))
+ description = "The set of versions for each jetbrains IDE"
+ default = {
+ "IU" = {
+ build_number = "241.14494.240"
+ version = "2024.1"
+ }
+ "PS" = {
+ build_number = "241.14494.237"
+ version = "2024.1"
+ }
+ "WS" = {
+ build_number = "241.14494.235"
+ version = "2024.1"
+ }
+ "PY" = {
+ build_number = "241.14494.241"
+ version = "2024.1"
+ }
+ "CL" = {
+ build_number = "241.14494.288"
+ version = "2024.1"
+ }
+ "GO" = {
+ build_number = "241.14494.238"
+ version = "2024.1"
+ }
+ "RM" = {
+ build_number = "241.14494.234"
+ version = "2024.1"
+ }
+ "RD" = {
+ build_number = "241.14494.307"
+ version = "2024.1"
+ }
+ }
+ validation {
+ condition = (
+ alltrue([
+ for code in keys(var.jetbrains_ide_versions) : contains(["IU", "PS", "WS", "PY", "CL", "GO", "RM", "RD"], code)
+ ])
+ )
+ error_message = "The jetbrains_ide_versions must contain a map of valid product codes. Valid product codes are ${join(",", ["IU", "PS", "WS", "PY", "CL", "GO", "RM", "RD"])}."
+ }
}
-variable "gateway_ide_product_code" {
+variable "jetbrains_ides" {
type = list(string)
- description = "The list of IDE product codes, e.g. ['GO', 'WS'] or ['ALL']"
- default = ["ALL"]
+ description = "The list of IDE product codes."
+ default = ["IU", "PS", "WS", "PY", "CL", "GO", "RM", "RD"]
validation {
condition = (
- length(var.gateway_ide_product_code) == 1 && var.gateway_ide_product_code[0] == "ALL" ||
alltrue([
- for code in var.gateway_ide_product_code : contains(["IU", "IC", "PS", "WS", "PY", "PC", "CL", "GO", "DB", "RD", "RM"], code)
+ for code in var.jetbrains_ides : contains(["IU", "PS", "WS", "PY", "CL", "GO", "RM", "RD"], code)
])
)
- error_message = "The gateway_ide_product_code must be ['ALL'] or a list of valid product codes. https://plugins.jetbrains.com/docs/marketplace/product-codes.html"
+ error_message = "The jetbrains_ides must be a list of valid product codes. Valid product codes are ${join(",", ["IU", "PS", "WS", "PY", "CL", "GO", "RM", "RD"])}."
+ }
+ # check if the list is empty
+ validation {
+ condition = length(var.jetbrains_ides) > 0
+ error_message = "The jetbrains_ides must not be empty."
+ }
+ # check if the list contains duplicates
+ validation {
+ condition = length(var.jetbrains_ides) == length(toset(var.jetbrains_ides))
+ error_message = "The jetbrains_ides must not contain duplicates."
}
}
+data "http" "jetbrains_ide_versions" {
+ for_each = var.latest ? toset(var.jetbrains_ides) : toset([])
+ url = "https://data.services.jetbrains.com/products/releases?code=${each.key}&latest=true&type=${var.channel}"
+}
+
locals {
- gateway_ides = {
+ jetbrains_ides = {
"GO" = {
- icon = "/icon/goland.svg",
- name = "GoLand",
- value = jsonencode(["GO", "232.9921.53", "https://download.jetbrains.com/go/goland-2023.2.2.tar.gz"])
+ icon = "/icon/goland.svg",
+ name = "GoLand",
+ identifier = "GO",
+ build_number = var.jetbrains_ide_versions["GO"].build_number,
+ download_link = "https://download.jetbrains.com/go/goland-${var.jetbrains_ide_versions["GO"].version}.tar.gz"
+ version = var.jetbrains_ide_versions["GO"].version
},
"WS" = {
- icon = "/icon/webstorm.svg",
- name = "WebStorm",
- value = jsonencode(["WS", "232.9921.42", "https://download.jetbrains.com/webstorm/WebStorm-2023.2.2.tar.gz"])
+ icon = "/icon/webstorm.svg",
+ name = "WebStorm",
+ identifier = "WS",
+ build_number = var.jetbrains_ide_versions["WS"].build_number,
+ download_link = "https://download.jetbrains.com/webstorm/WebStorm-${var.jetbrains_ide_versions["WS"].version}.tar.gz"
+ version = var.jetbrains_ide_versions["WS"].version
},
"IU" = {
- icon = "/icon/intellij.svg",
- name = "IntelliJ IDEA Ultimate",
- value = jsonencode(["IU", "232.9921.47", "https://download.jetbrains.com/idea/ideaIU-2023.2.2.tar.gz"])
- },
- "IC" = {
- icon = "/icon/intellij.svg",
- name = "IntelliJ IDEA Community",
- value = jsonencode(["IC", "232.9921.47", "https://download.jetbrains.com/idea/ideaIC-2023.2.2.tar.gz"])
+ icon = "/icon/intellij.svg",
+ name = "IntelliJ IDEA Ultimate",
+ identifier = "IU",
+ build_number = var.jetbrains_ide_versions["IU"].build_number,
+ download_link = "https://download.jetbrains.com/idea/ideaIU-${var.jetbrains_ide_versions["IU"].version}.tar.gz"
+ version = var.jetbrains_ide_versions["IU"].version
},
"PY" = {
- icon = "/icon/pycharm.svg",
- name = "PyCharm Professional",
- value = jsonencode(["PY", "232.9559.58", "https://download.jetbrains.com/python/pycharm-professional-2023.2.1.tar.gz"])
+ icon = "/icon/pycharm.svg",
+ name = "PyCharm Professional",
+ identifier = "PY",
+ build_number = var.jetbrains_ide_versions["PY"].build_number,
+ download_link = "https://download.jetbrains.com/python/pycharm-professional-${var.jetbrains_ide_versions["PY"].version}.tar.gz"
+ version = var.jetbrains_ide_versions["PY"].version
},
- "PC" = {
- icon = "/icon/pycharm.svg",
- name = "PyCharm Community",
- value = jsonencode(["PC", "232.9559.58", "https://download.jetbrains.com/python/pycharm-community-2023.2.1.tar.gz"])
- },
- "RD" = {
- icon = "/icon/rider.svg",
- name = "Rider",
- value = jsonencode(["RD", "232.9559.61", "https://download.jetbrains.com/rider/JetBrains.Rider-2023.2.1.tar.gz"])
- }
"CL" = {
- icon = "/icon/clion.svg",
- name = "CLion",
- value = jsonencode(["CL", "232.9921.42", "https://download.jetbrains.com/cpp/CLion-2023.2.2.tar.gz"])
- },
- "DB" = {
- icon = "/icon/datagrip.svg",
- name = "DataGrip",
- value = jsonencode(["DB", "232.9559.28", "https://download.jetbrains.com/datagrip/datagrip-2023.2.1.tar.gz"])
+ icon = "/icon/clion.svg",
+ name = "CLion",
+ identifier = "CL",
+ build_number = var.jetbrains_ide_versions["CL"].build_number,
+ download_link = "https://download.jetbrains.com/cpp/CLion-${var.jetbrains_ide_versions["CL"].version}.tar.gz"
+ version = var.jetbrains_ide_versions["CL"].version
},
"PS" = {
- icon = "/icon/phpstorm.svg",
- name = "PhpStorm",
- value = jsonencode(["PS", "232.9559.64", "https://download.jetbrains.com/webide/PhpStorm-2023.2.1.tar.gz"])
+ icon = "/icon/phpstorm.svg",
+ name = "PhpStorm",
+ identifier = "PS",
+ build_number = var.jetbrains_ide_versions["PS"].build_number,
+ download_link = "https://download.jetbrains.com/webide/PhpStorm-${var.jetbrains_ide_versions["PS"].version}.tar.gz"
+ version = var.jetbrains_ide_versions["PS"].version
},
"RM" = {
- icon = "/icon/rubymine.svg",
- name = "RubyMine",
- value = jsonencode(["RM", "232.9921.48", "https://download.jetbrains.com/ruby/RubyMine-2023.2.2.tar.gz"])
+ icon = "/icon/rubymine.svg",
+ name = "RubyMine",
+ identifier = "RM",
+ build_number = var.jetbrains_ide_versions["RM"].build_number,
+ download_link = "https://download.jetbrains.com/ruby/RubyMine-${var.jetbrains_ide_versions["RM"].version}.tar.gz"
+ version = var.jetbrains_ide_versions["RM"].version
+ }
+ "RD" = {
+ icon = "/icon/rider.svg",
+ name = "Rider",
+ identifier = "RD",
+ build_number = var.jetbrains_ide_versions["RD"].build_number,
+ download_link = "https://download.jetbrains.com/rider/JetBrains.Rider-${var.jetbrains_ide_versions["RD"].version}.tar.gz"
+ version = var.jetbrains_ide_versions["RD"].version
}
}
+
+ icon = local.jetbrains_ides[data.coder_parameter.jetbrains_ide.value].icon
+ json_data = var.latest ? jsondecode(data.http.jetbrains_ide_versions[data.coder_parameter.jetbrains_ide.value].response_body) : {}
+ key = var.latest ? keys(local.json_data)[0] : ""
+ display_name = local.jetbrains_ides[data.coder_parameter.jetbrains_ide.value].name
+ identifier = data.coder_parameter.jetbrains_ide.value
+ download_link = var.latest ? local.json_data[local.key][0].downloads.linux.link : local.jetbrains_ides[data.coder_parameter.jetbrains_ide.value].download_link
+ build_number = var.latest ? local.json_data[local.key][0].build : local.jetbrains_ides[data.coder_parameter.jetbrains_ide.value].build_number
+ version = var.latest ? local.json_data[local.key][0].version : var.jetbrains_ide_versions[data.coder_parameter.jetbrains_ide.value].version
}
data "coder_parameter" "jetbrains_ide" {
- type = "list(string)"
+ type = "string"
name = "jetbrains_ide"
display_name = "JetBrains IDE"
icon = "/icon/gateway.svg"
mutable = true
- default = local.gateway_ides["GO"].value
+ default = var.default == "" ? var.jetbrains_ides[0] : var.default
+ order = var.coder_parameter_order
dynamic "option" {
- for_each = contains(var.gateway_ide_product_code, "ALL") ? local.gateway_ides : { for key, value in local.gateway_ides : key => value if contains(var.gateway_ide_product_code, key) }
+ for_each = var.jetbrains_ides
content {
- icon = option.value.icon
- name = option.value.name
- value = option.value.value
+ icon = local.jetbrains_ides[option.value].icon
+ name = local.jetbrains_ides[option.value].name
+ value = option.value
}
}
}
@@ -121,9 +246,55 @@ data "coder_workspace" "me" {}
resource "coder_app" "gateway" {
agent_id = var.agent_id
- display_name = data.coder_parameter.jetbrains_ide.option[index(data.coder_parameter.jetbrains_ide.option.*.value, data.coder_parameter.jetbrains_ide.value)].name
slug = "gateway"
- url = "jetbrains-gateway://connect#type=coder&workspace=${data.coder_workspace.me.name}&agent=${var.agent_name}&folder=${var.project_directory}&url=${data.coder_workspace.me.access_url}&token=${data.coder_workspace.me.owner_session_token}&ide_product_code=${jsondecode(data.coder_parameter.jetbrains_ide.value)[0]}&ide_build_number=${jsondecode(data.coder_parameter.jetbrains_ide.value)[1]}&ide_download_link=${jsondecode(data.coder_parameter.jetbrains_ide.value)[2]}"
- icon = data.coder_parameter.jetbrains_ide.option[index(data.coder_parameter.jetbrains_ide.option.*.value, data.coder_parameter.jetbrains_ide.value)].icon
+ display_name = local.display_name
+ icon = local.icon
external = true
+ order = var.order
+ url = join("", [
+ "jetbrains-gateway://connect#type=coder&workspace=",
+ data.coder_workspace.me.name,
+ "&agent=",
+ var.agent_name,
+ "&folder=",
+ var.folder,
+ "&url=",
+ data.coder_workspace.me.access_url,
+ "&token=",
+ "$SESSION_TOKEN",
+ "&ide_product_code=",
+ data.coder_parameter.jetbrains_ide.value,
+ "&ide_build_number=",
+ local.build_number,
+ "&ide_download_link=",
+ local.download_link,
+ ])
+}
+
+output "identifier" {
+ value = local.identifier
+}
+
+output "display_name" {
+ value = local.display_name
+}
+
+output "icon" {
+ value = local.icon
+}
+
+output "download_link" {
+ value = local.download_link
+}
+
+output "build_number" {
+ value = local.build_number
+}
+
+output "version" {
+ value = local.version
+}
+
+output "url" {
+ value = coder_app.gateway.url
}
diff --git a/jfrog-oauth/README.md b/jfrog-oauth/README.md
new file mode 100644
index 0000000..60b3bb7
--- /dev/null
+++ b/jfrog-oauth/README.md
@@ -0,0 +1,103 @@
+---
+display_name: JFrog (OAuth)
+description: Install the JF CLI and authenticate with Artifactory using OAuth.
+icon: ../.icons/jfrog.svg
+maintainer_github: coder
+partner_github: jfrog
+verified: true
+tags: [integration, jfrog]
+---
+
+# JFrog
+
+Install the JF CLI and authenticate package managers with Artifactory using OAuth configured via the Coder [`external-auth`](https://coder.com/docs/v2/latest/admin/external-auth) feature.
+
+
+
+```tf
+module "jfrog" {
+ source = "registry.coder.com/modules/jfrog-oauth/coder"
+ version = "1.0.5"
+ agent_id = coder_agent.example.id
+ jfrog_url = "https://example.jfrog.io"
+ username_field = "username" # If you are using GitHub to login to both Coder and Artifactory, use username_field = "username"
+
+ package_managers = {
+ "npm" : "npm",
+ "go" : "go",
+ "pypi" : "pypi"
+ }
+}
+```
+
+> Note
+> This module does not install `npm`, `go`, `pip`, etc but only configure them. You need to handle the installation of these tools yourself.
+
+## Prerequisites
+
+This module is usable by JFrog self-hosted (on-premises) Artifactory as it requires configuring a custom integration. This integration benefits from Coder's [external-auth](https://coder.com/docs/v2/latest/admin/external-auth) feature and allows each user to authenticate with Artifactory using an OAuth flow and issues user-scoped tokens to each user. For configuration instructions, see this [guide](https://coder.com/docs/v2/latest/guides/artifactory-integration#jfrog-oauth) on the Coder documentation.
+
+## Examples
+
+Configure the Python pip package manager to fetch packages from Artifactory while mapping the Coder email to the Artifactory username.
+
+```tf
+module "jfrog" {
+ source = "registry.coder.com/modules/jfrog-oauth/coder"
+ version = "1.0.5"
+ agent_id = coder_agent.example.id
+ jfrog_url = "https://example.jfrog.io"
+ username_field = "email"
+
+ package_managers = {
+ "pypi" : "pypi"
+ }
+}
+```
+
+You should now be able to install packages from Artifactory using both the `jf pip` and `pip` command.
+
+```shell
+jf pip install requests
+```
+
+```shell
+pip install requests
+```
+
+### Configure code-server with JFrog extension
+
+The [JFrog extension](https://open-vsx.org/extension/JFrog/jfrog-vscode-extension) for VS Code allows you to interact with Artifactory from within the IDE.
+
+```tf
+module "jfrog" {
+ source = "registry.coder.com/modules/jfrog-oauth/coder"
+ version = "1.0.5"
+ agent_id = coder_agent.example.id
+ jfrog_url = "https://example.jfrog.io"
+ username_field = "username" # If you are using GitHub to login to both Coder and Artifactory, use username_field = "username"
+ configure_code_server = true # Add JFrog extension configuration for code-server
+ package_managers = {
+ "npm" : "npm",
+ "go" : "go",
+ "pypi" : "pypi"
+ }
+}
+```
+
+### Using the access token in other terraform resources
+
+JFrog Access token is also available as a terraform output. You can use it in other terraform resources. For example, you can use it to configure an [Artifactory docker registry](https://jfrog.com/help/r/jfrog-artifactory-documentation/docker-registry) with the [docker terraform provider](https://registry.terraform.io/providers/kreuzwerker/docker/latest/docs).
+
+```tf
+provider "docker" {
+ # ...
+ registry_auth {
+ address = "https://example.jfrog.io/artifactory/api/docker/REPO-KEY"
+ username = module.jfrog.username
+ password = module.jfrog.access_token
+ }
+}
+```
+
+> Here `REPO_KEY` is the name of docker repository in Artifactory.
diff --git a/jfrog-oauth/main.test.ts b/jfrog-oauth/main.test.ts
new file mode 100644
index 0000000..3397eeb
--- /dev/null
+++ b/jfrog-oauth/main.test.ts
@@ -0,0 +1,19 @@
+import { serve } from "bun";
+import { describe } from "bun:test";
+import {
+ createJSONResponse,
+ runTerraformInit,
+ testRequiredVariables,
+} from "../test";
+
+describe("jfrog-oauth", async () => {
+ await runTerraformInit(import.meta.dir);
+
+ testRequiredVariables(import.meta.dir, {
+ agent_id: "some-agent-id",
+ jfrog_url: "http://localhost:8081",
+ package_managers: "{}",
+ });
+});
+
+//TODO add more tests
diff --git a/jfrog-oauth/main.tf b/jfrog-oauth/main.tf
new file mode 100644
index 0000000..70fd0e8
--- /dev/null
+++ b/jfrog-oauth/main.tf
@@ -0,0 +1,138 @@
+terraform {
+ required_version = ">= 1.0"
+
+ required_providers {
+ coder = {
+ source = "coder/coder"
+ version = ">= 0.12.4"
+ }
+ }
+}
+
+variable "jfrog_url" {
+ type = string
+ description = "JFrog instance URL. e.g. https://myartifactory.jfrog.io"
+ # ensue the URL is HTTPS or HTTP
+ validation {
+ condition = can(regex("^(https|http)://", var.jfrog_url))
+ error_message = "jfrog_url must be a valid URL starting with either 'https://' or 'http://'"
+ }
+}
+
+variable "jfrog_server_id" {
+ type = string
+ description = "The server ID of the JFrog instance for JFrog CLI configuration"
+ default = "0"
+}
+
+variable "username_field" {
+ type = string
+ description = "The field to use for the artifactory username. i.e. Coder username or email."
+ default = "username"
+ validation {
+ condition = can(regex("^(email|username)$", var.username_field))
+ error_message = "username_field must be either 'email' or 'username'"
+ }
+}
+
+variable "external_auth_id" {
+ type = string
+ description = "JFrog external auth ID. Default: 'jfrog'"
+ default = "jfrog"
+}
+
+variable "agent_id" {
+ type = string
+ description = "The ID of a Coder agent."
+}
+
+variable "configure_code_server" {
+ type = bool
+ description = "Set to true to configure code-server to use JFrog."
+ default = false
+}
+
+variable "package_managers" {
+ type = map(string)
+ description = < /dev/null 2>&1; then
+ echo "✅ JFrog CLI is already installed, skipping installation."
+else
+ echo "📦 Installing JFrog CLI..."
+ curl -fL https://install-cli.jfrog.io | sudo sh
+ sudo chmod 755 /usr/local/bin/jf
+fi
+
+# The jf CLI checks $CI when determining whether to use interactive
+# flows.
+export CI=true
+# Authenticate JFrog CLI with Artifactory.
+echo "${ARTIFACTORY_ACCESS_TOKEN}" | jf c add --access-token-stdin --url "${JFROG_URL}" --overwrite "${JFROG_SERVER_ID}"
+# Set the configured server as the default.
+jf c use "${JFROG_SERVER_ID}"
+
+# Configure npm to use the Artifactory "npm" repository.
+if [ -z "${REPOSITORY_NPM}" ]; then
+ echo "🤔 no npm repository is set, skipping npm configuration."
+ echo "You can configure an npm repository by providing the a key for 'npm' in the 'package_managers' input."
+else
+ echo "📦 Configuring npm..."
+ jf npmc --global --repo-resolve "${REPOSITORY_NPM}"
+ cat << EOF > ~/.npmrc
+email=${ARTIFACTORY_EMAIL}
+registry=${JFROG_URL}/artifactory/api/npm/${REPOSITORY_NPM}
+EOF
+ echo "//${JFROG_HOST}/artifactory/api/npm/${REPOSITORY_NPM}/:_authToken=${ARTIFACTORY_ACCESS_TOKEN}" >> ~/.npmrc
+fi
+
+# Configure the `pip` to use the Artifactory "python" repository.
+if [ -z "${REPOSITORY_PYPI}" ]; then
+ echo "🤔 no pypi repository is set, skipping pip configuration."
+ echo "You can configure a pypi repository by providing the a key for 'pypi' in the 'package_managers' input."
+else
+ echo "📦 Configuring pip..."
+ jf pipc --global --repo-resolve "${REPOSITORY_PYPI}"
+ mkdir -p ~/.pip
+ cat << EOF > ~/.pip/pip.conf
+[global]
+index-url = https://${ARTIFACTORY_USERNAME}:${ARTIFACTORY_ACCESS_TOKEN}@${JFROG_HOST}/artifactory/api/pypi/${REPOSITORY_PYPI}/simple
+EOF
+fi
+
+# Configure Artifactory "go" repository.
+if [ -z "${REPOSITORY_GO}" ]; then
+ echo "🤔 no go repository is set, skipping go configuration."
+ echo "You can configure a go repository by providing the a key for 'go' in the 'package_managers' input."
+else
+ echo "🐹 Configuring go..."
+ jf goc --global --repo-resolve "${REPOSITORY_GO}"
+fi
+echo "🥳 Configuration complete!"
+
+# Configure the JFrog CLI to use the Artifactory "docker" repository.
+if [ -z "${REPOSITORY_DOCKER}" ]; then
+ echo "🤔 no docker repository is set, skipping docker configuration."
+ echo "You can configure a docker repository by providing the a key for 'docker' in the 'package_managers' input."
+else
+ if command -v docker > /dev/null 2>&1; then
+ echo "🔑 Configuring 🐳 docker credentials..."
+ mkdir -p ~/.docker
+ echo -n "${ARTIFACTORY_ACCESS_TOKEN}" | docker login ${JFROG_HOST} --username ${ARTIFACTORY_USERNAME} --password-stdin
+ else
+ echo "🤔 no docker is installed, skipping docker configuration."
+ fi
+fi
+
+# Install the JFrog vscode extension for code-server.
+if [ "${CONFIGURE_CODE_SERVER}" == "true" ]; then
+ while ! [ -x /tmp/code-server/bin/code-server ]; do
+ counter=0
+ if [ $counter -eq 60 ]; then
+ echo "Timed out waiting for /tmp/code-server/bin/code-server to be installed."
+ exit 1
+ fi
+ echo "Waiting for /tmp/code-server/bin/code-server to be installed..."
+ sleep 1
+ ((counter++))
+ done
+ echo "📦 Installing JFrog extension..."
+ /tmp/code-server/bin/code-server --install-extension jfrog.jfrog-vscode-extension
+ echo "🥳 JFrog extension installed!"
+else
+ echo "🤔 Skipping JFrog extension installation. Set configure_code_server to true to install the JFrog extension."
+fi
+
+# Configure the JFrog CLI completion
+echo "📦 Configuring JFrog CLI completion..."
+# Get the user's shell
+SHELLNAME=$(grep "^$USER" /etc/passwd | awk -F':' '{print $7}' | awk -F'/' '{print $NF}')
+# Generate the completion script
+jf completion $SHELLNAME --install
+# Add the completion script to the user's shell profile
+if [ "$SHELLNAME" == "bash" ] && [ -f ~/.bashrc ]; then
+ if ! grep -q "# jf CLI shell completion" ~/.bashrc; then
+ echo "" >> ~/.bashrc
+ echo "# BEGIN: jf CLI shell completion (added by coder module jfrog-oauth)" >> ~/.bashrc
+ echo 'source "$HOME/.jfrog/jfrog_bash_completion"' >> ~/.bashrc
+ echo "# END: jf CLI shell completion" >> ~/.bashrc
+ else
+ echo "🥳 ~/.bashrc already contains jf CLI shell completion configuration, skipping."
+ fi
+elif [ "$SHELLNAME" == "zsh" ] && [ -f ~/.zshrc ]; then
+ if ! grep -q "# jf CLI shell completion" ~/.zshrc; then
+ echo "" >> ~/.zshrc
+ echo "# BEGIN: jf CLI shell completion (added by coder module jfrog-oauth)" >> ~/.zshrc
+ echo "autoload -Uz compinit" >> ~/.zshrc
+ echo "compinit" >> ~/.zshrc
+ echo 'source "$HOME/.jfrog/jfrog_zsh_completion"' >> ~/.zshrc
+ echo "# END: jf CLI shell completion" >> ~/.zshrc
+ else
+ echo "🥳 ~/.zshrc already contains jf CLI shell completion configuration, skipping."
+ fi
+else
+ echo "🤔 ~/.bashrc or ~/.zshrc does not exist, skipping jf CLI shell completion configuration."
+fi
diff --git a/jfrog-token/README.md b/jfrog-token/README.md
new file mode 100644
index 0000000..0bd4781
--- /dev/null
+++ b/jfrog-token/README.md
@@ -0,0 +1,126 @@
+---
+display_name: JFrog (Token)
+description: Install the JF CLI and authenticate with Artifactory using Artifactory terraform provider.
+icon: ../.icons/jfrog.svg
+maintainer_github: coder
+partner_github: jfrog
+verified: true
+tags: [integration, jfrog]
+---
+
+# JFrog
+
+Install the JF CLI and authenticate package managers with Artifactory using Artifactory terraform provider.
+
+```tf
+module "jfrog" {
+ source = "registry.coder.com/modules/jfrog-token/coder"
+ version = "1.0.10"
+ agent_id = coder_agent.example.id
+ jfrog_url = "https://XXXX.jfrog.io"
+ artifactory_access_token = var.artifactory_access_token
+ package_managers = {
+ "npm" : "npm",
+ "go" : "go",
+ "pypi" : "pypi"
+ }
+}
+```
+
+For detailed instructions, please see this [guide](https://coder.com/docs/v2/latest/guides/artifactory-integration#jfrog-token) on the Coder documentation.
+
+> Note
+> This module does not install `npm`, `go`, `pip`, etc but only configure them. You need to handle the installation of these tools yourself.
+
+
+
+## Examples
+
+### Configure npm, go, and pypi to use Artifactory local repositories
+
+```tf
+module "jfrog" {
+ source = "registry.coder.com/modules/jfrog-token/coder"
+ version = "1.0.10"
+ agent_id = coder_agent.example.id
+ jfrog_url = "https://YYYY.jfrog.io"
+ artifactory_access_token = var.artifactory_access_token # An admin access token
+ package_managers = {
+ "npm" : "npm-local",
+ "go" : "go-local",
+ "pypi" : "pypi-local"
+ }
+}
+```
+
+You should now be able to install packages from Artifactory using both the `jf npm`, `jf go`, `jf pip` and `npm`, `go`, `pip` commands.
+
+```shell
+jf npm install prettier
+jf go get github.com/golang/example/hello
+jf pip install requests
+```
+
+```shell
+npm install prettier
+go get github.com/golang/example/hello
+pip install requests
+```
+
+### Configure code-server with JFrog extension
+
+The [JFrog extension](https://open-vsx.org/extension/JFrog/jfrog-vscode-extension) for VS Code allows you to interact with Artifactory from within the IDE.
+
+```tf
+module "jfrog" {
+ source = "registry.coder.com/modules/jfrog-token/coder"
+ version = "1.0.10"
+ agent_id = coder_agent.example.id
+ jfrog_url = "https://XXXX.jfrog.io"
+ artifactory_access_token = var.artifactory_access_token
+ configure_code_server = true # Add JFrog extension configuration for code-server
+ package_managers = {
+ "npm" : "npm",
+ "go" : "go",
+ "pypi" : "pypi"
+ }
+}
+```
+
+### Add a custom token description
+
+```tf
+data "coder_workspace" "me" {}
+
+module "jfrog" {
+ source = "registry.coder.com/modules/jfrog-token/coder"
+ version = "1.0.10"
+ agent_id = coder_agent.example.id
+ jfrog_url = "https://XXXX.jfrog.io"
+ artifactory_access_token = var.artifactory_access_token
+ token_description = "Token for Coder workspace: ${data.coder_workspace.me.owner}/${data.coder_workspace.me.name}"
+ package_managers = {
+ "npm" : "npm",
+ "go" : "go",
+ "pypi" : "pypi"
+ }
+}
+```
+
+### Using the access token in other terraform resources
+
+JFrog Access token is also available as a terraform output. You can use it in other terraform resources. For example, you can use it to configure an [Artifactory docker registry](https://jfrog.com/help/r/jfrog-artifactory-documentation/docker-registry) with the [docker terraform provider](https://registry.terraform.io/providers/kreuzwerker/docker/latest/docs).
+
+```tf
+
+provider "docker" {
+ # ...
+ registry_auth {
+ address = "https://YYYY.jfrog.io/artifactory/api/docker/REPO-KEY"
+ username = module.jfrog.username
+ password = module.jfrog.access_token
+ }
+}
+```
+
+> Here `REPO_KEY` is the name of docker repository in Artifactory.
diff --git a/jfrog-token/main.test.ts b/jfrog-token/main.test.ts
new file mode 100644
index 0000000..b3b8df9
--- /dev/null
+++ b/jfrog-token/main.test.ts
@@ -0,0 +1,41 @@
+import { serve } from "bun";
+import { describe } from "bun:test";
+import {
+ createJSONResponse,
+ runTerraformInit,
+ testRequiredVariables,
+} from "../test";
+
+describe("jfrog-token", async () => {
+ await runTerraformInit(import.meta.dir);
+
+ // Run a fake JFrog server so the provider can initialize
+ // correctly. This saves us from having to make remote requests!
+ const fakeFrogHost = serve({
+ fetch: (req) => {
+ const url = new URL(req.url);
+ // See https://jfrog.com/help/r/jfrog-rest-apis/license-information
+ if (url.pathname === "/artifactory/api/system/license")
+ return createJSONResponse({
+ type: "Commercial",
+ licensedTo: "JFrog inc.",
+ validThrough: "May 15, 2036",
+ });
+ if (url.pathname === "/access/api/v1/tokens")
+ return createJSONResponse({
+ token_id: "xxx",
+ access_token: "xxx",
+ scopes: "any",
+ });
+ return createJSONResponse({});
+ },
+ port: 0,
+ });
+
+ testRequiredVariables(import.meta.dir, {
+ agent_id: "some-agent-id",
+ jfrog_url: "http://" + fakeFrogHost.hostname + ":" + fakeFrogHost.port,
+ artifactory_access_token: "XXXX",
+ package_managers: "{}",
+ });
+});
diff --git a/jfrog-token/main.tf b/jfrog-token/main.tf
new file mode 100644
index 0000000..d1f99f1
--- /dev/null
+++ b/jfrog-token/main.tf
@@ -0,0 +1,178 @@
+terraform {
+ required_version = ">= 1.0"
+
+ required_providers {
+ coder = {
+ source = "coder/coder"
+ version = ">= 0.12.4"
+ }
+ artifactory = {
+ source = "registry.terraform.io/jfrog/artifactory"
+ version = "~> 10.0.2"
+ }
+ }
+}
+
+variable "jfrog_url" {
+ type = string
+ description = "JFrog instance URL. e.g. https://myartifactory.jfrog.io"
+ # ensue the URL is HTTPS or HTTP
+ validation {
+ condition = can(regex("^(https|http)://", var.jfrog_url))
+ error_message = "jfrog_url must be a valid URL starting with either 'https://' or 'http://'"
+ }
+}
+
+variable "jfrog_server_id" {
+ type = string
+ description = "The server ID of the JFrog instance for JFrog CLI configuration"
+ default = "0"
+}
+
+variable "artifactory_access_token" {
+ type = string
+ description = "The admin-level access token to use for JFrog."
+}
+
+variable "token_description" {
+ type = string
+ description = "Free text token description. Useful for filtering and managing tokens."
+ default = "Token for Coder workspace"
+}
+
+variable "check_license" {
+ type = bool
+ description = "Toggle for pre-flight checking of Artifactory license. Default to `true`."
+ default = true
+}
+
+variable "refreshable" {
+ type = bool
+ description = "Is this token refreshable? Default is `false`."
+ default = false
+}
+
+variable "expires_in" {
+ type = number
+ description = "The amount of time, in seconds, it would take for the token to expire."
+ default = null
+}
+
+variable "username_field" {
+ type = string
+ description = "The field to use for the artifactory username. Default `username`."
+ default = "username"
+ validation {
+ condition = can(regex("^(email|username)$", var.username_field))
+ error_message = "username_field must be either 'email' or 'username'"
+ }
+}
+
+variable "agent_id" {
+ type = string
+ description = "The ID of a Coder agent."
+}
+
+variable "configure_code_server" {
+ type = bool
+ description = "Set to true to configure code-server to use JFrog."
+ default = false
+}
+
+variable "package_managers" {
+ type = map(string)
+ description = < 0 ? local.username : "dummy"
+ scopes = ["applied-permissions/user"]
+ refreshable = var.refreshable
+ expires_in = var.expires_in
+ description = var.token_description
+}
+
+data "coder_workspace" "me" {}
+
+resource "coder_script" "jfrog" {
+ agent_id = var.agent_id
+ display_name = "jfrog"
+ icon = "/icon/jfrog.svg"
+ script = templatefile("${path.module}/run.sh", {
+ JFROG_URL : var.jfrog_url,
+ JFROG_HOST : local.jfrog_host,
+ JFROG_SERVER_ID : var.jfrog_server_id,
+ ARTIFACTORY_USERNAME : local.username,
+ ARTIFACTORY_EMAIL : data.coder_workspace.me.owner_email,
+ ARTIFACTORY_ACCESS_TOKEN : artifactory_scoped_token.me.access_token,
+ CONFIGURE_CODE_SERVER : var.configure_code_server,
+ REPOSITORY_NPM : lookup(var.package_managers, "npm", ""),
+ REPOSITORY_GO : lookup(var.package_managers, "go", ""),
+ REPOSITORY_PYPI : lookup(var.package_managers, "pypi", ""),
+ REPOSITORY_DOCKER : lookup(var.package_managers, "docker", ""),
+ })
+ run_on_start = true
+}
+
+resource "coder_env" "jfrog_ide_url" {
+ count = var.configure_code_server ? 1 : 0
+ agent_id = var.agent_id
+ name = "JFROG_IDE_URL"
+ value = var.jfrog_url
+}
+
+resource "coder_env" "jfrog_ide_access_token" {
+ count = var.configure_code_server ? 1 : 0
+ agent_id = var.agent_id
+ name = "JFROG_IDE_ACCESS_TOKEN"
+ value = artifactory_scoped_token.me.access_token
+}
+
+resource "coder_env" "jfrog_ide_store_connection" {
+ count = var.configure_code_server ? 1 : 0
+ agent_id = var.agent_id
+ name = "JFROG_IDE_STORE_CONNECTION"
+ value = true
+}
+
+resource "coder_env" "goproxy" {
+ count = lookup(var.package_managers, "go", "") == "" ? 0 : 1
+ agent_id = var.agent_id
+ name = "GOPROXY"
+ value = "https://${local.username}:${artifactory_scoped_token.me.access_token}@${local.jfrog_host}/artifactory/api/go/${lookup(var.package_managers, "go", "")}"
+}
+
+output "access_token" {
+ description = "value of the JFrog access token"
+ value = artifactory_scoped_token.me.access_token
+ sensitive = true
+}
+
+output "username" {
+ description = "value of the JFrog username"
+ value = local.username
+}
diff --git a/jfrog-token/run.sh b/jfrog-token/run.sh
new file mode 100644
index 0000000..52b3513
--- /dev/null
+++ b/jfrog-token/run.sh
@@ -0,0 +1,122 @@
+#!/usr/bin/env bash
+
+BOLD='\033[0;1m'
+
+# check if JFrog CLI is already installed
+if command -v jf > /dev/null 2>&1; then
+ echo "✅ JFrog CLI is already installed, skipping installation."
+else
+ echo "📦 Installing JFrog CLI..."
+ curl -fL https://install-cli.jfrog.io | sudo sh
+ sudo chmod 755 /usr/local/bin/jf
+fi
+
+# The jf CLI checks $CI when determining whether to use interactive
+# flows.
+export CI=true
+# Authenticate JFrog CLI with Artifactory.
+echo "${ARTIFACTORY_ACCESS_TOKEN}" | jf c add --access-token-stdin --url "${JFROG_URL}" --overwrite "${JFROG_SERVER_ID}"
+# Set the configured server as the default.
+jf c use "${JFROG_SERVER_ID}"
+
+# Configure npm to use the Artifactory "npm" repository.
+if [ -z "${REPOSITORY_NPM}" ]; then
+ echo "🤔 no npm repository is set, skipping npm configuration."
+ echo "You can configure an npm repository by providing the a key for 'npm' in the 'package_managers' input."
+else
+ echo "📦 Configuring npm..."
+ jf npmc --global --repo-resolve "${REPOSITORY_NPM}"
+ cat << EOF > ~/.npmrc
+email=${ARTIFACTORY_EMAIL}
+registry=${JFROG_URL}/artifactory/api/npm/${REPOSITORY_NPM}
+EOF
+ echo "//${JFROG_HOST}/artifactory/api/npm/${REPOSITORY_NPM}/:_authToken=${ARTIFACTORY_ACCESS_TOKEN}" >> ~/.npmrc
+fi
+
+# Configure the `pip` to use the Artifactory "python" repository.
+if [ -z "${REPOSITORY_PYPI}" ]; then
+ echo "🤔 no pypi repository is set, skipping pip configuration."
+ echo "You can configure a pypi repository by providing the a key for 'pypi' in the 'package_managers' input."
+else
+ echo "🐍 Configuring pip..."
+ jf pipc --global --repo-resolve "${REPOSITORY_PYPI}"
+ mkdir -p ~/.pip
+ cat << EOF > ~/.pip/pip.conf
+[global]
+index-url = https://${ARTIFACTORY_USERNAME}:${ARTIFACTORY_ACCESS_TOKEN}@${JFROG_HOST}/artifactory/api/pypi/${REPOSITORY_PYPI}/simple
+EOF
+fi
+
+# Configure Artifactory "go" repository.
+if [ -z "${REPOSITORY_GO}" ]; then
+ echo "🤔 no go repository is set, skipping go configuration."
+ echo "You can configure a go repository by providing the a key for 'go' in the 'package_managers' input."
+else
+ echo "🐹 Configuring go..."
+ jf goc --global --repo-resolve "${REPOSITORY_GO}"
+fi
+echo "🥳 Configuration complete!"
+
+# Configure the JFrog CLI to use the Artifactory "docker" repository.
+if [ -z "${REPOSITORY_DOCKER}" ]; then
+ echo "🤔 no docker repository is set, skipping docker configuration."
+ echo "You can configure a docker repository by providing the a key for 'docker' in the 'package_managers' input."
+else
+ if command -v docker > /dev/null 2>&1; then
+ echo "🔑 Configuring 🐳 docker credentials..."
+ mkdir -p ~/.docker
+ echo -n "${ARTIFACTORY_ACCESS_TOKEN}" | docker login ${JFROG_HOST} --username ${ARTIFACTORY_USERNAME} --password-stdin
+ else
+ echo "🤔 no docker is installed, skipping docker configuration."
+ fi
+fi
+
+# Install the JFrog vscode extension for code-server.
+if [ "${CONFIGURE_CODE_SERVER}" == "true" ]; then
+ while ! [ -x /tmp/code-server/bin/code-server ]; do
+ counter=0
+ if [ $counter -eq 60 ]; then
+ echo "Timed out waiting for /tmp/code-server/bin/code-server to be installed."
+ exit 1
+ fi
+ echo "Waiting for /tmp/code-server/bin/code-server to be installed..."
+ sleep 1
+ ((counter++))
+ done
+ echo "📦 Installing JFrog extension..."
+ /tmp/code-server/bin/code-server --install-extension jfrog.jfrog-vscode-extension
+ echo "🥳 JFrog extension installed!"
+else
+ echo "🤔 Skipping JFrog extension installation. Set configure_code_server to true to install the JFrog extension."
+fi
+
+# Configure the JFrog CLI completion
+echo "📦 Configuring JFrog CLI completion..."
+# Get the user's shell
+SHELLNAME=$(grep "^$USER" /etc/passwd | awk -F':' '{print $7}' | awk -F'/' '{print $NF}')
+# Generate the completion script
+jf completion $SHELLNAME --install
+# Add the completion script to the user's shell profile
+if [ "$SHELLNAME" == "bash" ] && [ -f ~/.bashrc ]; then
+ if ! grep -q "# jf CLI shell completion" ~/.bashrc; then
+ echo "" >> ~/.bashrc
+ echo "# BEGIN: jf CLI shell completion (added by coder module jfrog-token)" >> ~/.bashrc
+ echo 'source "$HOME/.jfrog/jfrog_bash_completion"' >> ~/.bashrc
+ echo "# END: jf CLI shell completion" >> ~/.bashrc
+ else
+ echo "🥳 ~/.bashrc already contains jf CLI shell completion configuration, skipping."
+ fi
+elif [ "$SHELLNAME" == "zsh" ] && [ -f ~/.zshrc ]; then
+ if ! grep -q "# jf CLI shell completion" ~/.zshrc; then
+ echo "" >> ~/.zshrc
+ echo "# BEGIN: jf CLI shell completion (added by coder module jfrog-token)" >> ~/.zshrc
+ echo "autoload -Uz compinit" >> ~/.zshrc
+ echo "compinit" >> ~/.zshrc
+ echo 'source "$HOME/.jfrog/jfrog_zsh_completion"' >> ~/.zshrc
+ echo "# END: jf CLI shell completion" >> ~/.zshrc
+ else
+ echo "🥳 ~/.zshrc already contains jf CLI shell completion configuration, skipping."
+ fi
+else
+ echo "🤔 ~/.bashrc or ~/.zshrc does not exist, skipping jf CLI shell completion configuration."
+fi
diff --git a/jfrog/README.md b/jfrog/README.md
deleted file mode 100644
index 755d5c8..0000000
--- a/jfrog/README.md
+++ /dev/null
@@ -1,13 +0,0 @@
----
-display_name: JFrog
-description: Install the JF CLI and authenticate with Artifactory
-icon: ../.icons/jfrog.svg
-maintainer_github: coder
-partner_github: jfrog
-verified: true
-tags: [integration]
----
-
-# JFrog
-
-TODO
\ No newline at end of file
diff --git a/jupyter-notebook/README.md b/jupyter-notebook/README.md
new file mode 100644
index 0000000..6338f11
--- /dev/null
+++ b/jupyter-notebook/README.md
@@ -0,0 +1,22 @@
+---
+display_name: Jupyter Notebook
+description: A module that adds Jupyter Notebook in your Coder template.
+icon: ../.icons/jupyter.svg
+maintainer_github: coder
+verified: true
+tags: [jupyter, helper, ide, web]
+---
+
+# Jupyter Notebook
+
+A module that adds Jupyter Notebook in your Coder template.
+
+
+
+```tf
+module "jupyter-notebook" {
+ source = "registry.coder.com/modules/jupyter-notebook/coder"
+ version = "1.0.8"
+ agent_id = coder_agent.example.id
+}
+```
diff --git a/jupyter-notebook/main.tf b/jupyter-notebook/main.tf
new file mode 100644
index 0000000..a588ef1
--- /dev/null
+++ b/jupyter-notebook/main.tf
@@ -0,0 +1,65 @@
+terraform {
+ required_version = ">= 1.0"
+
+ required_providers {
+ coder = {
+ source = "coder/coder"
+ version = ">= 0.17"
+ }
+ }
+}
+
+# Add required variables for your modules and remove any unneeded variables
+variable "agent_id" {
+ type = string
+ description = "The ID of a Coder agent."
+}
+
+variable "log_path" {
+ type = string
+ description = "The path to log jupyter notebook to."
+ default = "/tmp/jupyter-notebook.log"
+}
+
+variable "port" {
+ type = number
+ description = "The port to run jupyter-notebook on."
+ default = 19999
+}
+
+variable "share" {
+ type = string
+ default = "owner"
+ validation {
+ condition = var.share == "owner" || var.share == "authenticated" || var.share == "public"
+ error_message = "Incorrect value. Please set either 'owner', 'authenticated', or 'public'."
+ }
+}
+
+variable "order" {
+ type = number
+ description = "The order determines the position of app in the UI presentation. The lowest order is shown first and apps with equal order are sorted by name (ascending order)."
+ default = null
+}
+
+resource "coder_script" "jupyter-notebook" {
+ agent_id = var.agent_id
+ display_name = "jupyter-notebook"
+ icon = "/icon/jupyter.svg"
+ script = templatefile("${path.module}/run.sh", {
+ LOG_PATH : var.log_path,
+ PORT : var.port
+ })
+ run_on_start = true
+}
+
+resource "coder_app" "jupyter-notebook" {
+ agent_id = var.agent_id
+ slug = "jupyter-notebook"
+ display_name = "Jupyter Notebook"
+ url = "http://localhost:${var.port}"
+ icon = "/icon/jupyter.svg"
+ subdomain = true
+ share = var.share
+ order = var.order
+}
diff --git a/jupyter-notebook/run.sh b/jupyter-notebook/run.sh
new file mode 100755
index 0000000..4f8c4a2
--- /dev/null
+++ b/jupyter-notebook/run.sh
@@ -0,0 +1,25 @@
+#!/usr/bin/env sh
+
+BOLD='\033[0;1m'
+
+printf "$${BOLD}Installing jupyter-notebook!\n"
+
+# check if jupyter-notebook is installed
+if ! command -v jupyter-notebook > /dev/null 2>&1; then
+ # install jupyter-notebook
+ # check if python3 pip is installed
+ if ! command -v pip3 > /dev/null 2>&1; then
+ echo "pip3 is not installed"
+ echo "Please install pip3 in your Dockerfile/VM image before running this script"
+ exit 1
+ fi
+ # install jupyter-notebook
+ pip3 install --upgrade --no-cache-dir --no-warn-script-location jupyter
+ echo "🥳 jupyter-notebook has been installed\n\n"
+else
+ echo "🥳 jupyter-notebook is already installed\n\n"
+fi
+
+echo "👷 Starting jupyter-notebook in background..."
+echo "check logs at ${LOG_PATH}"
+$HOME/.local/bin/jupyter notebook --NotebookApp.ip='0.0.0.0' --ServerApp.port=${PORT} --no-browser --ServerApp.token='' --ServerApp.password='' > ${LOG_PATH} 2>&1 &
diff --git a/jupyterhub/README.md b/jupyterhub/README.md
deleted file mode 100644
index ab123ed..0000000
--- a/jupyterhub/README.md
+++ /dev/null
@@ -1,16 +0,0 @@
----
-display_name: JupyterHub
-description: A multi-user version of the notebook designed for companies, classrooms and research labs
-icon: ../.icons/jupyter.svg
-maintainer_github: coder
-verified: true
-tags: [helper, ide]
----
-
-# JupyterHub
-
-Automatically install [JupyterHub](https://jupyter.org/hub) in a workspace, and create an app to access it via the dashboard.
-
-## Examples
-
-TODO
\ No newline at end of file
diff --git a/jupyterlab/README.md b/jupyterlab/README.md
new file mode 100644
index 0000000..3d04cf3
--- /dev/null
+++ b/jupyterlab/README.md
@@ -0,0 +1,22 @@
+---
+display_name: JupyterLab
+description: A module that adds JupyterLab in your Coder template.
+icon: ../.icons/jupyter.svg
+maintainer_github: coder
+verified: true
+tags: [jupyter, helper, ide, web]
+---
+
+# JupyterLab
+
+A module that adds JupyterLab in your Coder template.
+
+
+
+```tf
+module "jupyterlab" {
+ source = "registry.coder.com/modules/jupyterlab/coder"
+ version = "1.0.8"
+ agent_id = coder_agent.example.id
+}
+```
diff --git a/jupyterlab/main.test.ts b/jupyterlab/main.test.ts
new file mode 100644
index 0000000..2597dc2
--- /dev/null
+++ b/jupyterlab/main.test.ts
@@ -0,0 +1,63 @@
+import { describe, expect, it } from "bun:test";
+import {
+ executeScriptInContainer,
+ runTerraformApply,
+ runTerraformInit,
+ testRequiredVariables,
+ findResourceInstance,
+ runContainer,
+ TerraformState,
+ execContainer,
+} from "../test";
+
+// executes the coder script after installing pip
+const executeScriptInContainerWithPip = async (
+ state: TerraformState,
+ image: string,
+ shell: string = "sh",
+): Promise<{
+ exitCode: number;
+ stdout: string[];
+ stderr: string[];
+}> => {
+ const instance = findResourceInstance(state, "coder_script");
+ const id = await runContainer(image);
+ const respPip = await execContainer(id, [shell, "-c", "apk add py3-pip"]);
+ const resp = await execContainer(id, [shell, "-c", instance.script]);
+ const stdout = resp.stdout.trim().split("\n");
+ const stderr = resp.stderr.trim().split("\n");
+ return {
+ exitCode: resp.exitCode,
+ stdout,
+ stderr,
+ };
+};
+
+describe("jupyterlab", async () => {
+ await runTerraformInit(import.meta.dir);
+
+ testRequiredVariables(import.meta.dir, {
+ agent_id: "foo",
+ });
+
+ it("fails without pip3", async () => {
+ const state = await runTerraformApply(import.meta.dir, {
+ agent_id: "foo",
+ });
+ const output = await executeScriptInContainer(state, "alpine");
+ expect(output.exitCode).toBe(1);
+ expect(output.stdout).toEqual([
+ "\u001B[0;1mInstalling jupyterlab!",
+ "pip3 is not installed",
+ "Please install pip3 in your Dockerfile/VM image before running this script",
+ ]);
+ });
+
+ // TODO: Add faster test to run with pip3.
+ // currently times out.
+ // it("runs with pip3", async () => {
+ // ...
+ // const output = await executeScriptInContainerWithPip(state, "alpine");
+ // ...
+ // });
+});
diff --git a/jupyterlab/main.tf b/jupyterlab/main.tf
new file mode 100644
index 0000000..d7928f0
--- /dev/null
+++ b/jupyterlab/main.tf
@@ -0,0 +1,65 @@
+terraform {
+ required_version = ">= 1.0"
+
+ required_providers {
+ coder = {
+ source = "coder/coder"
+ version = ">= 0.17"
+ }
+ }
+}
+
+# Add required variables for your modules and remove any unneeded variables
+variable "agent_id" {
+ type = string
+ description = "The ID of a Coder agent."
+}
+
+variable "log_path" {
+ type = string
+ description = "The path to log jupyterlab to."
+ default = "/tmp/jupyterlab.log"
+}
+
+variable "port" {
+ type = number
+ description = "The port to run jupyterlab on."
+ default = 19999
+}
+
+variable "share" {
+ type = string
+ default = "owner"
+ validation {
+ condition = var.share == "owner" || var.share == "authenticated" || var.share == "public"
+ error_message = "Incorrect value. Please set either 'owner', 'authenticated', or 'public'."
+ }
+}
+
+variable "order" {
+ type = number
+ description = "The order determines the position of app in the UI presentation. The lowest order is shown first and apps with equal order are sorted by name (ascending order)."
+ default = null
+}
+
+resource "coder_script" "jupyterlab" {
+ agent_id = var.agent_id
+ display_name = "jupyterlab"
+ icon = "/icon/jupyter.svg"
+ script = templatefile("${path.module}/run.sh", {
+ LOG_PATH : var.log_path,
+ PORT : var.port
+ })
+ run_on_start = true
+}
+
+resource "coder_app" "jupyterlab" {
+ agent_id = var.agent_id
+ slug = "jupyterlab"
+ display_name = "JupyterLab"
+ url = "http://localhost:${var.port}"
+ icon = "/icon/jupyter.svg"
+ subdomain = true
+ share = var.share
+ order = var.order
+}
diff --git a/jupyterlab/run.sh b/jupyterlab/run.sh
new file mode 100755
index 0000000..b040cec
--- /dev/null
+++ b/jupyterlab/run.sh
@@ -0,0 +1,25 @@
+#!/usr/bin/env sh
+
+BOLD='\033[0;1m'
+
+printf "$${BOLD}Installing jupyterlab!\n"
+
+# check if jupyterlab is installed
+if ! command -v jupyterlab > /dev/null 2>&1; then
+ # install jupyterlab
+ # check if python3 pip is installed
+ if ! command -v pip3 > /dev/null 2>&1; then
+ echo "pip3 is not installed"
+ echo "Please install pip3 in your Dockerfile/VM image before running this script"
+ exit 1
+ fi
+ # install jupyterlab
+ pip3 install --upgrade --no-cache-dir --no-warn-script-location jupyterlab
+ echo "🥳 jupyterlab has been installed\n\n"
+else
+ echo "🥳 jupyterlab is already installed\n\n"
+fi
+
+echo "👷 Starting jupyterlab in background..."
+echo "check logs at ${LOG_PATH}"
+$HOME/.local/bin/jupyter lab --ServerApp.ip='0.0.0.0' --ServerApp.port=${PORT} --no-browser --ServerApp.token='' --ServerApp.password='' > ${LOG_PATH} 2>&1 &
diff --git a/kasmvnc/README.md b/kasmvnc/README.md
index 1b16dad..29aec55 100644
--- a/kasmvnc/README.md
+++ b/kasmvnc/README.md
@@ -17,7 +17,8 @@ Automatically install [KasmVNC](https://kasmweb.com/kasmvnc) in a workspace, and
```hcl
module "kasmvnc" {
- source = "https://registry.coder.com/modules/kasmvnc"
+ source = "registry.coder.com/modules/kasmvnc/coder"
+ version = "1.0.0"
agent_id = coder_agent.example.id
}
@@ -27,7 +28,7 @@ Automatically install [KasmVNC](https://kasmweb.com/kasmvnc) in a workspace, and
```hcl
module "kasmvnc" {
- source = "https://registry.coder.com/modules/kasmvnc"
+ source = "registry.coder.com/modules/kasmvnc/coder"
agent_id = coder_agent.example.id
version = "1.0.0"
desktop_environment = "mate"
@@ -35,5 +36,3 @@ Automatically install [KasmVNC](https://kasmweb.com/kasmvnc) in a workspace, and
}
```
-
-![Screenshot of KasmVNC]() //TODO
diff --git a/lint.ts b/lint.ts
new file mode 100644
index 0000000..db1ee9a
--- /dev/null
+++ b/lint.ts
@@ -0,0 +1,139 @@
+import { readFile, readdir, stat } from "fs/promises";
+import * as path from "path";
+import * as marked from "marked";
+import grayMatter from "gray-matter";
+
+const files = await readdir(".", { withFileTypes: true });
+const dirs = files.filter(
+ (f) => f.isDirectory() && !f.name.startsWith(".") && f.name !== "node_modules"
+);
+
+let badExit = false;
+
+// error reports an error to the console and sets badExit to true
+// so that the process will exit with a non-zero exit code.
+const error = (...data: any[]) => {
+ console.error(...data);
+ badExit = true;
+};
+
+const verifyCodeBlocks = (
+ tokens: marked.Token[],
+ res = {
+ codeIsTF: false,
+ codeIsHCL: false,
+ }
+) => {
+ for (const token of tokens) {
+ // Check in-depth.
+ if (token.type === "list") {
+ verifyCodeBlocks(token.items, res);
+ continue;
+ }
+ if (token.type === "list_item") {
+ verifyCodeBlocks(token.tokens, res);
+ continue;
+ }
+
+ if (token.type === "code") {
+ if (token.lang === "tf") {
+ res.codeIsTF = true;
+ }
+ if (token.lang === "hcl") {
+ res.codeIsHCL = true;
+ }
+ }
+ }
+ return res;
+};
+
+// Ensures that each README has the proper format.
+// Exits with 0 if all is good!
+for (const dir of dirs) {
+ const readme = path.join(dir.name, "README.md");
+ // Ensure exists
+ try {
+ await stat(readme);
+ } catch (ex) {
+ throw new Error(`Missing README.md in ${dir.name}`);
+ }
+ const content = await readFile(readme, "utf8");
+ const matter = grayMatter(content);
+ const data = matter.data as {
+ display_name?: string;
+ description?: string;
+ icon?: string;
+ maintainer_github?: string;
+ partner_github?: string;
+ verified?: boolean;
+ tags?: string[];
+ };
+ if (!data.display_name) {
+ error(dir.name, "missing display_name");
+ }
+ if (!data.description) {
+ error(dir.name, "missing description");
+ }
+ if (!data.icon) {
+ error(dir.name, "missing icon");
+ }
+ if (!data.maintainer_github) {
+ error(dir.name, "missing maintainer_github");
+ }
+ try {
+ await stat(path.join(".", dir.name, data.icon));
+ } catch (ex) {
+ error(dir.name, "icon does not exist", data.icon);
+ }
+
+ const tokens = marked.lexer(content);
+ // Ensure there is an h1 and some text, then a code block
+
+ let h1 = false;
+ let code = false;
+ let paragraph = false;
+ let version = true;
+
+ for (const token of tokens) {
+ if (token.type === "heading" && token.depth === 1) {
+ h1 = true;
+ continue;
+ }
+ if (h1 && token.type === "heading") {
+ break;
+ }
+ if (token.type === "paragraph") {
+ paragraph = true;
+ continue;
+ }
+ if (token.type === "code") {
+ code = true;
+ if (token.lang === "tf" && !token.text.includes("version")) {
+ version = false;
+ error(dir.name, "missing version in tf code block");
+ }
+ continue;
+ }
+ }
+ if (!h1) {
+ error(dir.name, "missing h1");
+ }
+ if (!paragraph) {
+ error(dir.name, "missing paragraph after h1");
+ }
+ if (!code) {
+ error(dir.name, "missing example code block after paragraph");
+ }
+
+ const { codeIsTF, codeIsHCL } = verifyCodeBlocks(tokens);
+ if (!codeIsTF) {
+ error(dir.name, "missing example tf code block");
+ }
+ if (codeIsHCL) {
+ error(dir.name, "hcl code block should be tf");
+ }
+}
+
+if (badExit) {
+ process.exit(1);
+}
diff --git a/new.sh b/new.sh
old mode 100644
new mode 100755
index 0617957..514ce4d
--- a/new.sh
+++ b/new.sh
@@ -1 +1,40 @@
-#!/usr/bin/env sh
+#!/usr/bin/env bash
+
+# This scripts creates a new sample moduledir with required files
+# Run it like : ./new.sh my-module
+
+MODULE_NAME=$1
+
+# Check if module name is provided
+if [ -z "$MODULE_NAME" ]; then
+ echo "Usage: ./new.sh "
+ exit 1
+fi
+
+# Create module directory and exit if it already exists
+if [ -d "$MODULE_NAME" ]; then
+ echo "Module with name $MODULE_NAME already exists"
+ echo "Please choose a different name"
+ exit 1
+fi
+mkdir -p "${MODULE_NAME}"
+
+# Copy required files from the sample module
+cp -r .sample/* "${MODULE_NAME}"
+
+# Change to module directory
+cd "${MODULE_NAME}"
+
+# Detect OS
+if [[ "$OSTYPE" == "darwin"* ]]; then
+ # macOS
+ sed -i '' "s/MODULE_NAME/${MODULE_NAME}/g" main.tf
+ sed -i '' "s/MODULE_NAME/${MODULE_NAME}/g" README.md
+else
+ # Linux
+ sed -i "s/MODULE_NAME/${MODULE_NAME}/g" main.tf
+ sed -i "s/MODULE_NAME/${MODULE_NAME}/g" README.md
+fi
+
+# Make run.sh executable
+chmod +x run.sh
diff --git a/nodejs/README.md b/nodejs/README.md
new file mode 100644
index 0000000..25714aa
--- /dev/null
+++ b/nodejs/README.md
@@ -0,0 +1,58 @@
+---
+display_name: nodejs
+description: Install Node.js via nvm
+icon: ../.icons/node.svg
+maintainer_github: TheZoker
+verified: false
+tags: [helper]
+---
+
+# nodejs
+
+Automatically installs [Node.js](https://github.com/nodejs/node) via [nvm](https://github.com/nvm-sh/nvm). It can also install multiple versions of node and set a default version. If no options are specified, the latest version is installed.
+
+```tf
+module "nodejs" {
+ source = "registry.coder.com/modules/nodejs/coder"
+ version = "1.0.10"
+ agent_id = coder_agent.example.id
+}
+```
+
+### Install multiple versions
+
+This installs multiple versions of Node.js:
+
+```tf
+module "nodejs" {
+ source = "registry.coder.com/modules/nodejs/coder"
+ version = "1.0.10"
+ agent_id = coder_agent.example.id
+ node_versions = [
+ "18",
+ "20",
+ "node"
+ ]
+ default_node_version = "20"
+}
+```
+
+### Full example
+
+A example with all available options:
+
+```tf
+module "nodejs" {
+ source = "registry.coder.com/modules/nodejs/coder"
+ version = "1.0.10"
+ agent_id = coder_agent.example.id
+ nvm_version = "v0.39.7"
+ nvm_install_prefix = "/opt/nvm"
+ node_versions = [
+ "16",
+ "18",
+ "node"
+ ]
+ default_node_version = "16"
+}
+```
diff --git a/nodejs/main.test.ts b/nodejs/main.test.ts
new file mode 100644
index 0000000..07fc7a5
--- /dev/null
+++ b/nodejs/main.test.ts
@@ -0,0 +1,12 @@
+import { describe, expect, it } from "bun:test";
+import { runTerraformInit, testRequiredVariables } from "../test";
+
+describe("nodejs", async () => {
+ await runTerraformInit(import.meta.dir);
+
+ testRequiredVariables(import.meta.dir, {
+ agent_id: "foo",
+ });
+
+ // More tests depend on shebang refactors
+});
diff --git a/nodejs/main.tf b/nodejs/main.tf
new file mode 100644
index 0000000..9c9c5c7
--- /dev/null
+++ b/nodejs/main.tf
@@ -0,0 +1,52 @@
+terraform {
+ required_version = ">= 1.0"
+
+ required_providers {
+ coder = {
+ source = "coder/coder"
+ version = ">= 0.12"
+ }
+ }
+}
+
+variable "agent_id" {
+ type = string
+ description = "The ID of a Coder agent."
+}
+
+variable "nvm_version" {
+ type = string
+ description = "The version of nvm to install."
+ default = "master"
+}
+
+variable "nvm_install_prefix" {
+ type = string
+ description = "The prefix to install nvm to (relative to $HOME)."
+ default = ".nvm"
+}
+
+variable "node_versions" {
+ type = list(string)
+ description = "A list of Node.js versions to install."
+ default = ["node"]
+}
+
+variable "default_node_version" {
+ type = string
+ description = "The default Node.js version"
+ default = "node"
+}
+
+resource "coder_script" "nodejs" {
+ agent_id = var.agent_id
+ display_name = "Node.js:"
+ script = templatefile("${path.module}/run.sh", {
+ NVM_VERSION : var.nvm_version,
+ INSTALL_PREFIX : var.nvm_install_prefix,
+ NODE_VERSIONS : join(",", var.node_versions),
+ DEFAULT : var.default_node_version,
+ })
+ run_on_start = true
+ start_blocks_login = true
+}
diff --git a/nodejs/run.sh b/nodejs/run.sh
new file mode 100755
index 0000000..78e940a
--- /dev/null
+++ b/nodejs/run.sh
@@ -0,0 +1,51 @@
+#!/usr/bin/env bash
+
+NVM_VERSION='${NVM_VERSION}'
+NODE_VERSIONS='${NODE_VERSIONS}'
+INSTALL_PREFIX='${INSTALL_PREFIX}'
+DEFAULT='${DEFAULT}'
+BOLD='\033[0;1m'
+CODE='\033[36;40;1m'
+RESET='\033[0m'
+
+printf "$${BOLD}Installing nvm!$${RESET}\n"
+
+export NVM_DIR="$HOME/$${INSTALL_PREFIX}/nvm"
+mkdir -p "$NVM_DIR"
+
+script="$(curl -sS -o- "https://raw.githubusercontent.com/nvm-sh/nvm/$${NVM_VERSION}/install.sh" 2>&1)"
+if [ $? -ne 0 ]; then
+ echo "Failed to download nvm installation script: $script"
+ exit 1
+fi
+
+output="$(bash <<< "$script" 2>&1)"
+if [ $? -ne 0 ]; then
+ echo "Failed to install nvm: $output"
+ exit 1
+fi
+
+printf "🥳 nvm has been installed\n\n"
+
+# Set up nvm for the rest of the script.
+[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"
+
+# Install each node version...
+IFS=',' read -r -a VERSIONLIST <<< "$${NODE_VERSIONS}"
+for version in "$${VERSIONLIST[@]}"; do
+ if [ -z "$version" ]; then
+ continue
+ fi
+ printf "🛠️ Installing node version $${CODE}$version$${RESET}...\n"
+ output=$(nvm install "$version" 2>&1)
+ if [ $? -ne 0 ]; then
+ echo "Failed to install version: $version: $output"
+ exit 1
+ fi
+done
+
+# Set default if provided
+if [ -n "$${DEFAULT}" ]; then
+ printf "🛠️ Setting default node version $${CODE}$DEFAULT$${RESET}...\n"
+ output=$(nvm alias default $DEFAULT 2>&1)
+fi
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..8039c1c
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,263 @@
+{
+ "name": "modules",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "modules",
+ "devDependencies": {
+ "bun-types": "^1.0.18",
+ "gray-matter": "^4.0.3",
+ "marked": "^12.0.0",
+ "prettier": "^3.2.5",
+ "prettier-plugin-sh": "^0.13.1",
+ "prettier-plugin-terraform-formatter": "^1.2.1"
+ },
+ "peerDependencies": {
+ "typescript": "^5.3.3"
+ }
+ },
+ "node_modules/@types/node": {
+ "version": "20.11.30",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.30.tgz",
+ "integrity": "sha512-dHM6ZxwlmuZaRmUPfv1p+KrdD1Dci04FbdEm/9wEMouFqxYoFl5aMkt0VMAUtYRQDyYvD41WJLukhq/ha3YuTw==",
+ "dev": true,
+ "dependencies": {
+ "undici-types": "~5.26.4"
+ }
+ },
+ "node_modules/@types/ws": {
+ "version": "8.5.10",
+ "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.10.tgz",
+ "integrity": "sha512-vmQSUcfalpIq0R9q7uTo2lXs6eGIpt9wtnLdMv9LVpIjCA/+ufZRozlVoVelIYixx1ugCBKDhn89vnsEGOCx9A==",
+ "dev": true,
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/argparse": {
+ "version": "1.0.10",
+ "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
+ "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
+ "dev": true,
+ "dependencies": {
+ "sprintf-js": "~1.0.2"
+ }
+ },
+ "node_modules/bun-types": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.1.4.tgz",
+ "integrity": "sha512-E1kk0FNpxpkSSlCVXEa4HfyhSUEpKtCFrybPVyz1A4TEnBGy5bqqtSYkyjKTfKScdyZTBeFrTxJLiKGOIRWgwg==",
+ "dev": true,
+ "dependencies": {
+ "@types/node": "~20.11.3",
+ "@types/ws": "~8.5.10"
+ }
+ },
+ "node_modules/esprima": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
+ "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
+ "dev": true,
+ "bin": {
+ "esparse": "bin/esparse.js",
+ "esvalidate": "bin/esvalidate.js"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/extend-shallow": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
+ "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
+ "dev": true,
+ "dependencies": {
+ "is-extendable": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/gray-matter": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz",
+ "integrity": "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==",
+ "dev": true,
+ "dependencies": {
+ "js-yaml": "^3.13.1",
+ "kind-of": "^6.0.2",
+ "section-matter": "^1.0.0",
+ "strip-bom-string": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=6.0"
+ }
+ },
+ "node_modules/is-extendable": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
+ "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/js-yaml": {
+ "version": "3.14.1",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz",
+ "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==",
+ "dev": true,
+ "dependencies": {
+ "argparse": "^1.0.7",
+ "esprima": "^4.0.0"
+ },
+ "bin": {
+ "js-yaml": "bin/js-yaml.js"
+ }
+ },
+ "node_modules/kind-of": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
+ "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/marked": {
+ "version": "12.0.2",
+ "resolved": "https://registry.npmjs.org/marked/-/marked-12.0.2.tgz",
+ "integrity": "sha512-qXUm7e/YKFoqFPYPa3Ukg9xlI5cyAtGmyEIzMfW//m6kXwCy2Ps9DYf5ioijFKQ8qyuscrHoY04iJGctu2Kg0Q==",
+ "dev": true,
+ "bin": {
+ "marked": "bin/marked.js"
+ },
+ "engines": {
+ "node": ">= 18"
+ }
+ },
+ "node_modules/mvdan-sh": {
+ "version": "0.10.1",
+ "resolved": "https://registry.npmjs.org/mvdan-sh/-/mvdan-sh-0.10.1.tgz",
+ "integrity": "sha512-kMbrH0EObaKmK3nVRKUIIya1dpASHIEusM13S4V1ViHFuxuNxCo+arxoa6j/dbV22YBGjl7UKJm9QQKJ2Crzhg==",
+ "dev": true
+ },
+ "node_modules/prettier": {
+ "version": "3.2.5",
+ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.2.5.tgz",
+ "integrity": "sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A==",
+ "dev": true,
+ "bin": {
+ "prettier": "bin/prettier.cjs"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/prettier/prettier?sponsor=1"
+ }
+ },
+ "node_modules/prettier-plugin-sh": {
+ "version": "0.13.1",
+ "resolved": "https://registry.npmjs.org/prettier-plugin-sh/-/prettier-plugin-sh-0.13.1.tgz",
+ "integrity": "sha512-ytMcl1qK4s4BOFGvsc9b0+k9dYECal7U29bL/ke08FEUsF/JLN0j6Peo0wUkFDG4y2UHLMhvpyd6Sd3zDXe/eg==",
+ "dev": true,
+ "dependencies": {
+ "mvdan-sh": "^0.10.1",
+ "sh-syntax": "^0.4.1"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/unts"
+ },
+ "peerDependencies": {
+ "prettier": "^3.0.0"
+ }
+ },
+ "node_modules/prettier-plugin-terraform-formatter": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/prettier-plugin-terraform-formatter/-/prettier-plugin-terraform-formatter-1.2.1.tgz",
+ "integrity": "sha512-rdzV61Bs/Ecnn7uAS/vL5usTX8xUWM+nQejNLZxt3I1kJH5WSeLEmq7LYu1wCoEQF+y7Uv1xGvPRfl3lIe6+tA==",
+ "dev": true,
+ "peerDependencies": {
+ "prettier": ">= 1.16.0"
+ },
+ "peerDependenciesMeta": {
+ "prettier": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/section-matter": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz",
+ "integrity": "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==",
+ "dev": true,
+ "dependencies": {
+ "extend-shallow": "^2.0.1",
+ "kind-of": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/sh-syntax": {
+ "version": "0.4.2",
+ "resolved": "https://registry.npmjs.org/sh-syntax/-/sh-syntax-0.4.2.tgz",
+ "integrity": "sha512-/l2UZ5fhGZLVZa16XQM9/Vq/hezGGbdHeVEA01uWjOL1+7Ek/gt6FquW0iKKws4a9AYPYvlz6RyVvjh3JxOteg==",
+ "dev": true,
+ "dependencies": {
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/unts"
+ }
+ },
+ "node_modules/sprintf-js": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
+ "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
+ "dev": true
+ },
+ "node_modules/strip-bom-string": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz",
+ "integrity": "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/tslib": {
+ "version": "2.6.2",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz",
+ "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==",
+ "dev": true
+ },
+ "node_modules/typescript": {
+ "version": "5.4.5",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz",
+ "integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==",
+ "peer": true,
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "5.26.5",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
+ "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
+ "dev": true
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..f3136b1
--- /dev/null
+++ b/package.json
@@ -0,0 +1,27 @@
+{
+ "name": "modules",
+ "scripts": {
+ "test": "bun test",
+ "fmt": "bun x prettier -w **/*.sh .sample/run.sh new.sh **/*.ts **/*.md *.md && terraform fmt **/*.tf .sample/main.tf",
+ "fmt:ci": "bun x prettier --check **/*.sh .sample/run.sh new.sh **/*.ts **/*.md *.md && terraform fmt -check **/*.tf .sample/main.tf",
+ "lint": "bun run lint.ts && ./terraform_validate.sh",
+ "update-version": "./update-version.sh"
+ },
+ "devDependencies": {
+ "bun-types": "^1.0.18",
+ "gray-matter": "^4.0.3",
+ "marked": "^12.0.0",
+ "prettier": "^3.2.5",
+ "prettier-plugin-sh": "^0.13.1",
+ "prettier-plugin-terraform-formatter": "^1.2.1"
+ },
+ "peerDependencies": {
+ "typescript": "^5.3.3"
+ },
+ "prettier": {
+ "plugins": [
+ "prettier-plugin-sh",
+ "prettier-plugin-terraform-formatter"
+ ]
+ }
+}
diff --git a/personalize/README.md b/personalize/README.md
index 60afc3a..24d19a9 100644
--- a/personalize/README.md
+++ b/personalize/README.md
@@ -10,3 +10,11 @@ tags: [helper]
# Personalize
Run a script on workspace start that allows developers to run custom commands to personalize their workspace.
+
+```tf
+module "personalize" {
+ source = "registry.coder.com/modules/personalize/coder"
+ version = "1.0.2"
+ agent_id = coder_agent.example.id
+}
+```
diff --git a/personalize/main.test.ts b/personalize/main.test.ts
new file mode 100644
index 0000000..9c8134e
--- /dev/null
+++ b/personalize/main.test.ts
@@ -0,0 +1,33 @@
+import { readableStreamToText, spawn } from "bun";
+import { describe, expect, it } from "bun:test";
+import {
+ executeScriptInContainer,
+ runTerraformApply,
+ runTerraformInit,
+ testRequiredVariables,
+ runContainer,
+ execContainer,
+ findResourceInstance,
+} from "../test";
+
+describe("personalize", async () => {
+ await runTerraformInit(import.meta.dir);
+
+ testRequiredVariables(import.meta.dir, {
+ agent_id: "foo",
+ });
+
+ it("warns without personalize script", async () => {
+ const state = await runTerraformApply(import.meta.dir, {
+ agent_id: "foo",
+ });
+ const output = await executeScriptInContainer(state, "alpine");
+ expect(output.exitCode).toBe(0);
+ expect(output.stdout).toEqual([
+ "✨ \u001b[0;1mYou don't have a personalize script!",
+ "",
+ "Run \u001b[36;40;1mtouch ~/personalize && chmod +x ~/personalize\u001b[0m to create one.",
+ "It will run every time your workspace starts. Use it to install personal packages!",
+ ]);
+ });
+});
diff --git a/personalize/main.tf b/personalize/main.tf
index 2b44c0a..9de4b78 100644
--- a/personalize/main.tf
+++ b/personalize/main.tf
@@ -10,22 +10,30 @@ terraform {
}
variable "agent_id" {
- type = string
- description = "The ID of a Coder agent."
+ type = string
+ description = "The ID of a Coder agent."
}
variable "path" {
- type = string
- description = "The path to a script that will be ran on start enabling a user to personalize their workspace."
- default = "~/personalize"
+ type = string
+ description = "The path to a script that will be ran on start enabling a user to personalize their workspace."
+ default = "~/personalize"
+}
+
+variable "log_path" {
+ type = string
+ description = "The path to a log file that will contain the output of the personalize script."
+ default = "~/personalize.log"
}
resource "coder_script" "personalize" {
- agent_id = var.agent_id
- script = templatefile("${path.module}/run.sh", {
- PERSONALIZE_PATH: var.path,
- })
- display_name = "Personalize"
- icon = "/emojis/1f58c.png"
- run_on_start = true
+ agent_id = var.agent_id
+ script = templatefile("${path.module}/run.sh", {
+ PERSONALIZE_PATH : var.path,
+ })
+ display_name = "Personalize"
+ icon = "/icon/personalize.svg"
+ log_path = var.log_path
+ run_on_start = true
+ start_blocks_login = true
}
diff --git a/personalize/run.sh b/personalize/run.sh
index 466be1c..dacaf48 100755
--- a/personalize/run.sh
+++ b/personalize/run.sh
@@ -1,26 +1,27 @@
-#!/usr/bin/env sh
+#!/usr/bin/env bash
BOLD='\033[0;1m'
CODE='\033[36;40;1m'
RESET='\033[0m'
SCRIPT="${PERSONALIZE_PATH}"
+SCRIPT="$${SCRIPT/#\~/$${HOME}}"
# If the personalize script doesn't exist, educate
# the user how they can customize their environment!
if [ ! -f $SCRIPT ]; then
- printf "✨ $${BOLD}You don't have a personalize script!\n\n"
- printf "Run $${CODE}touch $${SCRIPT} && chmod +x $${SCRIPT}$${RESET} to create one.\n"
- printf "It will run every time your workspace starts. Use it to install personal packages!\n\n"
- exit 0
+ printf "✨ $${BOLD}You don't have a personalize script!\n\n"
+ printf "Run $${CODE}touch $${SCRIPT} && chmod +x $${SCRIPT}$${RESET} to create one.\n"
+ printf "It will run every time your workspace starts. Use it to install personal packages!\n\n"
+ exit 0
fi
# Check if the personalize script is executable, if not,
# try to make it executable and educate the user if it fails.
if [ ! -x $SCRIPT ]; then
- echo "🔐 Your personalize script isn't executable!"
- printf "Run $CODE\`chmod +x $SCRIPT\`$RESET to make it executable.\n"
- exit 0
+ echo "🔐 Your personalize script isn't executable!"
+ printf "Run $CODE\`chmod +x $SCRIPT\`$RESET to make it executable.\n"
+ exit 0
fi
# Run the personalize script!
-exec $SCRIPT
+$SCRIPT
diff --git a/setup.ts b/setup.ts
new file mode 100644
index 0000000..3cfb871
--- /dev/null
+++ b/setup.ts
@@ -0,0 +1,49 @@
+import { readableStreamToText, spawn } from "bun";
+import { afterAll, beforeAll } from "bun:test";
+
+const removeStatefiles = async () => {
+ const proc = spawn([
+ "find",
+ ".",
+ "-type",
+ "f",
+ "-o",
+ "-name",
+ "*.tfstate",
+ "-o",
+ "-name",
+ "*.tfstate.lock.info",
+ "-delete",
+ ]);
+ await proc.exited;
+};
+
+const removeOldContainers = async () => {
+ let proc = spawn([
+ "docker",
+ "ps",
+ "-a",
+ "-q",
+ "--filter",
+ `label=modules-test`,
+ ]);
+ let containerIDsRaw = await readableStreamToText(proc.stdout);
+ let exitCode = await proc.exited;
+ if (exitCode !== 0) {
+ throw new Error(containerIDsRaw);
+ }
+ containerIDsRaw = containerIDsRaw.trim();
+ if (containerIDsRaw === "") {
+ return;
+ }
+ proc = spawn(["docker", "rm", "-f", ...containerIDsRaw.split("\n")]);
+ const stdout = await readableStreamToText(proc.stdout);
+ exitCode = await proc.exited;
+ if (exitCode !== 0) {
+ throw new Error(stdout);
+ }
+};
+
+afterAll(async () => {
+ await Promise.all([removeStatefiles(), removeOldContainers()]);
+});
diff --git a/slackme/README.md b/slackme/README.md
new file mode 100644
index 0000000..0858c3d
--- /dev/null
+++ b/slackme/README.md
@@ -0,0 +1,83 @@
+---
+display_name: Slack Me
+description: Send a Slack message when a command finishes inside a workspace!
+icon: ../.icons/slack.svg
+maintainer_github: coder
+verified: true
+tags: [helper]
+---
+
+# Slack Me
+
+Add the `slackme` command to your workspace that DMs you on Slack when your command finishes running.
+
+```bash
+slackme npm run long-build
+```
+
+## Setup
+
+1. Navigate to [Create a Slack App](https://api.slack.com/apps?new_app=1) and select "From an app manifest". Select a workspace and paste in the following manifest, adjusting the redirect URL to your Coder deployment:
+
+ ```json
+ {
+ "display_information": {
+ "name": "Command Notify",
+ "description": "Notify developers when commands finish running inside Coder!",
+ "background_color": "#1b1b1c"
+ },
+ "features": {
+ "bot_user": {
+ "display_name": "Command Notify"
+ }
+ },
+ "oauth_config": {
+ "redirect_urls": [
+ "https:///external-auth/slack/callback"
+ ],
+ "scopes": {
+ "bot": ["chat:write"]
+ }
+ }
+ }
+ ```
+
+2. In the "Basic Information" tab on the left after creating your app, scroll down to the "App Credentials" section. Set the following environment variables in your Coder deployment:
+
+ ```env
+ CODER_EXTERNAL_AUTH_1_TYPE=slack
+ CODER_EXTERNAL_AUTH_1_SCOPES="chat:write"
+ CODER_EXTERNAL_AUTH_1_DISPLAY_NAME="Slack Me"
+ CODER_EXTERNAL_AUTH_1_CLIENT_ID="
+ CODER_EXTERNAL_AUTH_1_CLIENT_SECRET=""
+ ```
+
+3. Restart your Coder deployment. Any Template can now import the Slack Me module, and `slackme` will be available on the `$PATH`:
+
+ ```tf
+ module "slackme" {
+ source = "registry.coder.com/modules/slackme/coder"
+ version = "1.0.2"
+ agent_id = coder_agent.example.id
+ auth_provider_id = "slack"
+ }
+ ```
+
+## Examples
+
+### Custom Slack Message
+
+- `$COMMAND` is replaced with the command the user executed.
+- `$DURATION` is replaced with a human-readable duration the command took to execute.
+
+```tf
+module "slackme" {
+ source = "registry.coder.com/modules/slackme/coder"
+ version = "1.0.2"
+ agent_id = coder_agent.example.id
+ auth_provider_id = "slack"
+ slack_message = < {
+ await runTerraformInit(import.meta.dir);
+
+ testRequiredVariables(import.meta.dir, {
+ agent_id: "foo",
+ auth_provider_id: "foo",
+ });
+
+ it("writes to path as executable", async () => {
+ const { instance, id } = await setupContainer();
+ await writeCoder(id, "exit 0");
+ let exec = await execContainer(id, ["sh", "-c", instance.script]);
+ expect(exec.exitCode).toBe(0);
+ exec = await execContainer(id, ["sh", "-c", "which slackme"]);
+ expect(exec.exitCode).toBe(0);
+ expect(exec.stdout.trim()).toEqual("/usr/bin/slackme");
+ });
+
+ it("prints usage with no command", async () => {
+ const { instance, id } = await setupContainer();
+ await writeCoder(id, "echo 👋");
+ let exec = await execContainer(id, ["sh", "-c", instance.script]);
+ expect(exec.exitCode).toBe(0);
+ exec = await execContainer(id, ["sh", "-c", "slackme"]);
+ expect(exec.stdout.trim()).toStartWith(
+ "slackme — Send a Slack notification when a command finishes",
+ );
+ });
+
+ it("displays url when not authenticated", async () => {
+ const { instance, id } = await setupContainer();
+ await writeCoder(id, "echo 'some-url' && exit 1");
+ let exec = await execContainer(id, ["sh", "-c", instance.script]);
+ expect(exec.exitCode).toBe(0);
+ exec = await execContainer(id, ["sh", "-c", "slackme echo test"]);
+ expect(exec.stdout.trim()).toEndWith("some-url");
+ });
+
+ it("default output", async () => {
+ await assertSlackMessage({
+ command: "echo test",
+ durationMS: 2,
+ output: "👨💻 `echo test` completed in 2ms",
+ });
+ });
+
+ it("formats multiline message", async () => {
+ await assertSlackMessage({
+ command: "echo test",
+ format: `this command:
+\`$COMMAND\`
+executed`,
+ output: `this command:
+\`echo test\`
+executed`,
+ });
+ });
+
+ it("formats execution with milliseconds", async () => {
+ await assertSlackMessage({
+ command: "echo test",
+ format: `$COMMAND took $DURATION`,
+ durationMS: 150,
+ output: "echo test took 150ms",
+ });
+ });
+
+ it("formats execution with seconds", async () => {
+ await assertSlackMessage({
+ command: "echo test",
+ format: `$COMMAND took $DURATION`,
+ durationMS: 15000,
+ output: "echo test took 15.0s",
+ });
+ });
+
+ it("formats execution with minutes", async () => {
+ await assertSlackMessage({
+ command: "echo test",
+ format: `$COMMAND took $DURATION`,
+ durationMS: 120000,
+ output: "echo test took 2m 0.0s",
+ });
+ });
+
+ it("formats execution with hours", async () => {
+ await assertSlackMessage({
+ command: "echo test",
+ format: `$COMMAND took $DURATION`,
+ durationMS: 60000 * 60,
+ output: "echo test took 1hr 0m 0.0s",
+ });
+ });
+});
+
+const setupContainer = async (
+ image = "alpine",
+ vars: Record = {},
+) => {
+ const state = await runTerraformApply(import.meta.dir, {
+ agent_id: "foo",
+ auth_provider_id: "foo",
+ ...vars,
+ });
+ const instance = findResourceInstance(state, "coder_script");
+ const id = await runContainer(image);
+ return { id, instance };
+};
+
+const assertSlackMessage = async (opts: {
+ command: string;
+ format?: string;
+ durationMS?: number;
+ output: string;
+}) => {
+ let url: URL;
+ const fakeSlackHost = serve({
+ fetch: (req) => {
+ url = new URL(req.url);
+ if (url.pathname === "/api/chat.postMessage")
+ return createJSONResponse({
+ ok: true,
+ });
+ return createJSONResponse({}, 404);
+ },
+ port: 0,
+ });
+ const { instance, id } = await setupContainer(
+ "alpine/curl",
+ opts.format && {
+ slack_message: opts.format,
+ },
+ );
+ await writeCoder(id, "echo 'token'");
+ let exec = await execContainer(id, ["sh", "-c", instance.script]);
+ expect(exec.exitCode).toBe(0);
+ exec = await execContainer(id, [
+ "sh",
+ "-c",
+ `DURATION_MS=${opts.durationMS || 0} SLACK_URL="http://${
+ fakeSlackHost.hostname
+ }:${fakeSlackHost.port}" slackme ${opts.command}`,
+ ]);
+ expect(exec.stderr.trim()).toBe("");
+ expect(url.pathname).toEqual("/api/chat.postMessage");
+ expect(url.searchParams.get("channel")).toEqual("token");
+ expect(url.searchParams.get("text")).toEqual(opts.output);
+};
diff --git a/slackme/main.tf b/slackme/main.tf
new file mode 100644
index 0000000..5fe948e
--- /dev/null
+++ b/slackme/main.tf
@@ -0,0 +1,46 @@
+terraform {
+ required_version = ">= 1.0"
+
+ required_providers {
+ coder = {
+ source = "coder/coder"
+ version = ">= 0.12"
+ }
+ }
+}
+
+variable "agent_id" {
+ type = string
+ description = "The ID of a Coder agent."
+}
+
+variable "auth_provider_id" {
+ type = string
+ description = "The ID of an external auth provider."
+}
+
+variable "slack_message" {
+ type = string
+ description = "The message to send to Slack."
+ default = "👨💻 `$COMMAND` completed in $DURATION"
+}
+
+resource "coder_script" "install_slackme" {
+ agent_id = var.agent_id
+ display_name = "install_slackme"
+ run_on_start = true
+ script = < $CODER_DIR/slackme <
+
+Example: slackme npm run long-build
+EOF
+}
+
+pretty_duration() {
+ local duration_ms=$1
+
+ # If the duration is less than 1 second, display in milliseconds
+ if [ $duration_ms -lt 1000 ]; then
+ echo "$${duration_ms}ms"
+ return
+ fi
+
+ # Convert the duration to seconds
+ local duration_sec=$((duration_ms / 1000))
+ local remaining_ms=$((duration_ms % 1000))
+
+ # If the duration is less than 1 minute, display in seconds (with ms)
+ if [ $duration_sec -lt 60 ]; then
+ echo "$${duration_sec}.$${remaining_ms}s"
+ return
+ fi
+
+ # Convert the duration to minutes
+ local duration_min=$((duration_sec / 60))
+ local remaining_sec=$((duration_sec % 60))
+
+ # If the duration is less than 1 hour, display in minutes and seconds
+ if [ $duration_min -lt 60 ]; then
+ echo "$${duration_min}m $${remaining_sec}.$${remaining_ms}s"
+ return
+ fi
+
+ # Convert the duration to hours
+ local duration_hr=$((duration_min / 60))
+ local remaining_min=$((duration_min % 60))
+
+ # Display in hours, minutes, and seconds
+ echo "$${duration_hr}hr $${remaining_min}m $${remaining_sec}.$${remaining_ms}s"
+}
+
+if [ $# -eq 0 ]; then
+ usage
+ exit 1
+fi
+
+BOT_TOKEN=$(coder external-auth access-token $PROVIDER_ID)
+if [ $? -ne 0 ]; then
+ printf "Authenticate with Slack to be notified when a command finishes:\n$BOT_TOKEN\n"
+ exit 1
+fi
+
+USER_ID=$(coder external-auth access-token $PROVIDER_ID --extra "authed_user.id")
+if [ $? -ne 0 ]; then
+ printf "Failed to get authenticated user ID:\n$USER_ID\n"
+ exit 1
+fi
+
+START=$(date +%s%N)
+# Run all arguments as a command
+$@
+END=$(date +%s%N)
+DURATION_MS=$${DURATION_MS:-$(((END - START) / 1000000))}
+PRETTY_DURATION=$(pretty_duration $DURATION_MS)
+
+set -e
+COMMAND=$(echo $@)
+SLACK_MESSAGE=$(echo "$SLACK_MESSAGE" | sed "s|\\$COMMAND|$COMMAND|g")
+SLACK_MESSAGE=$(echo "$SLACK_MESSAGE" | sed "s|\\$DURATION|$PRETTY_DURATION|g")
+
+curl --silent -o /dev/null --header "Authorization: Bearer $BOT_TOKEN" \
+ -G --data-urlencode "text=$${SLACK_MESSAGE}" \
+ "$SLACK_URL/api/chat.postMessage?channel=$USER_ID&pretty=1"
diff --git a/terraform_validate.sh b/terraform_validate.sh
new file mode 100755
index 0000000..292c94c
--- /dev/null
+++ b/terraform_validate.sh
@@ -0,0 +1,29 @@
+#!/bin/bash
+
+set -euo pipefail
+
+# Function to run terraform init and validate in a directory
+run_terraform() {
+ local dir="$1"
+ echo "Running terraform init and validate in $dir"
+ pushd "$dir"
+ terraform init -upgrade
+ terraform validate
+ popd
+}
+
+# Main script
+main() {
+ # Get the directory of the script
+ script_dir=$(dirname "$(readlink -f "$0")")
+
+ # Get all subdirectories in the repository
+ subdirs=$(find "$script_dir" -mindepth 1 -maxdepth 1 -type d -not -name ".*" | sort)
+
+ for dir in $subdirs; do
+ run_terraform "$dir"
+ done
+}
+
+# Run the main script
+main
diff --git a/test.ts b/test.ts
new file mode 100644
index 0000000..c2eb65e
--- /dev/null
+++ b/test.ts
@@ -0,0 +1,234 @@
+import { readableStreamToText, spawn } from "bun";
+import { afterEach, expect, it } from "bun:test";
+import { readFile, unlink } from "fs/promises";
+
+export const runContainer = async (
+ image: string,
+ init = "sleep infinity",
+): Promise => {
+ const proc = spawn([
+ "docker",
+ "run",
+ "--rm",
+ "-d",
+ "--label",
+ "modules-test=true",
+ "--network",
+ "host",
+ "--entrypoint",
+ "sh",
+ image,
+ "-c",
+ init,
+ ]);
+ let containerID = await readableStreamToText(proc.stdout);
+ const exitCode = await proc.exited;
+ if (exitCode !== 0) {
+ throw new Error(containerID);
+ }
+ return containerID.trim();
+};
+
+// executeScriptInContainer finds the only "coder_script"
+// resource in the given state and runs it in a container.
+export const executeScriptInContainer = async (
+ state: TerraformState,
+ image: string,
+ shell: string = "sh",
+): Promise<{
+ exitCode: number;
+ stdout: string[];
+ stderr: string[];
+}> => {
+ const instance = findResourceInstance(state, "coder_script");
+ const id = await runContainer(image);
+ const resp = await execContainer(id, [shell, "-c", instance.script]);
+ const stdout = resp.stdout.trim().split("\n");
+ const stderr = resp.stderr.trim().split("\n");
+ return {
+ exitCode: resp.exitCode,
+ stdout,
+ stderr,
+ };
+};
+
+export const execContainer = async (
+ id: string,
+ cmd: string[],
+): Promise<{
+ exitCode: number;
+ stderr: string;
+ stdout: string;
+}> => {
+ const proc = spawn(["docker", "exec", id, ...cmd], {
+ stderr: "pipe",
+ stdout: "pipe",
+ });
+ const [stderr, stdout] = await Promise.all([
+ readableStreamToText(proc.stderr),
+ readableStreamToText(proc.stdout),
+ ]);
+ const exitCode = await proc.exited;
+ return {
+ exitCode,
+ stderr,
+ stdout,
+ };
+};
+
+export interface TerraformState {
+ outputs: {
+ [key: string]: {
+ type: string;
+ value: any;
+ };
+ }
+ resources: [
+ {
+ type: string;
+ name: string;
+ provider: string;
+ instances: [
+ {
+ attributes: {
+ [key: string]: any;
+ };
+ },
+ ];
+ },
+ ];
+}
+
+export interface CoderScriptAttributes {
+ script: string;
+ agent_id: string;
+ url: string;
+}
+
+// findResourceInstance finds the first instance of the given resource
+// type in the given state. If name is specified, it will only find
+// the instance with the given name.
+export const findResourceInstance = (
+ state: TerraformState,
+ type: T,
+ name?: string,
+ // if type is "coder_script" return CoderScriptAttributes
+): T extends "coder_script"
+ ? CoderScriptAttributes
+ : Record => {
+ const resource = state.resources.find(
+ (resource) =>
+ resource.type === type && (name ? resource.name === name : true),
+ );
+ if (!resource) {
+ throw new Error(`Resource ${type} not found`);
+ }
+ if (resource.instances.length !== 1) {
+ throw new Error(
+ `Resource ${type} has ${resource.instances.length} instances`,
+ );
+ }
+ return resource.instances[0].attributes as any;
+};
+
+// testRequiredVariables creates a test-case
+// for each variable provided and ensures that
+// the apply fails without it.
+export const testRequiredVariables = (
+ dir: string,
+ vars: Record,
+) => {
+ // Ensures that all required variables are provided.
+ it("required variables", async () => {
+ await runTerraformApply(dir, vars);
+ });
+ const varNames = Object.keys(vars);
+ varNames.forEach((varName) => {
+ // Ensures that every variable provided is required!
+ it("missing variable " + varName, async () => {
+ const localVars = {};
+ varNames.forEach((otherVarName) => {
+ if (otherVarName !== varName) {
+ localVars[otherVarName] = vars[otherVarName];
+ }
+ });
+ try {
+ await runTerraformApply(dir, localVars);
+ } catch (ex) {
+ expect(ex.message).toContain(
+ `input variable \"${varName}\" is not set`,
+ );
+ return;
+ }
+ throw new Error(`${varName} is not a required variable!`);
+ });
+ });
+};
+
+// runTerraformApply runs terraform apply in the given directory
+// with the given variables. It is fine to run in parallel with
+// other instances of this function, as it uses a random state file.
+export const runTerraformApply = async (
+ dir: string,
+ vars: Record,
+ env: Record = {},
+): Promise => {
+ const stateFile = `${dir}/${crypto.randomUUID()}.tfstate`;
+ Object.keys(vars).forEach((key) => (env[`TF_VAR_${key}`] = vars[key]));
+ const proc = spawn(
+ [
+ "terraform",
+ "apply",
+ "-compact-warnings",
+ "-input=false",
+ "-auto-approve",
+ "-state",
+ "-no-color",
+ stateFile,
+ ],
+ {
+ cwd: dir,
+ env,
+ stderr: "pipe",
+ stdout: "pipe",
+ },
+ );
+ const text = await readableStreamToText(proc.stderr);
+ const exitCode = await proc.exited;
+ if (exitCode !== 0) {
+ throw new Error(text);
+ }
+ const content = await readFile(stateFile, "utf8");
+ await unlink(stateFile);
+ return JSON.parse(content);
+};
+
+// runTerraformInit runs terraform init in the given directory.
+export const runTerraformInit = async (dir: string) => {
+ const proc = spawn(["terraform", "init"], {
+ cwd: dir,
+ });
+ const text = await readableStreamToText(proc.stdout);
+ const exitCode = await proc.exited;
+ if (exitCode !== 0) {
+ throw new Error(text);
+ }
+};
+
+export const createJSONResponse = (obj: object, statusCode = 200): Response => {
+ return new Response(JSON.stringify(obj), {
+ headers: {
+ "Content-Type": "application/json",
+ },
+ status: statusCode,
+ })
+}
+
+export const writeCoder = async (id: string, script: string) => {
+ const exec = await execContainer(id, [
+ "sh",
+ "-c",
+ `echo '${script}' > /usr/bin/coder && chmod +x /usr/bin/coder`,
+ ]);
+ expect(exec.exitCode).toBe(0);
+};
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..e7b89cd
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,9 @@
+{
+ "compilerOptions": {
+ "target": "esnext",
+ "module": "esnext",
+ "allowSyntheticDefaultImports": true,
+ "moduleResolution": "nodenext",
+ "types": ["bun-types"]
+ }
+}
diff --git a/update-version.sh b/update-version.sh
new file mode 100755
index 0000000..5deb63b
--- /dev/null
+++ b/update-version.sh
@@ -0,0 +1,29 @@
+#!/usr/bin/env bash
+
+# This script updates the version number in the README.md files of all modules
+# to the latest tag in the repository. It is intended to be run from the root
+# of the repository or by using the `bun update-version` command.
+
+set -euo pipefail
+
+current_tag=$(git describe --tags --abbrev=0)
+previous_tag=$(git describe --tags --abbrev=0 $current_tag^)
+mapfile -t changed_dirs < <(git diff --name-only "$previous_tag"..."$current_tag" -- ':!**/README.md' ':!**/*.test.ts' | xargs dirname | grep -v '^\.' | sort -u)
+
+LATEST_TAG=$(git describe --abbrev=0 --tags | sed 's/^v//') || exit $?
+
+for dir in "${changed_dirs[@]}"; do
+ if [[ -f "$dir/README.md" ]]; then
+ echo "Bumping version in $dir/README.md"
+ file="$dir/README.md"
+ tmpfile=$(mktemp /tmp/tempfile.XXXXXX)
+ awk -v tag="$LATEST_TAG" '{
+ if ($1 == "version" && $2 == "=") {
+ sub(/"[^"]*"/, "\"" tag "\"")
+ print
+ } else {
+ print
+ }
+ }' "$file" > "$tmpfile" && mv "$tmpfile" "$file"
+ fi
+done
diff --git a/vault-github/README.md b/vault-github/README.md
new file mode 100644
index 0000000..ac73972
--- /dev/null
+++ b/vault-github/README.md
@@ -0,0 +1,79 @@
+---
+display_name: Hashicorp Vault Integration (GitHub)
+description: Authenticates with Vault using GitHub
+icon: ../.icons/vault.svg
+maintainer_github: coder
+partner_github: hashicorp
+verified: true
+tags: [helper, integration, vault, github]
+---
+
+# Hashicorp Vault Integration (GitHub)
+
+This module lets you authenticate with [Hashicorp Vault](https://www.vaultproject.io/) in your Coder workspaces using [external auth](https://coder.com/docs/v2/latest/admin/external-auth) for GitHub.
+
+```tf
+module "vault" {
+ source = "registry.coder.com/modules/vault-github/coder"
+ version = "1.0.7"
+ agent_id = coder_agent.example.id
+ vault_addr = "https://vault.example.com"
+}
+```
+
+Then you can use the Vault CLI in your workspaces to fetch secrets from Vault:
+
+```shell
+vault kv get -namespace=coder -mount=secrets coder
+```
+
+or using the Vault API:
+
+```shell
+curl -H "X-Vault-Token: ${VAULT_TOKEN}" -X GET "${VAULT_ADDR}/v1/coder/secrets/data/coder"
+```
+
+
+
+## Configuration
+
+To configure the Vault module, you must set up a Vault GitHub auth method. See the [Vault documentation](https://www.vaultproject.io/docs/auth/github) for more information.
+
+## Examples
+
+### Configure Vault integration with a different Coder GitHub external auth ID (i.e., not the default `github`)
+
+```tf
+module "vault" {
+ source = "registry.coder.com/modules/vault-github/coder"
+ version = "1.0.7"
+ agent_id = coder_agent.example.id
+ vault_addr = "https://vault.example.com"
+ coder_github_auth_id = "my-github-auth-id"
+}
+```
+
+### Configure Vault integration with a different Coder GitHub external auth ID and a different Vault GitHub auth path
+
+```tf
+module "vault" {
+ source = "registry.coder.com/modules/vault-github/coder"
+ version = "1.0.7"
+ agent_id = coder_agent.example.id
+ vault_addr = "https://vault.example.com"
+ coder_github_auth_id = "my-github-auth-id"
+ vault_github_auth_path = "my-github-auth-path"
+}
+```
+
+### Configure Vault integration and install a specific version of the Vault CLI
+
+```tf
+module "vault" {
+ source = "registry.coder.com/modules/vault-github/coder"
+ version = "1.0.7"
+ agent_id = coder_agent.example.id
+ vault_addr = "https://vault.example.com"
+ vault_cli_version = "1.15.0"
+}
+```
diff --git a/vault-github/main.test.ts b/vault-github/main.test.ts
new file mode 100644
index 0000000..25934c8
--- /dev/null
+++ b/vault-github/main.test.ts
@@ -0,0 +1,11 @@
+import { describe } from "bun:test";
+import { runTerraformInit, testRequiredVariables } from "../test";
+
+describe("vault-github", async () => {
+ await runTerraformInit(import.meta.dir);
+
+ testRequiredVariables(import.meta.dir, {
+ agent_id: "foo",
+ vault_addr: "foo",
+ });
+});
diff --git a/vault-github/main.tf b/vault-github/main.tf
new file mode 100644
index 0000000..286025a
--- /dev/null
+++ b/vault-github/main.tf
@@ -0,0 +1,68 @@
+terraform {
+ required_version = ">= 1.0"
+
+ required_providers {
+ coder = {
+ source = "coder/coder"
+ version = ">= 0.12.4"
+ }
+ }
+}
+
+# Add required variables for your modules and remove any unneeded variables
+variable "agent_id" {
+ type = string
+ description = "The ID of a Coder agent."
+}
+
+variable "vault_addr" {
+ type = string
+ description = "The address of the Vault server."
+}
+
+variable "coder_github_auth_id" {
+ type = string
+ description = "The ID of the GitHub external auth."
+ default = "github"
+}
+
+variable "vault_github_auth_path" {
+ type = string
+ description = "The path to the GitHub auth method."
+ default = "github"
+}
+
+variable "vault_cli_version" {
+ type = string
+ description = "The version of Vault to install."
+ default = "latest"
+ validation {
+ condition = can(regex("^(latest|[0-9]+\\.[0-9]+\\.[0-9]+)$", var.vault_cli_version))
+ error_message = "Vault version must be in the format 0.0.0 or latest"
+ }
+}
+
+data "coder_workspace" "me" {}
+
+resource "coder_script" "vault" {
+ agent_id = var.agent_id
+ display_name = "Vault (GitHub)"
+ icon = "/icon/vault.svg"
+ script = templatefile("${path.module}/run.sh", {
+ AUTH_PATH : var.vault_github_auth_path,
+ GITHUB_EXTERNAL_AUTH_ID : data.coder_external_auth.github.id,
+ INSTALL_VERSION : var.vault_cli_version,
+ })
+ run_on_start = true
+ start_blocks_login = true
+}
+
+resource "coder_env" "vault_addr" {
+ agent_id = var.agent_id
+ name = "VAULT_ADDR"
+ value = var.vault_addr
+}
+
+data "coder_external_auth" "github" {
+ id = var.coder_github_auth_id
+}
diff --git a/vault-github/run.sh b/vault-github/run.sh
new file mode 100644
index 0000000..8ca96c0
--- /dev/null
+++ b/vault-github/run.sh
@@ -0,0 +1,119 @@
+#!/usr/bin/env bash
+
+# Convert all templated variables to shell variables
+INSTALL_VERSION=${INSTALL_VERSION}
+GITHUB_EXTERNAL_AUTH_ID=${GITHUB_EXTERNAL_AUTH_ID}
+AUTH_PATH=${AUTH_PATH}
+
+fetch() {
+ dest="$1"
+ url="$2"
+ if command -v curl > /dev/null 2>&1; then
+ curl -sSL --fail "$${url}" -o "$${dest}"
+ elif command -v wget > /dev/null 2>&1; then
+ wget -O "$${dest}" "$${url}"
+ elif command -v busybox > /dev/null 2>&1; then
+ busybox wget -O "$${dest}" "$${url}"
+ else
+ printf "curl, wget, or busybox is not installed. Please install curl or wget in your image.\n"
+ exit 1
+ fi
+}
+
+unzip_safe() {
+ if command -v unzip > /dev/null 2>&1; then
+ command unzip "$@"
+ elif command -v busybox > /dev/null 2>&1; then
+ busybox unzip "$@"
+ else
+ printf "unzip or busybox is not installed. Please install unzip in your image.\n"
+ exit 1
+ fi
+}
+
+install() {
+ # Get the architecture of the system
+ ARCH=$(uname -m)
+ if [ "$${ARCH}" = "x86_64" ]; then
+ ARCH="amd64"
+ elif [ "$${ARCH}" = "aarch64" ]; then
+ ARCH="arm64"
+ else
+ printf "Unsupported architecture: $${ARCH}\n"
+ return 1
+ fi
+ # Fetch the latest version of Vault if INSTALL_VERSION is 'latest'
+ if [ "$${INSTALL_VERSION}" = "latest" ]; then
+ LATEST_VERSION=$(curl -s https://releases.hashicorp.com/vault/ | grep -v 'rc' | grep -oE 'vault/[0-9]+\.[0-9]+\.[0-9]+' | sed 's/vault\///' | sort -V | tail -n 1)
+ printf "Latest version of Vault is %s.\n\n" "$${LATEST_VERSION}"
+ if [ -z "$${LATEST_VERSION}" ]; then
+ printf "Failed to determine the latest Vault version.\n"
+ return 1
+ fi
+ INSTALL_VERSION=$${LATEST_VERSION}
+ fi
+
+ # Check if the vault CLI is installed and has the correct version
+ installation_needed=1
+ if command -v vault > /dev/null 2>&1; then
+ CURRENT_VERSION=$(vault version | grep -oE '[0-9]+\.[0-9]+\.[0-9]+')
+ if [ "$${CURRENT_VERSION}" = "$${INSTALL_VERSION}" ]; then
+ printf "Vault version %s is already installed and up-to-date.\n\n" "$${CURRENT_VERSION}"
+ installation_needed=0
+ fi
+ fi
+
+ if [ $${installation_needed} -eq 1 ]; then
+ # Download and install Vault
+ if [ -z "$${CURRENT_VERSION}" ]; then
+ printf "Installing Vault CLI ...\n\n"
+ else
+ printf "Upgrading Vault CLI from version %s to %s ...\n\n" "$${CURRENT_VERSION}" "${INSTALL_VERSION}"
+ fi
+ fetch vault.zip "https://releases.hashicorp.com/vault/$${INSTALL_VERSION}/vault_$${INSTALL_VERSION}_linux_$${ARCH}.zip"
+ if [ $? -ne 0 ]; then
+ printf "Failed to download Vault.\n"
+ return 1
+ fi
+ if ! unzip_safe vault.zip; then
+ printf "Failed to unzip Vault.\n"
+ return 1
+ fi
+ rm vault.zip
+ if sudo mv vault /usr/local/bin/vault 2> /dev/null; then
+ printf "Vault installed successfully!\n\n"
+ else
+ mkdir -p ~/.local/bin
+ if ! mv vault ~/.local/bin/vault; then
+ printf "Failed to move Vault to local bin.\n"
+ return 1
+ fi
+ printf "Please add ~/.local/bin to your PATH to use vault CLI.\n"
+ fi
+ fi
+ return 0
+}
+
+TMP=$(mktemp -d)
+if ! (
+ cd "$TMP"
+ install
+); then
+ echo "Failed to install Vault CLI."
+ exit 1
+fi
+rm -rf "$TMP"
+
+# Authenticate with Vault
+printf "🔑 Authenticating with Vault ...\n\n"
+GITHUB_TOKEN=$(coder external-auth access-token "$${GITHUB_EXTERNAL_AUTH_ID}")
+if [ $? -ne 0 ]; then
+ printf "Authentication with Vault failed. Please check your credentials.\n"
+ exit 1
+fi
+
+# Login to vault using the GitHub token
+printf "🔑 Logging in to Vault ...\n\n"
+vault login -no-print -method=github -path=/$${AUTH_PATH} token="$${GITHUB_TOKEN}"
+printf "🥳 Vault authentication complete!\n\n"
+printf "You can now use Vault CLI to access secrets.\n"
diff --git a/vault-token/README.md b/vault-token/README.md
new file mode 100644
index 0000000..7e632a5
--- /dev/null
+++ b/vault-token/README.md
@@ -0,0 +1,83 @@
+---
+display_name: Hashicorp Vault Integration (Token)
+description: Authenticates with Vault using Token
+icon: ../.icons/vault.svg
+maintainer_github: coder
+partner_github: hashicorp
+verified: true
+tags: [helper, integration, vault, token]
+---
+
+# Hashicorp Vault Integration (Token)
+
+This module lets you authenticate with [Hashicorp Vault](https://www.vaultproject.io/) in your Coder workspaces using a [Vault token](https://developer.hashicorp.com/vault/docs/auth/token).
+
+```tf
+variable "vault_token" {
+ type = string
+ description = "The Vault token to use for authentication."
+ sensitive = true
+}
+
+module "vault" {
+ source = "registry.coder.com/modules/vault-token/coder"
+ version = "1.0.7"
+ agent_id = coder_agent.example.id
+ vault_token = var.token
+ vault_addr = "https://vault.example.com"
+}
+```
+
+Then you can use the Vault CLI in your workspaces to fetch secrets from Vault:
+
+```shell
+vault kv get -namespace=coder -mount=secrets coder
+```
+
+or using the Vault API:
+
+```shell
+curl -H "X-Vault-Token: ${VAULT_TOKEN}" -X GET "${VAULT_ADDR}/v1/coder/secrets/data/coder"
+```
+
+## Configuration
+
+To configure the Vault module, you must create a Vault token with the the required permissions and configure the module with the token and Vault address.
+
+1. Create a vault policy with read access to the secret mount you need your developers to access.
+ ```shell
+ vault policy write read-coder-secrets - < {
+ await runTerraformInit(import.meta.dir);
+
+ testRequiredVariables(import.meta.dir, {
+ agent_id: "foo",
+ vault_addr: "foo",
+ vault_token: "foo",
+ });
+});
diff --git a/vault-token/main.tf b/vault-token/main.tf
new file mode 100644
index 0000000..94517d1
--- /dev/null
+++ b/vault-token/main.tf
@@ -0,0 +1,62 @@
+terraform {
+ required_version = ">= 1.0"
+
+ required_providers {
+ coder = {
+ source = "coder/coder"
+ version = ">= 0.12.4"
+ }
+ }
+}
+
+# Add required variables for your modules and remove any unneeded variables
+variable "agent_id" {
+ type = string
+ description = "The ID of a Coder agent."
+}
+
+variable "vault_addr" {
+ type = string
+ description = "The address of the Vault server."
+}
+
+variable "vault_token" {
+ type = string
+ description = "The Vault token to use for authentication."
+ sensitive = true
+}
+
+variable "vault_cli_version" {
+ type = string
+ description = "The version of Vault to install."
+ default = "latest"
+ validation {
+ condition = can(regex("^(latest|[0-9]+\\.[0-9]+\\.[0-9]+)$", var.vault_cli_version))
+ error_message = "Vault version must be in the format 0.0.0 or latest"
+ }
+}
+
+data "coder_workspace" "me" {}
+
+resource "coder_script" "vault" {
+ agent_id = var.agent_id
+ display_name = "Vault (Token)"
+ icon = "/icon/vault.svg"
+ script = templatefile("${path.module}/run.sh", {
+ INSTALL_VERSION : var.vault_cli_version,
+ })
+ run_on_start = true
+ start_blocks_login = true
+}
+
+resource "coder_env" "vault_addr" {
+ agent_id = var.agent_id
+ name = "VAULT_ADDR"
+ value = var.vault_addr
+}
+
+resource "coder_env" "vault_token" {
+ agent_id = var.agent_id
+ name = "VAULT_TOKEN"
+ value = var.vault_token
+}
diff --git a/vault-token/run.sh b/vault-token/run.sh
new file mode 100644
index 0000000..e1da6ee
--- /dev/null
+++ b/vault-token/run.sh
@@ -0,0 +1,103 @@
+#!/usr/bin/env bash
+
+# Convert all templated variables to shell variables
+INSTALL_VERSION=${INSTALL_VERSION}
+
+fetch() {
+ dest="$1"
+ url="$2"
+ if command -v curl > /dev/null 2>&1; then
+ curl -sSL --fail "$${url}" -o "$${dest}"
+ elif command -v wget > /dev/null 2>&1; then
+ wget -O "$${dest}" "$${url}"
+ elif command -v busybox > /dev/null 2>&1; then
+ busybox wget -O "$${dest}" "$${url}"
+ else
+ printf "curl, wget, or busybox is not installed. Please install curl or wget in your image.\n"
+ return 1
+ fi
+}
+
+unzip_safe() {
+ if command -v unzip > /dev/null 2>&1; then
+ command unzip "$@"
+ elif command -v busybox > /dev/null 2>&1; then
+ busybox unzip "$@"
+ else
+ printf "unzip or busybox is not installed. Please install unzip in your image.\n"
+ return 1
+ fi
+}
+
+install() {
+ # Get the architecture of the system
+ ARCH=$(uname -m)
+ if [ "$${ARCH}" = "x86_64" ]; then
+ ARCH="amd64"
+ elif [ "$${ARCH}" = "aarch64" ]; then
+ ARCH="arm64"
+ else
+ printf "Unsupported architecture: $${ARCH}\n"
+ return 1
+ fi
+ # Fetch the latest version of Vault if INSTALL_VERSION is 'latest'
+ if [ "$${INSTALL_VERSION}" = "latest" ]; then
+ LATEST_VERSION=$(curl -s https://releases.hashicorp.com/vault/ | grep -v 'rc' | grep -oE 'vault/[0-9]+\.[0-9]+\.[0-9]+' | sed 's/vault\///' | sort -V | tail -n 1)
+ printf "Latest version of Vault is %s.\n\n" "$${LATEST_VERSION}"
+ if [ -z "$${LATEST_VERSION}" ]; then
+ printf "Failed to determine the latest Vault version.\n"
+ return 1
+ fi
+ INSTALL_VERSION=$${LATEST_VERSION}
+ fi
+
+ # Check if the vault CLI is installed and has the correct version
+ installation_needed=1
+ if command -v vault > /dev/null 2>&1; then
+ CURRENT_VERSION=$(vault version | grep -oE '[0-9]+\.[0-9]+\.[0-9]+')
+ if [ "$${CURRENT_VERSION}" = "$${INSTALL_VERSION}" ]; then
+ printf "Vault version %s is already installed and up-to-date.\n\n" "$${CURRENT_VERSION}"
+ installation_needed=0
+ fi
+ fi
+
+ if [ $${installation_needed} -eq 1 ]; then
+ # Download and install Vault
+ if [ -z "$${CURRENT_VERSION}" ]; then
+ printf "Installing Vault CLI ...\n\n"
+ else
+ printf "Upgrading Vault CLI from version %s to %s ...\n\n" "$${CURRENT_VERSION}" "${INSTALL_VERSION}"
+ fi
+ fetch vault.zip "https://releases.hashicorp.com/vault/$${INSTALL_VERSION}/vault_$${INSTALL_VERSION}_linux_amd64.zip"
+ if [ $? -ne 0 ]; then
+ printf "Failed to download Vault.\n"
+ return 1
+ fi
+ if ! unzip_safe vault.zip; then
+ printf "Failed to unzip Vault.\n"
+ return 1
+ fi
+ rm vault.zip
+ if sudo mv vault /usr/local/bin/vault 2> /dev/null; then
+ printf "Vault installed successfully!\n\n"
+ else
+ mkdir -p ~/.local/bin
+ if ! mv vault ~/.local/bin/vault; then
+ printf "Failed to move Vault to local bin.\n"
+ return 1
+ fi
+ printf "Please add ~/.local/bin to your PATH to use vault CLI.\n"
+ fi
+ fi
+ return 0
+}
+
+TMP=$(mktemp -d)
+if ! (
+ cd "$TMP"
+ install
+); then
+ echo "Failed to install Vault CLI."
+ exit 1
+fi
+rm -rf "$TMP"
diff --git a/vscode-desktop/README.md b/vscode-desktop/README.md
index 4ec37a1..e0d1ff2 100644
--- a/vscode-desktop/README.md
+++ b/vscode-desktop/README.md
@@ -4,6 +4,7 @@ description: Add a one-click button to launch VS Code Desktop
icon: ../.icons/code.svg
maintainer_github: coder
verified: true
+tags: [ide, vscode, helper]
---
# VS Code Desktop
@@ -11,3 +12,24 @@ verified: true
Add a button to open any workspace with a single click.
Uses the [Coder Remote VS Code Extension](https://github.com/coder/vscode-coder).
+
+```tf
+module "vscode" {
+ source = "registry.coder.com/modules/vscode-desktop/coder"
+ version = "1.0.8"
+ agent_id = coder_agent.example.id
+}
+```
+
+## Examples
+
+### Open in a specific directory
+
+```tf
+module "vscode" {
+ source = "registry.coder.com/modules/vscode-desktop/coder"
+ version = "1.0.8"
+ agent_id = coder_agent.example.id
+ folder = "/home/coder/project"
+}
+```
diff --git a/vscode-desktop/main.test.ts b/vscode-desktop/main.test.ts
new file mode 100644
index 0000000..53fba96
--- /dev/null
+++ b/vscode-desktop/main.test.ts
@@ -0,0 +1,37 @@
+import { describe, expect, it } from "bun:test";
+import {
+ executeScriptInContainer,
+ runTerraformApply,
+ runTerraformInit,
+ testRequiredVariables,
+} from "../test";
+
+describe("vscode-desktop", async () => {
+ await runTerraformInit(import.meta.dir);
+
+ testRequiredVariables(import.meta.dir, {
+ agent_id: "foo",
+ });
+
+ it("default output", async () => {
+ const state = await runTerraformApply(import.meta.dir, {
+ agent_id: "foo",
+ });
+ expect(state.outputs.vscode_url.value).toBe(
+ "vscode://coder.coder-remote/open?owner=default&workspace=default&token=$SESSION_TOKEN",
+ );
+
+ const resources: any = state.resources;
+ expect(resources[1].instances[0].attributes.order).toBeNull();
+ });
+
+ it("expect order to be set", async () => {
+ const state = await runTerraformApply(import.meta.dir, {
+ agent_id: "foo",
+ order: "22",
+ });
+
+ const resources: any = state.resources;
+ expect(resources[1].instances[0].attributes.order).toBe(22);
+ });
+});
diff --git a/vscode-desktop/main.tf b/vscode-desktop/main.tf
index fb588b9..7a0a052 100644
--- a/vscode-desktop/main.tf
+++ b/vscode-desktop/main.tf
@@ -4,7 +4,7 @@ terraform {
required_providers {
coder = {
source = "coder/coder"
- version = ">= 0.12"
+ version = ">= 0.17"
}
}
}
@@ -14,19 +14,47 @@ variable "agent_id" {
description = "The ID of a Coder agent."
}
+variable "folder" {
+ type = string
+ description = "The folder to open in VS Code."
+ default = ""
+}
+
+variable "order" {
+ type = number
+ description = "The order determines the position of app in the UI presentation. The lowest order is shown first and apps with equal order are sorted by name (ascending order)."
+ default = null
+}
+
data "coder_workspace" "me" {}
resource "coder_app" "vscode" {
- agent_id = var.agent_id
- external = true
- icon = "/icons/code.svg"
- slug = "vscode"
- url = join("", [
- "vscode://coder.coder-remote/open?owner=",
- data.coder_workspace.me.owner,
- "&workspace=",
- data.coder_workspace.me.name,
- "&token=",
- data.coder_workspace.me.owner_session_token,
- ])
-}
+ agent_id = var.agent_id
+ external = true
+ icon = "/icon/code.svg"
+ slug = "vscode"
+ display_name = "VS Code Desktop"
+ order = var.order
+ url = var.folder != "" ? join("", [
+ "vscode://coder.coder-remote/open?owner=",
+ data.coder_workspace.me.owner,
+ "&workspace=",
+ data.coder_workspace.me.name,
+ "&folder=",
+ var.folder,
+ "&url=",
+ data.coder_workspace.me.access_url,
+ "&token=$SESSION_TOKEN",
+ ]) : join("", [
+ "vscode://coder.coder-remote/open?owner=",
+ data.coder_workspace.me.owner,
+ "&workspace=",
+ data.coder_workspace.me.name,
+ "&token=$SESSION_TOKEN",
+ ])
+}
+
+output "vscode_url" {
+ value = coder_app.vscode.url
+ description = "VS Code Desktop URL."
+}
diff --git a/vscode-web/README.md b/vscode-web/README.md
new file mode 100644
index 0000000..ba395d0
--- /dev/null
+++ b/vscode-web/README.md
@@ -0,0 +1,67 @@
+---
+display_name: VS Code Web
+description: VS Code Web - Visual Studio Code in the browser
+icon: ../.icons/code.svg
+maintainer_github: coder
+verified: true
+tags: [helper, ide, vscode, web]
+---
+
+# VS Code Web
+
+Automatically install [Visual Studio Code Server](https://code.visualstudio.com/docs/remote/vscode-server) in a workspace and create an app to access it via the dashboard.
+
+```tf
+module "vscode-web" {
+ source = "registry.coder.com/modules/vscode-web/coder"
+ version = "1.0.14"
+ agent_id = coder_agent.example.id
+ accept_license = true
+}
+```
+
+
+
+## Examples
+
+### Install VS Code Web to a custom folder
+
+```tf
+module "vscode-web" {
+ source = "registry.coder.com/modules/vscode-web/coder"
+ version = "1.0.14"
+ agent_id = coder_agent.example.id
+ install_prefix = "/home/coder/.vscode-web"
+ folder = "/home/coder"
+ accept_license = true
+}
+```
+
+### Install Extensions
+
+```tf
+module "vscode-web" {
+ source = "registry.coder.com/modules/vscode-web/coder"
+ version = "1.0.14"
+ agent_id = coder_agent.example.id
+ extensions = ["github.copilot", "ms-python.python", "ms-toolsai.jupyter"]
+ accept_license = true
+}
+```
+
+### Pre-configure Settings
+
+Configure VS Code's [settings.json](https://code.visualstudio.com/docs/getstarted/settings#_settingsjson) file:
+
+```tf
+module "vscode-web" {
+ source = "registry.coder.com/modules/vscode-web/coder"
+ version = "1.0.14"
+ agent_id = coder_agent.example.id
+ extensions = ["dracula-theme.theme-dracula"]
+ settings = {
+ "workbench.colorTheme" = "Dracula"
+ }
+ accept_license = true
+}
+```
diff --git a/vscode-web/main.test.ts b/vscode-web/main.test.ts
new file mode 100644
index 0000000..d8e0e68
--- /dev/null
+++ b/vscode-web/main.test.ts
@@ -0,0 +1,42 @@
+import { describe, expect, it } from "bun:test";
+import { runTerraformApply, runTerraformInit } from "../test";
+
+describe("vscode-web", async () => {
+ await runTerraformInit(import.meta.dir);
+
+ it("accept_license should be set to true", () => {
+ const t = async () => {
+ await runTerraformApply(import.meta.dir, {
+ agent_id: "foo",
+ accept_license: "false",
+ });
+ };
+ expect(t).toThrow("Invalid value for variable");
+ });
+
+ it("use_cached and offline can not be used together", () => {
+ const t = async () => {
+ await runTerraformApply(import.meta.dir, {
+ agent_id: "foo",
+ accept_license: "true",
+ use_cached: "true",
+ offline: "true",
+ });
+ };
+ expect(t).toThrow("Offline and Use Cached can not be used together");
+ });
+
+ it("offline and extensions can not be used together", () => {
+ const t = async () => {
+ await runTerraformApply(import.meta.dir, {
+ agent_id: "foo",
+ accept_license: "true",
+ offline: "true",
+ extensions: '["1", "2"]',
+ });
+ };
+ expect(t).toThrow("Offline mode does not allow extensions to be installed");
+ });
+
+ // More tests depend on shebang refactors
+});
diff --git a/vscode-web/main.tf b/vscode-web/main.tf
new file mode 100644
index 0000000..084f830
--- /dev/null
+++ b/vscode-web/main.tf
@@ -0,0 +1,172 @@
+terraform {
+ required_version = ">= 1.0"
+
+ required_providers {
+ coder = {
+ source = "coder/coder"
+ version = ">= 0.17"
+ }
+ }
+}
+
+variable "agent_id" {
+ type = string
+ description = "The ID of a Coder agent."
+}
+
+variable "port" {
+ type = number
+ description = "The port to run VS Code Web on."
+ default = 13338
+}
+
+variable "display_name" {
+ type = string
+ description = "The display name for the VS Code Web application."
+ default = "VS Code Web"
+}
+
+variable "slug" {
+ type = string
+ description = "The slug for the VS Code Web application."
+ default = "vscode-web"
+}
+
+variable "folder" {
+ type = string
+ description = "The folder to open in vscode-web."
+ default = ""
+}
+
+variable "share" {
+ type = string
+ default = "owner"
+ validation {
+ condition = var.share == "owner" || var.share == "authenticated" || var.share == "public"
+ error_message = "Incorrect value. Please set either 'owner', 'authenticated', or 'public'."
+ }
+}
+
+variable "log_path" {
+ type = string
+ description = "The path to log."
+ default = "/tmp/vscode-web.log"
+}
+
+variable "install_prefix" {
+ type = string
+ description = "The prefix to install vscode-web to."
+ default = "/tmp/vscode-web"
+}
+
+variable "extensions" {
+ type = list(string)
+ description = "A list of extensions to install."
+ default = []
+}
+
+variable "accept_license" {
+ type = bool
+ description = "Accept the VS Code Server license. https://code.visualstudio.com/license/server"
+ default = false
+ validation {
+ condition = var.accept_license == true
+ error_message = "You must accept the VS Code license agreement by setting accept_license=true."
+ }
+}
+
+variable "telemetry_level" {
+ type = string
+ description = "Set the telemetry level for VS Code Web."
+ default = "error"
+ validation {
+ condition = var.telemetry_level == "off" || var.telemetry_level == "crash" || var.telemetry_level == "error" || var.telemetry_level == "all"
+ error_message = "Incorrect value. Please set either 'off', 'crash', 'error', or 'all'."
+ }
+}
+
+variable "order" {
+ type = number
+ description = "The order determines the position of app in the UI presentation. The lowest order is shown first and apps with equal order are sorted by name (ascending order)."
+ default = null
+}
+
+variable "settings" {
+ type = map(string)
+ description = "A map of settings to apply to VS Code web."
+ default = {}
+}
+
+variable "offline" {
+ type = bool
+ description = "Just run VS Code Web in the background, don't fetch it from the internet."
+ default = false
+}
+
+variable "use_cached" {
+ type = bool
+ description = "Uses cached copy of VS Code Web in the background, otherwise fetches it from internet."
+ default = false
+}
+
+variable "extensions_dir" {
+ type = string
+ description = "Override the directory to store extensions in."
+ default = ""
+}
+
+variable "auto_install_extensions" {
+ type = bool
+ description = "Automatically install recommended extensions when VS Code Web starts."
+ default = false
+}
+
+resource "coder_script" "vscode-web" {
+ agent_id = var.agent_id
+ display_name = "VS Code Web"
+ icon = "/icon/code.svg"
+ script = templatefile("${path.module}/run.sh", {
+ PORT : var.port,
+ LOG_PATH : var.log_path,
+ INSTALL_PREFIX : var.install_prefix,
+ EXTENSIONS : join(",", var.extensions),
+ TELEMETRY_LEVEL : var.telemetry_level,
+ // This is necessary otherwise the quotes are stripped!
+ SETTINGS : replace(jsonencode(var.settings), "\"", "\\\""),
+ OFFLINE : var.offline,
+ USE_CACHED : var.use_cached,
+ EXTENSIONS_DIR : var.extensions_dir,
+ FOLDER : var.folder,
+ AUTO_INSTALL_EXTENSIONS : var.auto_install_extensions,
+ })
+ run_on_start = true
+
+ lifecycle {
+ precondition {
+ condition = !var.offline || length(var.extensions) == 0
+ error_message = "Offline mode does not allow extensions to be installed"
+ }
+
+ precondition {
+ condition = !var.offline || !var.use_cached
+ error_message = "Offline and Use Cached can not be used together"
+ }
+ }
+}
+
+resource "coder_app" "vscode-web" {
+ agent_id = var.agent_id
+ slug = var.slug
+ display_name = var.display_name
+ url = var.folder == "" ? "http://localhost:${var.port}" : "http://localhost:${var.port}?folder=${var.folder}"
+ icon = "/icon/code.svg"
+ subdomain = true
+ share = var.share
+ order = var.order
+
+ healthcheck {
+ url = "http://localhost:${var.port}/healthz"
+ interval = 5
+ threshold = 6
+ }
+}
diff --git a/vscode-web/run.sh b/vscode-web/run.sh
new file mode 100755
index 0000000..ce8782f
--- /dev/null
+++ b/vscode-web/run.sh
@@ -0,0 +1,99 @@
+#!/usr/bin/env bash
+
+BOLD='\033[0;1m'
+EXTENSIONS=("${EXTENSIONS}")
+VSCODE_WEB="${INSTALL_PREFIX}/bin/code-server"
+
+# Set extension directory
+EXTENSION_ARG=""
+if [ -n "${EXTENSIONS_DIR}" ]; then
+ EXTENSION_ARG="--extensions-dir=${EXTENSIONS_DIR}"
+fi
+
+run_vscode_web() {
+ echo "👷 Running $VSCODE_WEB serve-local $EXTENSION_ARG --port ${PORT} --host 127.0.0.1 --accept-server-license-terms --without-connection-token --telemetry-level ${TELEMETRY_LEVEL} in the background..."
+ echo "Check logs at ${LOG_PATH}!"
+ "$VSCODE_WEB" serve-local "$EXTENSION_ARG" --port "${PORT}" --host 127.0.0.1 --accept-server-license-terms --without-connection-token --telemetry-level "${TELEMETRY_LEVEL}" > "${LOG_PATH}" 2>&1 &
+}
+
+# Check if the settings file exists...
+if [ ! -f ~/.vscode-server/data/Machine/settings.json ]; then
+ echo "⚙️ Creating settings file..."
+ mkdir -p ~/.vscode-server/data/Machine
+ echo "${SETTINGS}" > ~/.vscode-server/data/Machine/settings.json
+fi
+
+# Check if vscode-server is already installed for offline or cached mode
+if [ -f "$VSCODE_WEB" ]; then
+ if [ "${OFFLINE}" = true ] || [ "${USE_CACHED}" = true ]; then
+ echo "🥳 Found a copy of VS Code Web"
+ run_vscode_web
+ exit 0
+ fi
+fi
+# Offline mode always expects a copy of vscode-server to be present
+if [ "${OFFLINE}" = true ]; then
+ echo "Failed to find a copy of VS Code Web"
+ exit 1
+fi
+
+# Create install prefix
+mkdir -p ${INSTALL_PREFIX}
+
+printf "$${BOLD}Installing Microsoft Visual Studio Code Server!\n"
+
+# Download and extract vscode-server
+ARCH=$(uname -m)
+case "$ARCH" in
+ x86_64) ARCH="x64" ;;
+ aarch64) ARCH="arm64" ;;
+ *)
+ echo "Unsupported architecture"
+ exit 1
+ ;;
+esac
+
+HASH=$(curl -fsSL https://update.code.visualstudio.com/api/commits/stable/server-linux-$ARCH-web | cut -d '"' -f 2)
+output=$(curl -fsSL https://vscode.download.prss.microsoft.com/dbazure/download/stable/$HASH/vscode-server-linux-$ARCH-web.tar.gz | tar -xz -C ${INSTALL_PREFIX} --strip-components 1)
+
+if [ $? -ne 0 ]; then
+ echo "Failed to install Microsoft Visual Studio Code Server: $output"
+ exit 1
+fi
+printf "$${BOLD}VS Code Web has been installed.\n"
+
+# Install each extension...
+IFS=',' read -r -a EXTENSIONLIST <<< "$${EXTENSIONS}"
+for extension in "$${EXTENSIONLIST[@]}"; do
+ if [ -z "$extension" ]; then
+ continue
+ fi
+ printf "🧩 Installing extension $${CODE}$extension$${RESET}...\n"
+ output=$($VSCODE_WEB "$EXTENSION_ARG" --install-extension "$extension" --force)
+ if [ $? -ne 0 ]; then
+ echo "Failed to install extension: $extension: $output"
+ exit 1
+ fi
+done
+
+if [ "${AUTO_INSTALL_EXTENSIONS}" = true ]; then
+ if ! command -v jq > /dev/null; then
+ echo "jq is required to install extensions from a workspace file."
+ exit 0
+ fi
+
+ WORKSPACE_DIR="$HOME"
+ if [ -n "${FOLDER}" ]; then
+ WORKSPACE_DIR="${FOLDER}"
+ fi
+
+ if [ -f "$WORKSPACE_DIR/.vscode/extensions.json" ]; then
+ printf "🧩 Installing extensions from %s/.vscode/extensions.json...\n" "$WORKSPACE_DIR"
+ extensions=$(jq -r '.recommendations[]' "$WORKSPACE_DIR"/.vscode/extensions.json)
+ for extension in $extensions; do
+ $VSCODE_WEB "$EXTENSION_ARG" --install-extension "$extension" --force
+ done
+ fi
+fi
+
+run_vscode_web