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/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/.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/CONTRIBUTING.md b/CONTRIBUTING.md index 6aa77cf..557171e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,6 +10,8 @@ To create a new module, clone this repository and run: 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 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 +} +``` + +![Airflow](../.images/airflow.png) 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 934714b..4d363c3 100644 --- a/aws-region/README.md +++ b/aws-region/README.md @@ -17,7 +17,7 @@ Customize the preselected parameter value: ```tf module "aws-region" { source = "registry.coder.com/modules/aws-region/coder" - version = "1.0.10" + version = "1.0.12" default = "us-east-1" } @@ -37,7 +37,7 @@ Change the display name and icon for a region using the corresponding maps: ```tf module "aws-region" { source = "registry.coder.com/modules/aws-region/coder" - version = "1.0.10" + version = "1.0.12" default = "ap-south-1" custom_names = { @@ -63,7 +63,7 @@ Hide the Asia Pacific regions Seoul and Osaka: ```tf module "aws-region" { source = "registry.coder.com/modules/aws-region/coder" - version = "1.0.10" + version = "1.0.12" exclude = ["ap-northeast-2", "ap-northeast-3"] } diff --git a/aws-region/main.test.ts b/aws-region/main.test.ts index 42433df..0693e65 100644 --- a/aws-region/main.test.ts +++ b/aws-region/main.test.ts @@ -22,4 +22,13 @@ describe("aws-region", async () => { }); 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 7594320..12a01fe 100644 --- a/aws-region/main.tf +++ b/aws-region/main.tf @@ -51,6 +51,12 @@ variable "exclude" { type = list(string) } +variable "coder_parameter_order" { + type = number + description = "The order determines the position of a template parameter in the UI/CLI presentation. The lowest order is shown first and parameters with equal order are sorted by name (ascending order)." + default = null +} + locals { # This is a static list because the regions don't change _that_ # frequently and including the `aws_regions` data source requires @@ -176,6 +182,7 @@ data "coder_parameter" "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)) } diff --git a/azure-region/README.md b/azure-region/README.md index d88a2e9..cd0efd3 100644 --- a/azure-region/README.md +++ b/azure-region/README.md @@ -14,7 +14,7 @@ This module adds a parameter with all Azure regions, allowing developers to sele ```tf module "azure_region" { source = "registry.coder.com/modules/azure-region/coder" - version = "1.0.2" + version = "1.0.12" default = "eastus" } @@ -34,7 +34,7 @@ Change the display name and icon for a region using the corresponding maps: ```tf module "azure-region" { source = "registry.coder.com/modules/azure-region/coder" - version = "1.0.2" + version = "1.0.12" custom_names = { "australia" : "Go Australia!" } @@ -57,7 +57,7 @@ Hide all regions in Australia except australiacentral: ```tf module "azure-region" { source = "registry.coder.com/modules/azure-region/coder" - version = "1.0.2" + version = "1.0.12" exclude = [ "australia", "australiacentral2", diff --git a/azure-region/main.test.ts b/azure-region/main.test.ts index 0e41e29..bebc0c9 100644 --- a/azure-region/main.test.ts +++ b/azure-region/main.test.ts @@ -22,4 +22,13 @@ describe("azure-region", async () => { }); 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 307d61d..3d1c2f1 100644 --- a/azure-region/main.tf +++ b/azure-region/main.tf @@ -50,6 +50,12 @@ variable "exclude" { type = list(string) } +variable "coder_parameter_order" { + type = number + description = "The order determines the position of a template parameter in the UI/CLI presentation. The lowest order is shown first and parameters with equal order are sorted by name (ascending order)." + default = null +} + locals { # Note: Options are limited to 64 regions, some redundant regions have been removed. all_regions = { @@ -309,6 +315,7 @@ data "coder_parameter" "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" { diff --git a/code-server/README.md b/code-server/README.md index 993b9ba..e1ca7a2 100644 --- a/code-server/README.md +++ b/code-server/README.md @@ -14,7 +14,7 @@ Automatically install [code-server](https://github.com/coder/code-server) in a w ```tf module "code-server" { source = "registry.coder.com/modules/code-server/coder" - version = "1.0.10" + version = "1.0.15" agent_id = coder_agent.example.id } ``` @@ -28,7 +28,7 @@ module "code-server" { ```tf module "code-server" { source = "registry.coder.com/modules/code-server/coder" - version = "1.0.10" + version = "1.0.15" agent_id = coder_agent.example.id install_version = "4.8.3" } @@ -41,7 +41,7 @@ Install the Dracula theme from [OpenVSX](https://open-vsx.org/): ```tf module "code-server" { source = "registry.coder.com/modules/code-server/coder" - version = "1.0.10" + version = "1.0.15" agent_id = coder_agent.example.id extensions = [ "dracula-theme.theme-dracula" @@ -58,7 +58,7 @@ Configure VS Code's [settings.json](https://code.visualstudio.com/docs/getstarte ```tf module "code-server" { source = "registry.coder.com/modules/code-server/coder" - version = "1.0.10" + version = "1.0.15" agent_id = coder_agent.example.id extensions = ["dracula-theme.theme-dracula"] settings = { @@ -74,7 +74,7 @@ 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.10" + version = "1.0.15" agent_id = coder_agent.example.id extensions = ["dracula-theme.theme-dracula", "ms-azuretools.vscode-docker"] } @@ -89,7 +89,7 @@ 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.10" + version = "1.0.15" agent_id = coder_agent.example.id use_cached = true extensions = ["dracula-theme.theme-dracula", "ms-azuretools.vscode-docker"] @@ -101,7 +101,7 @@ 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.10" + version = "1.0.15" agent_id = coder_agent.example.id offline = true } diff --git a/code-server/main.tf b/code-server/main.tf index 30b92bc..30b705c 100644 --- a/code-server/main.tf +++ b/code-server/main.tf @@ -95,6 +95,24 @@ variable "use_cached" { default = false } +variable "use_cached_extensions" { + type = bool + description = "Uses cached copy of extensions, otherwise do a forced upgrade" + default = false +} + +variable "extensions_dir" { + type = string + description = "Override the directory to store extensions in." + default = "" +} + +variable "auto_install_extensions" { + type = bool + description = "Automatically install recommended extensions when code-server starts." + default = false +} + resource "coder_script" "code-server" { agent_id = var.agent_id display_name = "code-server" @@ -110,6 +128,10 @@ resource "coder_script" "code-server" { SETTINGS : replace(jsonencode(var.settings), "\"", "\\\""), OFFLINE : var.offline, USE_CACHED : var.use_cached, + USE_CACHED_EXTENSIONS : var.use_cached_extensions, + EXTENSIONS_DIR : var.extensions_dir, + FOLDER : var.folder, + AUTO_INSTALL_EXTENSIONS : var.auto_install_extensions, }) run_on_start = true diff --git a/code-server/run.sh b/code-server/run.sh index 2444324..8e068b8 100755 --- a/code-server/run.sh +++ b/code-server/run.sh @@ -6,10 +6,16 @@ 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 --auth none --port "${PORT}" --app-name "${APP_NAME}" > "${LOG_PATH}" 2>&1 & + $CODE_SERVER "$EXTENSION_ARG" --auth none --port "${PORT}" --app-name "${APP_NAME}" > "${LOG_PATH}" 2>&1 & } # Check if the settings file exists... @@ -19,36 +25,53 @@ if [ ! -f ~/.local/share/code-server/User/settings.json ]; then 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 +# Check if code-server is already installed for offline +if [ "${OFFLINE}" = true ]; then + if [ -f "$CODE_SERVER" ]; then echo "🥳 Found a copy of code-server" run_code_server exit 0 fi -fi -# Offline mode always expects a copy of code-server to be present -if [ "${OFFLINE}" = true ]; then + # Offline mode always expects a copy of code-server to be present echo "Failed to find a copy of code-server" exit 1 fi -printf "$${BOLD}Installing code-server!\n" +# If there is no cached install OR we don't want to use a cached install +if [ ! -f "$CODE_SERVER" ] || [ "${USE_CACHED}" != true ]; then + printf "$${BOLD}Installing code-server!\n" -ARGS=( - "--method=standalone" - "--prefix=${INSTALL_PREFIX}" -) -if [ -n "${VERSION}" ]; then - ARGS+=("--version=${VERSION}") -fi + ARGS=( + "--method=standalone" + "--prefix=${INSTALL_PREFIX}" + ) + if [ -n "${VERSION}" ]; then + ARGS+=("--version=${VERSION}") + fi -output=$(curl -fsSL https://code-server.dev/install.sh | sh -s -- "$${ARGS[@]}") -if [ $? -ne 0 ]; then - echo "Failed to install code-server: $output" - exit 1 + output=$(curl -fsSL https://code-server.dev/install.sh | sh -s -- "$${ARGS[@]}") + if [ $? -ne 0 ]; then + echo "Failed to install code-server: $output" + exit 1 + fi + printf "🥳 code-server has been installed in ${INSTALL_PREFIX}\n\n" fi -printf "🥳 code-server has been installed in ${INSTALL_PREFIX}\n\n" + +# Get the list of installed extensions... +LIST_EXTENSIONS=$($CODE_SERVER --list-extensions $EXTENSION_ARG) +readarray -t EXTENSIONS_ARRAY <<< "$LIST_EXTENSIONS" +function extension_installed() { + if [ "${USE_CACHED_EXTENSIONS}" != true ]; then + return 1 + fi + for _extension in "$${EXTENSIONS_ARRAY[@]}"; do + if [ "$_extension" == "$1" ]; then + echo "Extension $1 was already installed." + return 0 + fi + done + return 1 +} # Install each extension... IFS=',' read -r -a EXTENSIONLIST <<< "$${EXTENSIONS}" @@ -56,12 +79,38 @@ for extension in "$${EXTENSIONLIST[@]}"; do if [ -z "$extension" ]; then continue fi + if extension_installed "$extension"; then + continue + fi printf "🧩 Installing extension $${CODE}$extension$${RESET}...\n" - output=$($CODE_SERVER --install-extension "$extension") + output=$($CODE_SERVER "$EXTENSION_ARG" --force --install-extension "$extension") if [ $? -ne 0 ]; then echo "Failed to install extension: $extension: $output" exit 1 fi done +if [ "${AUTO_INSTALL_EXTENSIONS}" = true ]; then + if ! command -v jq > /dev/null; then + echo "jq is required to install extensions from a workspace file." + exit 0 + fi + + WORKSPACE_DIR="$HOME" + if [ -n "${FOLDER}" ]; then + WORKSPACE_DIR="${FOLDER}" + fi + + if [ -f "$WORKSPACE_DIR/.vscode/extensions.json" ]; then + printf "🧩 Installing extensions from %s/.vscode/extensions.json...\n" "$WORKSPACE_DIR" + extensions=$(jq -r '.recommendations[]' "$WORKSPACE_DIR"/.vscode/extensions.json) + for extension in $extensions; do + if extension_installed "$extension"; then + continue + fi + $CODE_SERVER "$EXTENSION_ARG" --force --install-extension "$extension" + done + fi +fi + run_code_server diff --git a/coder-login/README.md b/coder-login/README.md index d68e088..c9bb333 100644 --- a/coder-login/README.md +++ b/coder-login/README.md @@ -14,7 +14,7 @@ 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" + version = "1.0.15" agent_id = coder_agent.example.id } ``` diff --git a/coder-login/main.tf b/coder-login/main.tf index 58d1bf0..0db33a8 100644 --- a/coder-login/main.tf +++ b/coder-login/main.tf @@ -4,7 +4,7 @@ terraform { required_providers { coder = { source = "coder/coder" - version = ">= 0.12" + version = ">= 0.23" } } } @@ -15,11 +15,12 @@ variable "agent_id" { } data "coder_workspace" "me" {} +data "coder_workspace_owner" "me" {} resource "coder_script" "coder-login" { agent_id = var.agent_id script = templatefile("${path.module}/run.sh", { - CODER_USER_TOKEN : data.coder_workspace.me.owner_session_token, + CODER_USER_TOKEN : data.coder_workspace_owner.me.session_token, CODER_DEPLOYMENT_URL : data.coder_workspace.me.access_url }) display_name = "Coder Login" diff --git a/dotfiles/README.md b/dotfiles/README.md index eb64563..41371ab 100644 --- a/dotfiles/README.md +++ b/dotfiles/README.md @@ -9,12 +9,70 @@ tags: [helper] # Dotfiles -Allow developers to optionally bring their own [dotfiles repository](https://dotfiles.github.io)! Under the hood, this module uses the [coder dotfiles](https://coder.com/docs/v2/latest/dotfiles) command. +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.15" + 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.15" + 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.15" + 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.2" + version = "1.0.15" agent_id = coder_agent.example.id } + +module "dotfiles-root" { + source = "registry.coder.com/modules/dotfiles/coder" + version = "1.0.15" + 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.15" + 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 index 69eda32..6026719 100644 --- a/dotfiles/main.test.ts +++ b/dotfiles/main.test.ts @@ -18,4 +18,23 @@ describe("dotfiles", async () => { }); 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 index c0b0135..bfb67e4 100644 --- a/dotfiles/main.tf +++ b/dotfiles/main.tf @@ -14,24 +14,55 @@ variable "agent_id" { 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)" - default = "" + display_name = "Dotfiles URL" + order = var.coder_parameter_order + default = var.default_dotfiles_uri description = "Enter a URL for a [dotfiles repository](https://dotfiles.github.io) to personalize your workspace" mutable = true icon = "/icon/dotfiles.svg" } -resource "coder_script" "personalize" { - agent_id = var.agent_id - script = <<-EOT - DOTFILES_URI="${data.coder_parameter.dotfiles_uri.value}" - if [ -n "$${DOTFILES_URI// }" ]; then - coder dotfiles "$DOTFILES_URI" -y 2>&1 | tee -a ~/.dotfiles.log - fi - EOT +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 @@ -39,5 +70,5 @@ resource "coder_script" "personalize" { output "dotfiles_uri" { description = "Dotfiles URI" - value = data.coder_parameter.dotfiles_uri.value -} \ No newline at end of file + 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 index 4b493f9..4296121 100644 --- a/exoscale-instance-type/README.md +++ b/exoscale-instance-type/README.md @@ -17,7 +17,7 @@ Customize the preselected parameter value: ```tf module "exoscale-instance-type" { source = "registry.coder.com/modules/exoscale-instance-type/coder" - version = "1.0.2" + version = "1.0.12" default = "standard.medium" } @@ -45,7 +45,7 @@ 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.2" + version = "1.0.12" default = "standard.medium" custom_names = { @@ -79,7 +79,7 @@ Show only gpu1 types ```tf module "exoscale-instance-type" { source = "registry.coder.com/modules/exoscale-instance-type/coder" - version = "1.0.2" + version = "1.0.12" default = "gpu.large" type_category = ["gpu"] exclude = [ diff --git a/exoscale-instance-type/main.test.ts b/exoscale-instance-type/main.test.ts index eeb6745..e4b998b 100644 --- a/exoscale-instance-type/main.test.ts +++ b/exoscale-instance-type/main.test.ts @@ -31,4 +31,13 @@ describe("exoscale-instance-type", async () => { }); }).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 index f7c8998..65d3729 100644 --- a/exoscale-instance-type/main.tf +++ b/exoscale-instance-type/main.tf @@ -56,6 +56,12 @@ variable "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 { # https://www.exoscale.com/pricing/ @@ -257,6 +263,7 @@ data "coder_parameter" "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( diff --git a/exoscale-zone/README.md b/exoscale-zone/README.md index 4297bed..0f4353e 100644 --- a/exoscale-zone/README.md +++ b/exoscale-zone/README.md @@ -17,7 +17,7 @@ Customize the preselected parameter value: ```tf module "exoscale-zone" { source = "registry.coder.com/modules/exoscale-zone/coder" - version = "1.0.2" + version = "1.0.12" default = "ch-dk-2" } @@ -44,7 +44,7 @@ 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.2" + version = "1.0.12" default = "at-vie-1" custom_names = { @@ -76,7 +76,7 @@ Hide the Switzerland zones Geneva and Zurich ```tf module "exoscale-zone" { source = "registry.coder.com/modules/exoscale-zone/coder" - version = "1.0.2" + version = "1.0.12" exclude = ["ch-gva-2", "ch-dk-2"] } diff --git a/exoscale-zone/main.test.ts b/exoscale-zone/main.test.ts index 7c423e7..ca8eeb7 100644 --- a/exoscale-zone/main.test.ts +++ b/exoscale-zone/main.test.ts @@ -22,4 +22,13 @@ describe("exoscale-zone", async () => { }); 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 index 01f1467..090acb4 100644 --- a/exoscale-zone/main.tf +++ b/exoscale-zone/main.tf @@ -51,6 +51,11 @@ variable "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_ @@ -94,6 +99,7 @@ data "coder_parameter" "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)) } diff --git a/gcp-region/README.md b/gcp-region/README.md index bb6063a..776d638 100644 --- a/gcp-region/README.md +++ b/gcp-region/README.md @@ -14,7 +14,7 @@ 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.2" + version = "1.0.12" regions = ["us", "europe"] } @@ -34,7 +34,7 @@ Note: setting `gpu_only = true` and using a default region without GPU support, ```tf module "gcp_region" { source = "registry.coder.com/modules/gcp-region/coder" - version = "1.0.2" + version = "1.0.12" default = ["us-west1-a"] regions = ["us-west1"] gpu_only = false @@ -50,7 +50,7 @@ resource "google_compute_instance" "example" { ```tf module "gcp_region" { source = "registry.coder.com/modules/gcp-region/coder" - version = "1.0.2" + version = "1.0.12" regions = ["europe-west"] single_zone_per_region = false } @@ -65,7 +65,7 @@ resource "google_compute_instance" "example" { ```tf module "gcp_region" { source = "registry.coder.com/modules/gcp-region/coder" - version = "1.0.2" + version = "1.0.12" regions = ["us", "europe"] gpu_only = true single_zone_per_region = true diff --git a/gcp-region/main.test.ts b/gcp-region/main.test.ts index 2ec623b..bf01c2b 100644 --- a/gcp-region/main.test.ts +++ b/gcp-region/main.test.ts @@ -40,4 +40,13 @@ describe("gcp-region", async () => { }); 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 e9f549d..0a75924 100644 --- a/gcp-region/main.tf +++ b/gcp-region/main.tf @@ -63,6 +63,12 @@ variable "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 @@ -715,6 +721,7 @@ data "coder_parameter" "region" { 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 diff --git a/git-clone/README.md b/git-clone/README.md index 054e30c..255b3f1 100644 --- a/git-clone/README.md +++ b/git-clone/README.md @@ -14,7 +14,7 @@ This module allows you to automatically clone a repository by URL and skip if it ```tf module "git-clone" { source = "registry.coder.com/modules/git-clone/coder" - version = "1.0.2" + version = "1.0.12" agent_id = coder_agent.example.id url = "https://github.com/coder/coder" } @@ -27,7 +27,7 @@ module "git-clone" { ```tf module "git-clone" { source = "registry.coder.com/modules/git-clone/coder" - version = "1.0.2" + version = "1.0.12" agent_id = coder_agent.example.id url = "https://github.com/coder/coder" base_dir = "~/projects/coder" @@ -41,7 +41,7 @@ To use with [Git Authentication](https://coder.com/docs/v2/latest/admin/git-prov ```tf module "git-clone" { source = "registry.coder.com/modules/git-clone/coder" - version = "1.0.2" + version = "1.0.12" agent_id = coder_agent.example.id url = "https://github.com/coder/coder" } @@ -50,3 +50,106 @@ 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 index 0c3dd54..87b0e4a 100644 --- a/git-clone/main.test.ts +++ b/git-clone/main.test.ts @@ -36,4 +36,196 @@ describe("git-clone", async () => { "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 index c1e65cf..4af5000 100644 --- a/git-clone/main.tf +++ b/git-clone/main.tf @@ -25,8 +25,50 @@ variable "agent_id" { 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 { - clone_path = var.base_dir != "" ? join("/", [var.base_dir, replace(basename(var.url), ".git", "")]) : join("/", ["~", replace(basename(var.url), ".git", "")]) + # 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" { @@ -34,11 +76,37 @@ output "repo_dir" { 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 : var.url, + CLONE_PATH = local.clone_path, + REPO_URL : local.clone_url, + BRANCH_NAME : local.branch_name, }) display_name = "Git Clone" icon = "/icon/git.svg" diff --git a/git-clone/run.sh b/git-clone/run.sh index df647a1..bd80717 100755 --- a/git-clone/run.sh +++ b/git-clone/run.sh @@ -2,6 +2,7 @@ REPO_URL="${REPO_URL}" CLONE_PATH="${CLONE_PATH}" +BRANCH_NAME="${BRANCH_NAME}" # Expand home if it's specified! CLONE_PATH="$${CLONE_PATH/#\~/$${HOME}}" @@ -33,8 +34,13 @@ fi # Check if the directory is empty # and if it is, clone the repo, otherwise skip cloning if [ -z "$(ls -A "$CLONE_PATH")" ]; then - echo "Cloning $REPO_URL to $CLONE_PATH..." - git clone "$REPO_URL" "$CLONE_PATH" + 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 diff --git a/git-commit-signing/README.md b/git-commit-signing/README.md index 47bc324..37633a2 100644 --- a/git-commit-signing/README.md +++ b/git-commit-signing/README.md @@ -19,7 +19,7 @@ This module has a chance of conflicting with the user's dotfiles / the personali ```tf module "git-commit-signing" { source = "registry.coder.com/modules/git-commit-signing/coder" - version = "1.0.9" + version = "1.0.11" agent_id = coder_agent.example.id } ``` diff --git a/git-config/README.md b/git-config/README.md index 9b76658..90e8442 100644 --- a/git-config/README.md +++ b/git-config/README.md @@ -14,7 +14,7 @@ Runs a script that updates git credentials in the workspace to match the user's ```tf module "git-config" { source = "registry.coder.com/modules/git-config/coder" - version = "1.0.3" + version = "1.0.15" agent_id = coder_agent.example.id } ``` @@ -28,7 +28,7 @@ TODO: Add screenshot ```tf module "git-config" { source = "registry.coder.com/modules/git-config/coder" - version = "1.0.3" + version = "1.0.15" agent_id = coder_agent.example.id allow_email_change = true } @@ -41,7 +41,7 @@ TODO: Add screenshot ```tf module "git-config" { source = "registry.coder.com/modules/git-config/coder" - version = "1.0.3" + version = "1.0.15" 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..e702c6e --- /dev/null +++ b/git-config/main.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from "bun:test"; +import { + runTerraformApply, + runTerraformInit, + testRequiredVariables, +} from "../test"; + +describe("git-config", async () => { + await runTerraformInit(import.meta.dir); + + testRequiredVariables(import.meta.dir, { + agent_id: "foo", + }); + + it("can run apply allow_username_change and allow_email_change disabled", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + allow_username_change: "false", + allow_email_change: "false", + }); + + const resources = state.resources; + expect(resources).toHaveLength(6); + expect(resources).toMatchObject([ + { type: "coder_workspace", name: "me" }, + { type: "coder_workspace_owner", name: "me" }, + { type: "coder_env", name: "git_author_email" }, + { type: "coder_env", name: "git_author_name" }, + { type: "coder_env", name: "git_commmiter_email" }, + { type: "coder_env", name: "git_commmiter_name" }, + ]); + }); + + it("can run apply allow_email_change enabled", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + allow_email_change: "true", + }); + + const resources = state.resources; + expect(resources).toHaveLength(8); + expect(resources).toMatchObject([ + { type: "coder_parameter", name: "user_email" }, + { type: "coder_parameter", name: "username" }, + { type: "coder_workspace", name: "me" }, + { type: "coder_workspace_owner", name: "me" }, + { type: "coder_env", name: "git_author_email" }, + { type: "coder_env", name: "git_author_name" }, + { type: "coder_env", name: "git_commmiter_email" }, + { type: "coder_env", name: "git_commmiter_name" }, + ]); + }); + + it("can run apply allow_email_change enabled", async () => { + const state = await runTerraformApply( + import.meta.dir, + { + agent_id: "foo", + allow_username_change: "false", + allow_email_change: "false", + }, + { CODER_WORKSPACE_OWNER_EMAIL: "foo@email.com" }, + ); + + const resources = state.resources; + expect(resources).toHaveLength(6); + expect(resources).toMatchObject([ + { type: "coder_workspace", name: "me" }, + { type: "coder_workspace_owner", name: "me" }, + { type: "coder_env", name: "git_author_email" }, + { type: "coder_env", name: "git_author_name" }, + { type: "coder_env", name: "git_commmiter_email" }, + { type: "coder_env", name: "git_commmiter_name" }, + ]); + }); + + it("set custom order for coder_parameter for both fields", async () => { + const order = 20; + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + allow_username_change: "true", + allow_email_change: "true", + coder_parameter_order: order.toString(), + }); + const resources = state.resources; + expect(resources).toHaveLength(8); + expect(resources).toMatchObject([ + { type: "coder_parameter", name: "user_email" }, + { type: "coder_parameter", name: "username" }, + { type: "coder_workspace", name: "me" }, + { type: "coder_workspace_owner", name: "me" }, + { type: "coder_env", name: "git_author_email" }, + { type: "coder_env", name: "git_author_name" }, + { type: "coder_env", name: "git_commmiter_email" }, + { type: "coder_env", name: "git_commmiter_name" }, + ]); + // user_email order is the same as the order + expect(resources[0].instances[0].attributes.order).toBe(order); + // username order is incremented by 1 + // @ts-ignore: Object is possibly 'null'. + expect(resources[1].instances[0]?.attributes.order).toBe(order + 1); + }); + + it("set custom order for coder_parameter for just username", async () => { + const order = 30; + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + allow_email_change: "false", + allow_username_change: "true", + coder_parameter_order: order.toString(), + }); + const resources = state.resources; + expect(resources).toHaveLength(7); + expect(resources).toMatchObject([ + { type: "coder_parameter", name: "username" }, + { type: "coder_workspace", name: "me" }, + { type: "coder_workspace_owner", name: "me" }, + { type: "coder_env", name: "git_author_email" }, + { type: "coder_env", name: "git_author_name" }, + { type: "coder_env", name: "git_commmiter_email" }, + { type: "coder_env", name: "git_commmiter_name" }, + ]); + // user_email was not created + // username order is incremented by 1 + expect(resources[0].instances[0].attributes.order).toBe(order + 1); + }); +}); diff --git a/git-config/main.tf b/git-config/main.tf index d92a0b7..e8fea8f 100644 --- a/git-config/main.tf +++ b/git-config/main.tf @@ -4,7 +4,7 @@ terraform { required_providers { coder = { source = "coder/coder" - version = ">= 0.13" + version = ">= 0.23" } } } @@ -26,14 +26,21 @@ variable "allow_email_change" { default = false } +variable "coder_parameter_order" { + type = number + description = "The order determines the position of a template parameter in the UI/CLI presentation. The lowest order is shown first and parameters with equal order are sorted by name (ascending order)." + default = null +} data "coder_workspace" "me" {} +data "coder_workspace_owner" "me" {} data "coder_parameter" "user_email" { count = var.allow_email_change ? 1 : 0 name = "user_email" type = "string" default = "" + order = var.coder_parameter_order != null ? var.coder_parameter_order + 0 : null description = "Git user.email to be used for commits. Leave empty to default to Coder user's email." display_name = "Git config user.email" mutable = true @@ -44,6 +51,7 @@ data "coder_parameter" "username" { 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 @@ -52,23 +60,25 @@ data "coder_parameter" "username" { 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) + value = coalesce(try(data.coder_parameter.username[0].value, ""), data.coder_workspace_owner.me.full_name, data.coder_workspace_owner.me.name) } resource "coder_env" "git_commmiter_name" { agent_id = var.agent_id name = "GIT_COMMITTER_NAME" - value = coalesce(try(data.coder_parameter.username[0].value, ""), data.coder_workspace.me.owner_name, data.coder_workspace.me.owner) + value = coalesce(try(data.coder_parameter.username[0].value, ""), data.coder_workspace_owner.me.full_name, data.coder_workspace_owner.me.name) } resource "coder_env" "git_author_email" { agent_id = var.agent_id name = "GIT_AUTHOR_EMAIL" - value = coalesce(try(data.coder_parameter.user_email[0].value, ""), data.coder_workspace.me.owner_email) + value = coalesce(try(data.coder_parameter.user_email[0].value, ""), data.coder_workspace_owner.me.email) + count = data.coder_workspace_owner.me.email != "" ? 1 : 0 } resource "coder_env" "git_commmiter_email" { agent_id = var.agent_id name = "GIT_COMMITTER_EMAIL" - value = coalesce(try(data.coder_parameter.user_email[0].value, ""), data.coder_workspace.me.owner_email) + value = coalesce(try(data.coder_parameter.user_email[0].value, ""), data.coder_workspace_owner.me.email) + count = data.coder_workspace_owner.me.email != "" ? 1 : 0 } diff --git a/github-upload-public-key/README.md b/github-upload-public-key/README.md new file mode 100644 index 0000000..17464f3 --- /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.15" + agent_id = coder_agent.example.id +} +``` + +# Requirements + +This module requires `curl` and `jq` to be installed inside your workspace. + +Github External Auth must be enabled in the workspace for this module to work. The Github app that is configured for external auth must have both read and write permissions to "Git SSH keys" in order to upload the public key. Additionally, a Coder admin must also have the `admin:public_key` scope added to the external auth configuration of the Coder deployment. For example: + +``` +CODER_EXTERNAL_AUTH_0_ID="USER_DEFINED_ID" +CODER_EXTERNAL_AUTH_0_TYPE=github +CODER_EXTERNAL_AUTH_0_CLIENT_ID=xxxxxx +CODER_EXTERNAL_AUTH_0_CLIENT_SECRET=xxxxxxx +CODER_EXTERNAL_AUTH_0_SCOPES="repo,workflow,admin:public_key" +``` + +Note that the default scopes if not provided are `repo,workflow`. If the module is failing to complete after updating the external auth configuration, instruct users of the module to "Unlink" and "Link" their Github account in the External Auth user settings page to get the new scopes. + +# Example + +Using a coder github external auth with a non-default id: (default is `github`) + +```tf +data "coder_external_auth" "github" { + id = "myauthid" +} + +module "github-upload-public-key" { + source = "registry.coder.com/modules/github-upload-public-key/coder" + version = "1.0.15" + agent_id = coder_agent.example.id + external_auth_id = data.coder_external_auth.github.id +} +``` diff --git a/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..b527400 --- /dev/null +++ b/github-upload-public-key/main.tf @@ -0,0 +1,43 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 0.23" + } + } +} + +variable "agent_id" { + type = string + description = "The ID of a Coder agent." +} + +variable "external_auth_id" { + type = string + description = "The ID of the GitHub external auth." + default = "github" +} + +variable "github_api_url" { + type = string + description = "The URL of the GitHub instance." + default = "https://api.github.com" +} + +data "coder_workspace" "me" {} +data "coder_workspace_owner" "me" {} + +resource "coder_script" "github_upload_public_key" { + agent_id = var.agent_id + script = templatefile("${path.module}/run.sh", { + CODER_OWNER_SESSION_TOKEN : data.coder_workspace_owner.me.session_token, + CODER_ACCESS_URL : data.coder_workspace.me.access_url, + CODER_EXTERNAL_AUTH_ID : var.external_auth_id, + GITHUB_API_URL : var.github_api_url, + }) + display_name = "Github Upload Public Key" + icon = "/icon/github.svg" + run_on_start = true +} \ No newline at end of file diff --git a/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/jetbrains-gateway/README.md b/jetbrains-gateway/README.md index e5cb77f..b2c0e0f 100644 --- a/jetbrains-gateway/README.md +++ b/jetbrains-gateway/README.md @@ -14,7 +14,7 @@ This module adds a JetBrains Gateway Button to open any workspace with a single ```tf module "jetbrains_gateway" { source = "registry.coder.com/modules/jetbrains-gateway/coder" - version = "1.0.9" + version = "1.0.13" agent_id = coder_agent.example.id agent_name = "example" folder = "/home/coder/example" @@ -27,12 +27,12 @@ module "jetbrains_gateway" { ## Examples -### Add GoLand and WebStorm with the default set to GoLand +### Add GoLand and WebStorm as options with the default set to GoLand ```tf module "jetbrains_gateway" { source = "registry.coder.com/modules/jetbrains-gateway/coder" - version = "1.0.9" + version = "1.0.13" agent_id = coder_agent.example.id agent_name = "example" folder = "/home/coder/example" @@ -41,6 +41,37 @@ module "jetbrains_gateway" { } ``` +### 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 +} +``` + +### Use the latest EAP 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 + channel = "eap" +} +``` + ## Supported IDEs This module and JetBrains Gateway support the following JetBrains IDEs: diff --git a/jetbrains-gateway/main.tf b/jetbrains-gateway/main.tf index 273c712..c96098c 100644 --- a/jetbrains-gateway/main.tf +++ b/jetbrains-gateway/main.tf @@ -6,6 +6,10 @@ terraform { source = "coder/coder" version = ">= 0.17" } + http = { + source = "hashicorp/http" + version = ">= 3.0" + } } } @@ -46,6 +50,22 @@ variable "coder_parameter_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 @@ -54,36 +74,36 @@ variable "jetbrains_ide_versions" { description = "The set of versions for each jetbrains IDE" default = { "IU" = { - build_number = "233.14808.21" - version = "2023.3.5" + build_number = "241.14494.240" + version = "2024.1" } "PS" = { - build_number = "233.14808.18" - version = "2023.3.5" + build_number = "241.14494.237" + version = "2024.1" } "WS" = { - build_number = "233.14475.40" - version = "2023.3.4" + build_number = "241.14494.235" + version = "2024.1" } "PY" = { - build_number = "233.14475.56" - version = "2023.3.4" + build_number = "241.14494.241" + version = "2024.1" } "CL" = { - build_number = "233.14475.31" - version = "2023.3.4" + build_number = "241.14494.288" + version = "2024.1" } "GO" = { - build_number = "233.14808.20" - version = "2023.3.5" + build_number = "241.14494.238" + version = "2024.1" } "RM" = { - build_number = "233.14808.14" - version = "2023.3.5" + build_number = "241.14494.234" + version = "2024.1" } "RD" = { - build_number = "233.14475.66" - version = "2023.3.4" + build_number = "241.14494.307" + version = "2024.1" } } validation { @@ -120,6 +140,11 @@ variable "jetbrains_ides" { } } +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 { jetbrains_ides = { "GO" = { @@ -128,6 +153,7 @@ locals { 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", @@ -135,6 +161,7 @@ locals { 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", @@ -142,6 +169,7 @@ locals { 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", @@ -149,6 +177,7 @@ locals { 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 }, "CL" = { icon = "/icon/clion.svg", @@ -156,6 +185,7 @@ locals { 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", @@ -163,6 +193,7 @@ locals { 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", @@ -170,6 +201,7 @@ locals { 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", @@ -177,8 +209,18 @@ locals { 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" { @@ -193,9 +235,9 @@ data "coder_parameter" "jetbrains_ide" { dynamic "option" { for_each = var.jetbrains_ides content { - icon = lookup(local.jetbrains_ides, option.value).icon - name = lookup(local.jetbrains_ides, option.value).name - value = lookup(local.jetbrains_ides, option.value).identifier + icon = local.jetbrains_ides[option.value].icon + name = local.jetbrains_ides[option.value].name + value = option.value } } } @@ -205,8 +247,8 @@ data "coder_workspace" "me" {} resource "coder_app" "gateway" { agent_id = var.agent_id slug = "gateway" - display_name = try(lookup(local.jetbrains_ides, data.coder_parameter.jetbrains_ide.value).name, "JetBrains IDE") - icon = try(lookup(local.jetbrains_ides, data.coder_parameter.jetbrains_ide.value).icon, "/icon/gateway.svg") + display_name = local.display_name + icon = local.icon external = true order = var.order url = join("", [ @@ -221,36 +263,36 @@ resource "coder_app" "gateway" { "&token=", "$SESSION_TOKEN", "&ide_product_code=", - local.jetbrains_ides[data.coder_parameter.jetbrains_ide.value].identifier, + data.coder_parameter.jetbrains_ide.value, "&ide_build_number=", - local.jetbrains_ides[data.coder_parameter.jetbrains_ide.value].build_number, + local.build_number, "&ide_download_link=", - local.jetbrains_ides[data.coder_parameter.jetbrains_ide.value].download_link + local.download_link, ]) } output "identifier" { - value = data.coder_parameter.jetbrains_ide.value + value = local.identifier } -output "name" { - value = coder_app.gateway.display_name +output "display_name" { + value = local.display_name } output "icon" { - value = coder_app.gateway.icon + value = local.icon } output "download_link" { - value = lookup(local.jetbrains_ides, data.coder_parameter.jetbrains_ide.value).download_link + value = local.download_link } output "build_number" { - value = lookup(local.jetbrains_ides, data.coder_parameter.jetbrains_ide.value).build_number + value = local.build_number } output "version" { - value = var.jetbrains_ide_versions[data.coder_parameter.jetbrains_ide.value].version + value = local.version } output "url" { diff --git a/jfrog-oauth/README.md b/jfrog-oauth/README.md index 60b3bb7..b7f9d58 100644 --- a/jfrog-oauth/README.md +++ b/jfrog-oauth/README.md @@ -17,7 +17,7 @@ Install the JF CLI and authenticate package managers with Artifactory using OAut ```tf module "jfrog" { source = "registry.coder.com/modules/jfrog-oauth/coder" - version = "1.0.5" + version = "1.0.15" 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" @@ -44,7 +44,7 @@ Configure the Python pip package manager to fetch packages from Artifactory whil ```tf module "jfrog" { source = "registry.coder.com/modules/jfrog-oauth/coder" - version = "1.0.5" + version = "1.0.15" agent_id = coder_agent.example.id jfrog_url = "https://example.jfrog.io" username_field = "email" @@ -72,7 +72,7 @@ The [JFrog extension](https://open-vsx.org/extension/JFrog/jfrog-vscode-extensio ```tf module "jfrog" { source = "registry.coder.com/modules/jfrog-oauth/coder" - version = "1.0.5" + version = "1.0.15" 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" diff --git a/jfrog-oauth/main.tf b/jfrog-oauth/main.tf index 70fd0e8..767235a 100644 --- a/jfrog-oauth/main.tf +++ b/jfrog-oauth/main.tf @@ -4,7 +4,7 @@ terraform { required_providers { coder = { source = "coder/coder" - version = ">= 0.12.4" + version = ">= 0.23" } } } @@ -68,11 +68,12 @@ EOF locals { # The username field to use for artifactory - username = var.username_field == "email" ? data.coder_workspace.me.owner_email : data.coder_workspace.me.owner + username = var.username_field == "email" ? data.coder_workspace_owner.me.email : data.coder_workspace_owner.me.name jfrog_host = replace(var.jfrog_url, "https://", "") } data "coder_workspace" "me" {} +data "coder_workspace_owner" "me" {} data "coder_external_auth" "jfrog" { id = var.external_auth_id @@ -87,7 +88,7 @@ resource "coder_script" "jfrog" { 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_EMAIL : data.coder_workspace_owner.me.email, ARTIFACTORY_ACCESS_TOKEN : data.coder_external_auth.jfrog.access_token, CONFIGURE_CODE_SERVER : var.configure_code_server, REPOSITORY_NPM : lookup(var.package_managers, "npm", ""), diff --git a/jfrog-token/README.md b/jfrog-token/README.md index 0bd4781..f903f90 100644 --- a/jfrog-token/README.md +++ b/jfrog-token/README.md @@ -15,7 +15,7 @@ Install the JF CLI and authenticate package managers with Artifactory using Arti ```tf module "jfrog" { source = "registry.coder.com/modules/jfrog-token/coder" - version = "1.0.10" + version = "1.0.15" agent_id = coder_agent.example.id jfrog_url = "https://XXXX.jfrog.io" artifactory_access_token = var.artifactory_access_token @@ -41,7 +41,7 @@ For detailed instructions, please see this [guide](https://coder.com/docs/v2/lat ```tf module "jfrog" { source = "registry.coder.com/modules/jfrog-token/coder" - version = "1.0.10" + version = "1.0.15" agent_id = coder_agent.example.id jfrog_url = "https://YYYY.jfrog.io" artifactory_access_token = var.artifactory_access_token # An admin access token @@ -74,7 +74,7 @@ The [JFrog extension](https://open-vsx.org/extension/JFrog/jfrog-vscode-extensio ```tf module "jfrog" { source = "registry.coder.com/modules/jfrog-token/coder" - version = "1.0.10" + version = "1.0.15" agent_id = coder_agent.example.id jfrog_url = "https://XXXX.jfrog.io" artifactory_access_token = var.artifactory_access_token @@ -94,11 +94,11 @@ data "coder_workspace" "me" {} module "jfrog" { source = "registry.coder.com/modules/jfrog-token/coder" - version = "1.0.10" + version = "1.0.15" 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}" + token_description = "Token for Coder workspace: ${data.coder_workspace_owner.me.name}/${data.coder_workspace.me.name}" package_managers = { "npm" : "npm", "go" : "go", diff --git a/jfrog-token/main.tf b/jfrog-token/main.tf index d1f99f1..90dad61 100644 --- a/jfrog-token/main.tf +++ b/jfrog-token/main.tf @@ -4,7 +4,7 @@ terraform { required_providers { coder = { source = "coder/coder" - version = ">= 0.12.4" + version = ">= 0.23" } artifactory = { source = "registry.terraform.io/jfrog/artifactory" @@ -95,7 +95,7 @@ EOF locals { # The username field to use for artifactory - username = var.username_field == "email" ? data.coder_workspace.me.owner_email : data.coder_workspace.me.owner + username = var.username_field == "email" ? data.coder_workspace_owner.me.email : data.coder_workspace_owner.me.name jfrog_host = replace(var.jfrog_url, "https://", "") } @@ -117,6 +117,7 @@ resource "artifactory_scoped_token" "me" { } data "coder_workspace" "me" {} +data "coder_workspace_owner" "me" {} resource "coder_script" "jfrog" { agent_id = var.agent_id @@ -127,7 +128,7 @@ resource "coder_script" "jfrog" { 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_EMAIL : data.coder_workspace_owner.me.email, ARTIFACTORY_ACCESS_TOKEN : artifactory_scoped_token.me.access_token, CONFIGURE_CODE_SERVER : var.configure_code_server, REPOSITORY_NPM : lookup(var.package_managers, "npm", ""), diff --git a/package-lock.json b/package-lock.json index 4828ced..1010942 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "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" }, diff --git a/package.json b/package.json index 5a73d51..f3136b1 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "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" }, @@ -23,4 +24,4 @@ "prettier-plugin-terraform-formatter" ] } -} \ No newline at end of file +} diff --git a/slackme/main.test.ts b/slackme/main.test.ts index 53c6b2e..402a690 100644 --- a/slackme/main.test.ts +++ b/slackme/main.test.ts @@ -8,6 +8,7 @@ import { runTerraformApply, runTerraformInit, testRequiredVariables, + writeCoder, } from "../test"; describe("slackme", async () => { @@ -119,15 +120,6 @@ const setupContainer = async ( return { id, instance }; }; -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); -}; - const assertSlackMessage = async (opts: { command: string; format?: string; diff --git a/test.ts b/test.ts index 8052404..b338205 100644 --- a/test.ts +++ b/test.ts @@ -78,6 +78,14 @@ export const execContainer = async ( }; }; +type JsonValue = + | string + | number + | boolean + | null + | JsonValue[] + | { [key: string]: JsonValue }; + type TerraformStateResource = { type: string; name: string; @@ -170,14 +178,19 @@ export const testRequiredVariables = >( * random state file. */ export const runTerraformApply = async < - TVars extends Readonly>, + TVars extends Readonly>, >( dir: string, vars: TVars, + env?: Record, ): Promise => { const stateFile = `${dir}/${crypto.randomUUID()}.tfstate`; - const env = {}; - Object.keys(vars).forEach((key) => (env[`TF_VAR_${key}`] = vars[key])); + + const combinedEnv = env === undefined ? {} : { ...env }; + for (const [key, value] of Object.entries(vars)) { + combinedEnv[`TF_VAR_${key}`] = String(value); + } + const proc = spawn( [ "terraform", @@ -191,16 +204,18 @@ export const runTerraformApply = async < ], { cwd: dir, - env, + env: combinedEnv, 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); @@ -228,3 +243,12 @@ export const createJSONResponse = (obj: object, statusCode = 200): Response => { 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/vscode-desktop/README.md b/vscode-desktop/README.md index e0d1ff2..bc8920d 100644 --- a/vscode-desktop/README.md +++ b/vscode-desktop/README.md @@ -16,7 +16,7 @@ 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" + version = "1.0.15" agent_id = coder_agent.example.id } ``` @@ -28,7 +28,7 @@ module "vscode" { ```tf module "vscode" { source = "registry.coder.com/modules/vscode-desktop/coder" - version = "1.0.8" + version = "1.0.15" agent_id = coder_agent.example.id folder = "/home/coder/project" } diff --git a/vscode-desktop/main.test.ts b/vscode-desktop/main.test.ts index a3ab6bb..74c4ffb 100644 --- a/vscode-desktop/main.test.ts +++ b/vscode-desktop/main.test.ts @@ -18,11 +18,57 @@ describe("vscode-desktop", async () => { agent_id: "foo", }); expect(state.outputs.vscode_url.value).toBe( - "vscode://coder.coder-remote/open?owner=default&workspace=default&token=$SESSION_TOKEN", + "vscode://coder.coder-remote/open?owner=default&workspace=default&url=https://mydeployment.coder.com&token=$SESSION_TOKEN", ); - const resources = state.resources; - expect(resources[1].instances[0].attributes.order).toBeNull(); + const coder_app = state.resources.find( + (res) => res.type == "coder_app" && res.name == "vscode", + ); + expect(coder_app).not.toBeNull(); + expect(coder_app.instances.length).toBe(1); + expect(coder_app.instances[0].attributes.order).toBeNull(); + }); + + it("adds folder", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + folder: "/foo/bar", + }); + expect(state.outputs.vscode_url.value).toBe( + "vscode://coder.coder-remote/open?owner=default&workspace=default&folder=/foo/bar&url=https://mydeployment.coder.com&token=$SESSION_TOKEN", + ); + }); + + it("adds folder and open_recent", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + folder: "/foo/bar", + open_recent: "true", + }); + expect(state.outputs.vscode_url.value).toBe( + "vscode://coder.coder-remote/open?owner=default&workspace=default&folder=/foo/bar&openRecent&url=https://mydeployment.coder.com&token=$SESSION_TOKEN", + ); + }); + + it("adds folder but not open_recent", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + folder: "/foo/bar", + openRecent: "false", + }); + expect(state.outputs.vscode_url.value).toBe( + "vscode://coder.coder-remote/open?owner=default&workspace=default&folder=/foo/bar&url=https://mydeployment.coder.com&token=$SESSION_TOKEN", + ); + }); + + it("adds open_recent", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + open_recent: "true", + }); + expect(state.outputs.vscode_url.value).toBe( + "vscode://coder.coder-remote/open?owner=default&workspace=default&openRecent&url=https://mydeployment.coder.com&token=$SESSION_TOKEN", + ); }); it("expect order to be set", async () => { @@ -31,7 +77,11 @@ describe("vscode-desktop", async () => { order: "22", }); - const resources = state.resources; - expect(resources[1].instances[0].attributes.order).toBe(22); + const coder_app = state.resources.find( + (res) => res.type == "coder_app" && res.name == "vscode", + ); + expect(coder_app).not.toBeNull(); + expect(coder_app.instances.length).toBe(1); + expect(coder_app.instances[0].attributes.order).toBe(22); }); }); diff --git a/vscode-desktop/main.tf b/vscode-desktop/main.tf index 7a0a052..16d070b 100644 --- a/vscode-desktop/main.tf +++ b/vscode-desktop/main.tf @@ -4,7 +4,7 @@ terraform { required_providers { coder = { source = "coder/coder" - version = ">= 0.17" + version = ">= 0.23" } } } @@ -20,6 +20,12 @@ variable "folder" { default = "" } +variable "open_recent" { + type = bool + description = "Open the most recent workspace or folder. Falls back to the folder if there is no recent workspace or folder to open." + default = false +} + variable "order" { type = number description = "The order determines the position of app in the UI presentation. The lowest order is shown first and apps with equal order are sorted by name (ascending order)." @@ -27,6 +33,7 @@ variable "order" { } data "coder_workspace" "me" {} +data "coder_workspace_owner" "me" {} resource "coder_app" "vscode" { agent_id = var.agent_id @@ -35,22 +42,17 @@ resource "coder_app" "vscode" { 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, + url = join("", [ + "vscode://coder.coder-remote/open", + "?owner=", + data.coder_workspace_owner.me.name, "&workspace=", data.coder_workspace.me.name, - "&folder=", - var.folder, + var.folder != "" ? join("", ["&folder=", var.folder]) : "", + var.open_recent ? "&openRecent" : "", "&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", ]) } diff --git a/vscode-web/README.md b/vscode-web/README.md index c10f156..ba395d0 100644 --- a/vscode-web/README.md +++ b/vscode-web/README.md @@ -14,7 +14,7 @@ Automatically install [Visual Studio Code Server](https://code.visualstudio.com/ ```tf module "vscode-web" { source = "registry.coder.com/modules/vscode-web/coder" - version = "1.0.10" + version = "1.0.14" agent_id = coder_agent.example.id accept_license = true } @@ -29,7 +29,7 @@ module "vscode-web" { ```tf module "vscode-web" { source = "registry.coder.com/modules/vscode-web/coder" - version = "1.0.10" + version = "1.0.14" agent_id = coder_agent.example.id install_prefix = "/home/coder/.vscode-web" folder = "/home/coder" @@ -42,7 +42,7 @@ module "vscode-web" { ```tf module "vscode-web" { source = "registry.coder.com/modules/vscode-web/coder" - version = "1.0.10" + version = "1.0.14" agent_id = coder_agent.example.id extensions = ["github.copilot", "ms-python.python", "ms-toolsai.jupyter"] accept_license = true @@ -56,7 +56,7 @@ Configure VS Code's [settings.json](https://code.visualstudio.com/docs/getstarte ```tf module "vscode-web" { source = "registry.coder.com/modules/vscode-web/coder" - version = "1.0.10" + version = "1.0.14" agent_id = coder_agent.example.id extensions = ["dracula-theme.theme-dracula"] settings = { 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 index dd2ab3b..084f830 100644 --- a/vscode-web/main.tf +++ b/vscode-web/main.tf @@ -97,6 +97,30 @@ variable "settings" { 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" @@ -109,8 +133,25 @@ resource "coder_script" "vscode-web" { 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" { diff --git a/vscode-web/run.sh b/vscode-web/run.sh index 491906f..ce8782f 100755 --- a/vscode-web/run.sh +++ b/vscode-web/run.sh @@ -2,6 +2,40 @@ 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} @@ -26,9 +60,7 @@ if [ $? -ne 0 ]; then echo "Failed to install Microsoft Visual Studio Code Server: $output" exit 1 fi -printf "$${BOLD}Microsoft Visual Studio Code Server has been installed.\n" - -VSCODE_SERVER="${INSTALL_PREFIX}/bin/code-server" +printf "$${BOLD}VS Code Web has been installed.\n" # Install each extension... IFS=',' read -r -a EXTENSIONLIST <<< "$${EXTENSIONS}" @@ -37,20 +69,31 @@ for extension in "$${EXTENSIONLIST[@]}"; do continue fi printf "🧩 Installing extension $${CODE}$extension$${RESET}...\n" - output=$($VSCODE_SERVER --install-extension "$extension" --force) + output=$($VSCODE_WEB "$EXTENSION_ARG" --install-extension "$extension" --force) if [ $? -ne 0 ]; then echo "Failed to install extension: $extension: $output" exit 1 fi done -# 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 +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 -echo "👷 Running ${INSTALL_PREFIX}/bin/code-server serve-local --port ${PORT} --host 127.0.0.1 --accept-server-license-terms serve-local --without-connection-token --telemetry-level ${TELEMETRY_LEVEL} in the background..." -echo "Check logs at ${LOG_PATH}!" -"${INSTALL_PREFIX}/bin/code-server" serve-local --port "${PORT}" --host 127.0.0.1 --accept-server-license-terms serve-local --without-connection-token --telemetry-level "${TELEMETRY_LEVEL}" > "${LOG_PATH}" 2>&1 & +run_vscode_web