This commit is contained in:
Muhammad Atif Ali 2025-05-23 15:29:48 +05:00
parent ae6cf8c366
commit 09873f9d79
No known key found for this signature in database
3 changed files with 406 additions and 0 deletions

View File

@ -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/)

View File

@ -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=");
});
});

View File

@ -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
])
}