From 09873f9d793ea75ed8776a65e23957f3ddb4e044 Mon Sep 17 00:00:00 2001 From: Muhammad Atif Ali Date: Fri, 23 May 2025 15:29:48 +0500 Subject: [PATCH] wip --- registry/coder/modules/jetbrains/README.md | 95 ++++++++ registry/coder/modules/jetbrains/main.test.ts | 86 +++++++ registry/coder/modules/jetbrains/main.tf | 225 ++++++++++++++++++ 3 files changed, 406 insertions(+) create mode 100644 registry/coder/modules/jetbrains/README.md create mode 100644 registry/coder/modules/jetbrains/main.test.ts create mode 100644 registry/coder/modules/jetbrains/main.tf diff --git a/registry/coder/modules/jetbrains/README.md b/registry/coder/modules/jetbrains/README.md new file mode 100644 index 00000000..1e70d3d2 --- /dev/null +++ b/registry/coder/modules/jetbrains/README.md @@ -0,0 +1,95 @@ +--- +display_name: JetBrains IDEs +description: Add a one-click button to launch JetBrains IDEs from the Coder dashboard. +icon: ../.icons/jetbrains.svg +maintainer_github: coder +partner_github: jetbrains +verified: true +tags: [ide, jetbrains, parameter] +--- + +# JetBrains IDEs + +This module adds a JetBrains IDE Button to open any workspace with a single click. + +```tf +module "jetbrains" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/jetbrains/coder" + version = "1.0.0" + agent_id = coder_agent.example.id + folder = "/home/coder/example" + default = "GO" +} +``` + +> [!WARNING] +> JetBrains recommends a minimum of 4 CPU cores and 8GB of RAM. +> Consult the [JetBrains documentation](https://www.jetbrains.com/help/idea/prerequisites.html#min_requirements) to confirm other system requirements. + +![JetBrains IDEs list](../.images/jetbrains-gateway.png) + +## Examples + +### Use the latest version of each IDE + +```tf +module "jetbrains" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/jetbrains/coder" + version = "1.0.0" + agent_id = coder_agent.example.id + folder = "/home/coder/example" + options = ["IU", "PY"] + default = ["IU"] + latest = true +} +``` + +### Use the latest EAP version + +```tf +module "jetbrains" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/jetbrains/coder" + version = "1.0.0" + agent_id = coder_agent.example.id + folder = "/home/coder/example" + options = ["GO", "WS"] + default = ["GO"] + latest = true + channel = "eap" +} +``` + +### Custom base link + +Due to the highest priority of the `ide_download_link` parameter in the `(jetbrains-gateway://...` within IDEA, the pre-configured download address will be overridden when using [IDEA's offline mode](https://www.jetbrains.com/help/idea/fully-offline-mode.html). Therefore, it is necessary to configure the `download_base_link` parameter for the `jetbrains_gateway` module to change the value of `ide_download_link`. + +```tf +module "jetbrains_gateway" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/modules/jetbrains-gateway/coder" + version = "1.0.0" + agent_id = coder_agent.example.id + folder = "/home/coder/example" + options = ["GO", "WS"] + releases_base_link = "https://releases.internal.site/" + download_base_link = "https://download.internal.site/" + default = ["GO"] +} +``` + +## Supported IDEs + +JetBrains supports remote development for the following IDEs: + +- [GoLand (`GO`)](https://www.jetbrains.com/go/) +- [WebStorm (`WS`)](https://www.jetbrains.com/webstorm/) +- [IntelliJ IDEA Ultimate (`IU`)](https://www.jetbrains.com/idea/) +- [PyCharm Professional (`PY`)](https://www.jetbrains.com/pycharm/) +- [PhpStorm (`PS`)](https://www.jetbrains.com/phpstorm/) +- [CLion (`CL`)](https://www.jetbrains.com/clion/) +- [RubyMine (`RM`)](https://www.jetbrains.com/ruby/) +- [Rider (`RD`)](https://www.jetbrains.com/rider/) +- [RustRover (`RR`)](https://www.jetbrains.com/rust/) diff --git a/registry/coder/modules/jetbrains/main.test.ts b/registry/coder/modules/jetbrains/main.test.ts new file mode 100644 index 00000000..9fe304d7 --- /dev/null +++ b/registry/coder/modules/jetbrains/main.test.ts @@ -0,0 +1,86 @@ +import { it, expect, describe } from "bun:test"; +import { + runTerraformInit, + testRequiredVariables, + runTerraformApply, +} from "~test"; + +describe("jetbrains", async () => { + await runTerraformInit(import.meta.dir); + + await testRequiredVariables(import.meta.dir, { + agent_id: "foo", + folder: "/home/foo", + }); + + it("should create a link with the default values", async () => { + const state = await runTerraformApply(import.meta.dir, { + // These are all required. + agent_id: "foo", + folder: "/home/coder", + }); + + // Check that the URL contains the expected components + const url = state.outputs.url.value; + expect(url).toContain("jetbrains://gateway/com.coder.toolbox"); + expect(url).toMatch(/workspace=[^&]+/); + expect(url).toContain("owner=default"); + expect(url).toContain("project_path=/home/coder"); + expect(url).toContain("token=$SESSION_TOKEN"); + expect(url).toContain("ide_product_code=CL"); // First option in the default list + + const coder_app = state.resources.find( + (res) => res.type === "coder_app" && res.name === "jetbrains", + ); + + expect(coder_app).not.toBeNull(); + expect(coder_app?.instances.length).toBe(1); + expect(coder_app?.instances[0].attributes.order).toBeNull(); + }); + + it("should use the specified default IDE", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + folder: "/home/foo", + default: "GO", + }); + expect(state.outputs.identifier.value).toBe("GO"); + }); + + it("should use the first IDE from options when no default is specified", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + folder: "/home/foo", + options: '["PY", "GO", "IU"]', + }); + expect(state.outputs.identifier.value).toBe("PY"); + }); + + it("should set the app order when specified", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + folder: "/home/foo", + coder_app_order: 42, + }); + + const coder_app = state.resources.find( + (res) => res.type === "coder_app" && res.name === "jetbrains", + ); + + expect(coder_app).not.toBeNull(); + expect(coder_app?.instances[0].attributes.order).toBe(42); + }); + + it("should use the latest build number when latest is true", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + folder: "/home/foo", + latest: true, + }); + + // We can't test the exact build number since it's fetched dynamically, + // but we can check that the URL contains the build number parameter + const url = state.outputs.url.value; + expect(url).toContain("ide_build_number="); + }); +}); diff --git a/registry/coder/modules/jetbrains/main.tf b/registry/coder/modules/jetbrains/main.tf new file mode 100644 index 00000000..273aef28 --- /dev/null +++ b/registry/coder/modules/jetbrains/main.tf @@ -0,0 +1,225 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 2.4.2" + } + http = { + source = "hashicorp/http" + version = ">= 3.0" + } + } +} + +variable "agent_id" { + type = string + description = "The ID of a Coder agent." + default = "foo" # remove before merging +} + +variable "folder" { + type = string + default = "/home/coder/project" # remove before merging + description = "The directory to open in the IDE. e.g. /home/coder/project" + validation { + condition = can(regex("^(?:/[^/]+)+$", var.folder)) + error_message = "The folder must be a full path and must not start with a ~." + } +} + +variable "default" { + default = [] + type = set(string) + description = "Default IDEs selection" +} + +variable "coder_app_order" { + type = number + description = "The order determines the position of app in the UI presentation. The lowest order is shown first and apps with equal order are sorted by name (ascending order)." + default = null +} + +variable "coder_parameter_order" { + type = number + description = "The order determines the position of a template parameter in the UI/CLI presentation. The lowest order is shown first and parameters with equal order are sorted by name (ascending order)." + default = null +} + +variable "major_version" { + type = string + description = "The major version of the IDE. i.e. 2025.1" + default = "latest" + validation { + condition = can(regex("^[0-9]{4}\\.[0-2]{1}$", var.major_version)) || var.major_version == "latest" + error_message = "The major_version must be a valid version number. i.e. 2025.1 or latest" + } +} + +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 "options" { + type = set(string) + description = "The list of IDE product codes." + default = ["CL", "GO", "IU", "PS", "PY", "RD", "RM", "RR", "WS"] + validation { + condition = ( + alltrue([ + for code in var.options : contains(["CL", "GO", "IU", "PS", "PY", "RD", "RM", "RR", "WS"], code) + ]) + ) + error_message = "The options must be a set of valid product codes. Valid product codes are ${join(",", ["CL", "GO", "IU", "PS", "PY", "RD", "RM", "RR", "WS"])}." + } + # check if the set is empty + validation { + condition = length(var.options) > 0 + error_message = "The options must not be empty." + } +} + +variable "releases_base_link" { + type = string + description = "URL of the JetBrains releases base link." + default = "https://data.services.jetbrains.com" + validation { + condition = can(regex("^https?://.+$", var.releases_base_link)) + error_message = "The releases_base_link must be a valid HTTP/S address." + } +} + +variable "download_base_link" { + type = string + description = "URL of the JetBrains download base link." + default = "https://download.jetbrains.com" + validation { + condition = can(regex("^https?://.+$", var.download_base_link)) + error_message = "The download_base_link must be a valid HTTP/S address." + } +} + +data "http" "jetbrains_ide_versions" { + for_each = var.default == [] ? var.options : var.default + url = "${var.releases_base_link}/products/releases?code=${each.key}&type=${var.channel}&${var.major_version == "latest" ? "latest=true" : "major_version=${var.major_version}"}" +} + +variable "ide_config" { + description = <<-EOT + A map of JetBrains IDE configurations. + The key is the product code and the value is an object with the following properties: + - name: The name of the IDE. + - icon: The icon of the IDE. + - build: The build number of the IDE. + Example: + { + "CL" = { name = "CLion", icon = "/icon/clion.svg", build = "251.23774.202" }, + "GO" = { name = "GoLand", icon = "/icon/goland.svg", build = "251.25410.140" }, + "IU" = { name = "IntelliJ IDEA", icon = "/icon/intellij.svg", build = "251.23774.200" }, + } + EOT + type = map(object({ + name = string + icon = string + build = string + })) + default = { + "CL" = { name = "CLion", icon = "/icon/clion.svg", build = "251.23774.202" }, + "GO" = { name = "GoLand", icon = "/icon/goland.svg", build = "251.25410.140" }, + "IU" = { name = "IntelliJ IDEA", icon = "/icon/intellij.svg", build = "251.23774.200" }, + "PS" = { name = "PhpStorm", icon = "/icon/phpstorm.svg", build = "251.23774.209" }, + "PY" = { name = "PyCharm", icon = "/icon/pycharm.svg", build = "251.23774.211" }, + "RD" = { name = "Rider", icon = "/icon/rider.svg", build = "251.23774.212" }, + "RM" = { name = "RubyMine", icon = "/icon/rubymine.svg", build = "251.23774.208" }, + "RR" = { name = "RustRover", icon = "/icon/rustrover.svg", build = "251.23774.316" }, + "WS" = { name = "WebStorm", icon = "/icon/webstorm.svg", build = "251.23774.210" } + } + validation { + condition = length(var.ide_config) > 0 + error_message = "The ide_config must not be empty." + } + # ide_config must be a superset of var.. options + validation { + condition = alltrue([ + for code in var.options : contains(keys(var.ide_config), code) + ]) + error_message = "The ide_config must be a superset of var.options." + } +} + +locals { + # Dynamically generate IDE configurations based on options + options_metadata = { + for code in var.default == [] ? var.options : var.default : code => { + icon = var.ide_config[code].icon + name = var.ide_config[code].name + identifier = code + build = var.major_version != "" ? jsondecode(data.http.jetbrains_ide_versions[code].response_body)[code][0].build : var.ide_config[code].build + json_data = var.major_version != "" ? jsondecode(data.http.jetbrains_ide_versions[code].response_body)[code][0] : {} + key = var.major_version != "" ? keys(data.http.jetbrains_ide_versions[code].response_body)[code][0] : "" + + } + } +} + +data "coder_parameter" "jetbrains_ide" { + count = var.default == [] ? 0 : 1 + type = "list(string)" + name = "jetbrains_ide" + display_name = "JetBrains IDE" + icon = "/icon/jetbrains.svg" + mutable = true + default = jsonencode(var.default) + order = var.coder_parameter_order + form_type = "tag-select" + + dynamic "option" { + for_each = var.default == [] ? var.options : var.default + content { + icon = local.options_metadata[option.value].icon + name = local.options_metadata[option.value].name + value = option.value + } + } +} + +data "coder_workspace" "me" {} +data "coder_workspace_owner" "me" {} + +locals { + # Convert the parameter value to a set for for_each + selected_ides = var.default == [] ? var.options : toset(jsondecode(coalesce(data.coder_parameter.jetbrains_ide[0].value, "[]"))) +} + +resource "coder_app" "jetbrains" { + for_each = local.selected_ides + agent_id = var.agent_id + slug = "jetbrains-${each.key}" + display_name = local.options_metadata[each.key].name + icon = local.options_metadata[each.key].icon + external = true + order = var.coder_app_order + url = join("", [ + "jetbrains://gateway/com.coder.toolbox?&workspace=", + data.coder_workspace.me.name, + "&owner=", + data.coder_workspace_owner.me.name, + "&folder=", + var.folder, + "&url=", + data.coder_workspace.me.access_url, + "&token=", + "$SESSION_TOKEN", + "&ide_product_code=", + each.key, + "&ide_build_number=", + local.options_metadata[each.key].build + ]) +} \ No newline at end of file