From b76b544e78c9944858678a0bbbbbe297fce22c3f Mon Sep 17 00:00:00 2001 From: Atif Ali Date: Thu, 9 Apr 2026 07:28:57 +0000 Subject: [PATCH] feat(jetbrains): skip HTTP calls when ide_config is set (#836) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #835 ## Problem The `data "http"` resource always fires for every selected IDE, even when the user has pinned versions via `ide_config`. In air-gapped or caching scenarios, this causes: - **30-second hangs** when `releases_base_link` is set to a dummy URL like `https://localhost` - **Fatal errors** with `https://localhost:1` (connection refused) - The documented "air-gapped fallback" via `try()` never actually worked — the `http` data source fails before `try()` can catch anything ## Fix When `ide_config` is provided, the module now skips all HTTP calls and uses the pinned build numbers directly. | Scenario | `ide_config` | HTTP calls | Build source | On API failure | |---|---|---|---|---| | User wants latest | `null` (default) | Yes | JetBrains API | Terraform error (fail loudly) | | User pins versions | Set | **None** | `ide_config.build` | N/A | ### Changes - `ide_config` default changed from a full map to `null` - `name` and `icon` are now `optional(string)` in `ide_config` — falls back to built-in metadata - `data.http.jetbrains_ide_versions` `for_each` is empty when `ide_config` is set - Static `ide_metadata` local provides name/icon when `ide_config` is null - Removed `try()` fallback from `parsed_responses` — API errors are now explicit instead of silently using stale builds - Cross-variable validation rejects `major_version`, `channel`, and `releases_base_link` when `ide_config` is set - Validation for `ide_config ⊇ default` added (previously only `ide_config ⊇ options` was checked) - Version bumped `1.3.1` → `1.4.0` ### Usage ```tf module "jetbrains" { source = "registry.coder.com/coder/jetbrains/coder" version = "1.4.0" agent_id = coder_agent.main.id folder = "/home/coder/project" # Zero HTTP calls — only build is required. ide_config = { "GO" = { build = "261.22158.291" } "PY" = { build = "261.22158.340" } } options = ["GO", "PY"] } ``` > 🤖 This PR was created with the help of Coder Agents, and needs a human review. 🧑‍💻 --- registry/coder/modules/jetbrains/README.md | 55 ++-- .../modules/jetbrains/jetbrains.tftest.hcl | 235 +++++++++++++----- registry/coder/modules/jetbrains/main.tf | 117 +++++---- 3 files changed, 274 insertions(+), 133 deletions(-) diff --git a/registry/coder/modules/jetbrains/README.md b/registry/coder/modules/jetbrains/README.md index 7f474045..b4e36c75 100644 --- a/registry/coder/modules/jetbrains/README.md +++ b/registry/coder/modules/jetbrains/README.md @@ -14,7 +14,7 @@ This module adds JetBrains IDE buttons to launch IDEs directly from the dashboar module "jetbrains" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/jetbrains/coder" - version = "1.3.1" + version = "1.4.0" agent_id = coder_agent.main.id folder = "/home/coder/project" } @@ -39,7 +39,7 @@ When `default` contains IDE codes, those IDEs are created directly without user module "jetbrains" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/jetbrains/coder" - version = "1.3.1" + version = "1.4.0" agent_id = coder_agent.main.id folder = "/home/coder/project" default = ["PY", "IU"] # Pre-configure PyCharm and IntelliJ IDEA @@ -52,7 +52,7 @@ module "jetbrains" { module "jetbrains" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/jetbrains/coder" - version = "1.3.1" + version = "1.4.0" agent_id = coder_agent.main.id folder = "/home/coder/project" # Show parameter with limited options @@ -66,7 +66,7 @@ module "jetbrains" { module "jetbrains" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/jetbrains/coder" - version = "1.3.1" + version = "1.4.0" agent_id = coder_agent.main.id folder = "/home/coder/project" default = ["IU", "PY"] @@ -75,30 +75,37 @@ module "jetbrains" { } ``` -### Custom IDE Configuration +### Pinned Versions (Air-Gapped / Cached) + +When `ide_config` is set, the module makes zero HTTP calls and uses the +provided build numbers directly. This is ideal for air-gapped environments +or when caching IDE installations. + +> [!TIP] +> To find the latest build number for an IDE, query the JetBrains releases API: +> +> ```sh +> curl -s "https://data.services.jetbrains.com/products/releases?code=GO&type=release&latest=true" | jq 'to_entries[0].value[0] | {build, version}' +> ``` +> +> Replace `GO` with the product code for the IDE you want (e.g. `IU`, `PY`, `CL`). ```tf module "jetbrains" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/jetbrains/coder" - version = "1.3.1" + version = "1.4.0" agent_id = coder_agent.main.id - folder = "/workspace/project" + folder = "/home/coder/project" - # Custom IDE metadata (display names and icons) + # Only build is required. Name and icon fall back to built-in defaults. ide_config = { - "IU" = { - name = "IntelliJ IDEA" - icon = "/custom/icons/intellij.svg" - build = "251.26927.53" - } - - "PY" = { - name = "PyCharm" - icon = "/custom/icons/pycharm.svg" - build = "251.23774.211" - } + "GO" = { build = "261.22158.291" } + "PY" = { build = "261.22158.340" } + # Add entries for other IDEs as needed. } + + options = ["GO", "PY"] # Must match the keys in ide_config. } ``` @@ -108,7 +115,7 @@ module "jetbrains" { module "jetbrains_pycharm" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/jetbrains/coder" - version = "1.3.1" + version = "1.4.0" agent_id = coder_agent.main.id folder = "/workspace/project" @@ -128,7 +135,7 @@ Add helpful tooltip text that appears when users hover over the IDE app buttons: module "jetbrains" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/jetbrains/coder" - version = "1.3.1" + version = "1.4.0" agent_id = coder_agent.main.id folder = "/home/coder/project" default = ["IU", "PY"] @@ -165,9 +172,9 @@ resource "coder_metadata" "container_info" { ### Version Resolution -- Build numbers are fetched from the JetBrains API for the latest compatible versions when internet access is available -- If the API is unreachable (air-gapped environments), the module automatically falls back to build numbers from `ide_config` -- `major_version` and `channel` control which API endpoint is queried (when API access is available) +- **`ide_config` not set (default)**: Build numbers are fetched from the JetBrains releases API. If the API is unreachable, Terraform will return an error rather than silently using stale versions. +- **`ide_config` set**: The module skips all HTTP calls and uses the provided build numbers directly. No network access required. Ideal for air-gapped deployments or when caching IDE installations. +- `major_version` and `channel` control which API endpoint is queried (only when `ide_config` is not set). ## Supported IDEs diff --git a/registry/coder/modules/jetbrains/jetbrains.tftest.hcl b/registry/coder/modules/jetbrains/jetbrains.tftest.hcl index dba9551d..55570b53 100644 --- a/registry/coder/modules/jetbrains/jetbrains.tftest.hcl +++ b/registry/coder/modules/jetbrains/jetbrains.tftest.hcl @@ -1,53 +1,3 @@ -variables { - # Default IDE config, mirrored from main.tf for test assertions. - # If main.tf defaults change, update this map to match. - expected_ide_config = { - "CL" = { name = "CLion", icon = "/icon/clion.svg", build = "253.29346.141" }, - "GO" = { name = "GoLand", icon = "/icon/goland.svg", build = "253.28294.337" }, - "IU" = { name = "IntelliJ IDEA", icon = "/icon/intellij.svg", build = "253.29346.138" }, - "PS" = { name = "PhpStorm", icon = "/icon/phpstorm.svg", build = "253.29346.151" }, - "PY" = { name = "PyCharm", icon = "/icon/pycharm.svg", build = "253.29346.142" }, - "RD" = { name = "Rider", icon = "/icon/rider.svg", build = "253.29346.144" }, - "RM" = { name = "RubyMine", icon = "/icon/rubymine.svg", build = "253.29346.140" }, - "RR" = { name = "RustRover", icon = "/icon/rustrover.svg", build = "253.29346.139" }, - "WS" = { name = "WebStorm", icon = "/icon/webstorm.svg", build = "253.29346.143" } - } -} - -run "validate_test_config_matches_defaults" { - command = plan - - variables { - # Provide minimal vars to allow plan to read module variables - agent_id = "foo" - folder = "/home/coder" - } - - assert { - condition = length(var.ide_config) == length(var.expected_ide_config) - error_message = "Test configuration mismatch: 'var.ide_config' in main.tf has ${length(var.ide_config)} items, but 'var.expected_ide_config' in the test file has ${length(var.expected_ide_config)} items. Please update the test file's global variables block." - } - - assert { - # Check that all keys in the test local are present in the module's default - condition = alltrue([ - for key in keys(var.expected_ide_config) : - can(var.ide_config[key]) - ]) - error_message = "Test configuration mismatch: Keys in 'var.expected_ide_config' are out of sync with 'var.ide_config' defaults. Please update the test file's global variables block." - } - - assert { - # Check if all build numbers in the test local match the module's defaults - # This relies on the previous two assertions passing (same length, same keys) - condition = alltrue([ - for key, config in var.expected_ide_config : - var.ide_config[key].build == config.build - ]) - error_message = "Test configuration mismatch: One or more build numbers in 'var.expected_ide_config' do not match the defaults in 'var.ide_config'. Please update the test file's global variables block." - } -} - run "requires_agent_and_folder" { command = plan @@ -259,15 +209,17 @@ run "output_empty_when_default_empty" { } } -run "output_single_ide_uses_fallback_build" { +run "uses_ide_config_when_set" { command = plan variables { agent_id = "foo" folder = "/home/coder" default = ["GO"] - # Force HTTP data source to fail to test fallback logic - releases_base_link = "https://coder.com" + options = ["GO"] + ide_config = { + "GO" = { name = "GoLand Custom", icon = "/icon/goland.svg", build = "999.99999.999" } + } } assert { @@ -281,30 +233,38 @@ run "output_single_ide_uses_fallback_build" { } assert { - condition = output.ide_metadata["GO"].name == var.expected_ide_config["GO"].name - error_message = "Expected ide_metadata['GO'].name to be '${var.expected_ide_config["GO"].name}'" + condition = output.ide_metadata["GO"].name == "GoLand Custom" + error_message = "Expected ide_metadata['GO'].name to be 'GoLand Custom'" } assert { - condition = output.ide_metadata["GO"].build == var.expected_ide_config["GO"].build - error_message = "Expected ide_metadata['GO'].build to use the fallback '${var.expected_ide_config["GO"].build}'" + condition = output.ide_metadata["GO"].build == "999.99999.999" + error_message = "Expected ide_metadata['GO'].build to use the pinned build '999.99999.999'" } assert { - condition = output.ide_metadata["GO"].icon == var.expected_ide_config["GO"].icon - error_message = "Expected ide_metadata['GO'].icon to be '${var.expected_ide_config["GO"].icon}'" + condition = output.ide_metadata["GO"].icon == "/icon/goland.svg" + error_message = "Expected ide_metadata['GO'].icon to be '/icon/goland.svg'" + } + + assert { + condition = output.ide_metadata["GO"].json_data == null + error_message = "Expected ide_metadata['GO'].json_data to be null when using ide_config" } } -run "output_multiple_ides" { +run "uses_ide_config_for_multiple_ides" { command = plan variables { agent_id = "foo" folder = "/home/coder" default = ["IU", "PY"] - # Force HTTP data source to fail to test fallback logic - releases_base_link = "https://coder.com" + options = ["IU", "PY"] + ide_config = { + "IU" = { name = "IntelliJ IDEA", icon = "/icon/intellij.svg", build = "111.11111.111" } + "PY" = { name = "PyCharm", icon = "/icon/pycharm.svg", build = "222.22222.222" } + } } assert { @@ -318,15 +278,50 @@ run "output_multiple_ides" { } assert { - condition = output.ide_metadata["PY"].name == var.expected_ide_config["PY"].name - error_message = "Expected ide_metadata['PY'].name to be '${var.expected_ide_config["PY"].name}'" + condition = output.ide_metadata["PY"].name == "PyCharm" + error_message = "Expected ide_metadata['PY'].name to be 'PyCharm'" } assert { - condition = output.ide_metadata["PY"].build == var.expected_ide_config["PY"].build - error_message = "Expected ide_metadata['PY'].build to be the fallback '${var.expected_ide_config["PY"].build}'" + condition = output.ide_metadata["PY"].build == "222.22222.222" + error_message = "Expected ide_metadata['PY'].build to be the pinned build '222.22222.222'" + } + + assert { + condition = output.ide_metadata["IU"].build == "111.11111.111" + error_message = "Expected ide_metadata['IU'].build to be the pinned build '111.11111.111'" + } + + assert { + condition = output.ide_metadata["IU"].json_data == null + error_message = "Expected ide_metadata['IU'].json_data to be null when using ide_config" + } + + assert { + condition = output.ide_metadata["PY"].json_data == null + error_message = "Expected ide_metadata['PY'].json_data to be null when using ide_config" } } + +run "ide_config_build_in_url" { + command = apply + + variables { + agent_id = "test-agent-123" + folder = "/home/coder/project" + default = ["GO"] + options = ["GO"] + ide_config = { + "GO" = { name = "GoLand", icon = "/icon/goland.svg", build = "999.99999.999" } + } + } + + assert { + condition = anytrue([for app in values(resource.coder_app.jetbrains) : length(regexall("ide_build_number=999.99999.999", app.url)) > 0]) + error_message = "URL must include the pinned build number from ide_config" + } +} + run "validate_output_schema" { command = plan @@ -334,6 +329,10 @@ run "validate_output_schema" { agent_id = "foo" folder = "/home/coder" default = ["GO"] + options = ["GO"] + ide_config = { + "GO" = { name = "GoLand", icon = "/icon/goland.svg", build = "253.28294.337" } + } } assert { @@ -351,3 +350,107 @@ run "validate_output_schema" { error_message = "The ide_metadata output schema has changed. Please update the 'main.tf' and this test." } } + +run "rejects_major_version_with_ide_config" { + command = plan + + variables { + agent_id = "foo" + folder = "/home/coder" + default = ["GO"] + options = ["GO"] + major_version = "2025.3" + ide_config = { + "GO" = { name = "GoLand", icon = "/icon/goland.svg", build = "253.31033.129" } + } + } + + expect_failures = [ + var.ide_config, + ] +} + +run "rejects_default_not_in_ide_config" { + command = plan + + variables { + agent_id = "foo" + folder = "/home/coder" + default = ["GO", "IU"] + options = ["GO", "IU"] + ide_config = { + "GO" = { build = "253.31033.129" } + } + } + + expect_failures = [ + var.ide_config, + ] +} + +run "ide_config_with_build_only" { + command = plan + + variables { + agent_id = "foo" + folder = "/home/coder" + default = ["GO"] + options = ["GO"] + ide_config = { + "GO" = { build = "999.99999.999" } + } + } + + assert { + condition = output.ide_metadata["GO"].name == "GoLand" + error_message = "Expected name to fall back to ide_metadata when not set in ide_config" + } + + assert { + condition = output.ide_metadata["GO"].icon == "/icon/goland.svg" + error_message = "Expected icon to fall back to ide_metadata when not set in ide_config" + } + + assert { + condition = output.ide_metadata["GO"].build == "999.99999.999" + error_message = "Expected build to use ide_config value" + } +} + +run "rejects_releases_base_link_with_ide_config" { + command = plan + + variables { + agent_id = "foo" + folder = "/home/coder" + default = ["GO"] + options = ["GO"] + releases_base_link = "https://internal.mirror.example.com" + ide_config = { + "GO" = { name = "GoLand", icon = "/icon/goland.svg", build = "253.31033.129" } + } + } + + expect_failures = [ + var.ide_config, + ] +} + +run "rejects_channel_with_ide_config" { + command = plan + + variables { + agent_id = "foo" + folder = "/home/coder" + default = ["GO"] + options = ["GO"] + channel = "eap" + ide_config = { + "GO" = { name = "GoLand", icon = "/icon/goland.svg", build = "253.31033.129" } + } + } + + expect_failures = [ + var.ide_config, + ] +} diff --git a/registry/coder/modules/jetbrains/main.tf b/registry/coder/modules/jetbrains/main.tf index 018cf583..3ed2bb72 100644 --- a/registry/coder/modules/jetbrains/main.tf +++ b/registry/coder/modules/jetbrains/main.tf @@ -125,95 +125,126 @@ variable "download_base_link" { } data "http" "jetbrains_ide_versions" { - for_each = local.selected_ides + for_each = var.ide_config == null ? local.selected_ides : toset([]) url = "${var.releases_base_link}/products/releases?code=${each.key}&type=${var.channel}${var.major_version == "latest" ? "&latest=true" : ""}" } 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. + Optional map of JetBrains IDE configurations keyed by product code. + When null (default), the module fetches the latest build numbers from + the JetBrains API at plan time. When set, all HTTP calls are skipped + and the provided build numbers are used directly — useful for + air-gapped environments or pinning specific versions. + + Each value must contain: + - build: Full build number (e.g. "253.28294.337"). + + Optionally override the default display name or icon: + - name: Display name of the IDE (e.g. "GoLand"). + - icon: Path or URL to the IDE icon (e.g. "/icon/goland.svg"). + Example: { - "CL" = { name = "CLion", icon = "/icon/clion.svg", build = "253.29346.141" }, - "GO" = { name = "GoLand", icon = "/icon/goland.svg", build = "253.28294.337" }, - "IU" = { name = "IntelliJ IDEA", icon = "/icon/intellij.svg", build = "253.29346.138" }, + "GO" = { build = "261.22158.291" }, + "IU" = { build = "261.22158.277" }, } EOT type = map(object({ - name = string - icon = string build = string + name = optional(string) + icon = optional(string) })) - default = { - "CL" = { name = "CLion", icon = "/icon/clion.svg", build = "253.29346.141" }, - "GO" = { name = "GoLand", icon = "/icon/goland.svg", build = "253.28294.337" }, - "IU" = { name = "IntelliJ IDEA", icon = "/icon/intellij.svg", build = "253.29346.138" }, - "PS" = { name = "PhpStorm", icon = "/icon/phpstorm.svg", build = "253.29346.151" }, - "PY" = { name = "PyCharm", icon = "/icon/pycharm.svg", build = "253.29346.142" }, - "RD" = { name = "Rider", icon = "/icon/rider.svg", build = "253.29346.144" }, - "RM" = { name = "RubyMine", icon = "/icon/rubymine.svg", build = "253.29346.140" }, - "RR" = { name = "RustRover", icon = "/icon/rustrover.svg", build = "253.29346.139" }, - "WS" = { name = "WebStorm", icon = "/icon/webstorm.svg", build = "253.29346.143" } - } + default = null validation { - condition = length(var.ide_config) > 0 + condition = var.ide_config == null || length(var.ide_config) > 0 error_message = "The ide_config must not be empty." } # ide_config must be a superset of var.options # Requires Terraform 1.9+ for cross-variable validation references validation { - condition = alltrue([ + condition = var.ide_config == null || alltrue([ for code in var.options : contains(keys(var.ide_config), code) ]) - error_message = "The ide_config must be a superset of var.options." + error_message = "The ide_config must contain entries for all IDE codes in var.options. Either add the missing entries to ide_config or narrow var.options to match." + } + # ide_config must also cover all codes in var.default to avoid + # key-not-found errors when building options_metadata. + validation { + condition = var.ide_config == null || alltrue([ + for code in var.default : contains(keys(var.ide_config), code) + ]) + error_message = "The ide_config must contain entries for all IDE codes in var.default." + } + # major_version, channel, and releases_base_link only affect the + # HTTP call, which is skipped when ide_config is set. Reject + # non-default values to avoid silently ignoring user intent. + validation { + condition = var.ide_config == null || ( + var.major_version == "latest" && + var.channel == "release" && + var.releases_base_link == "https://data.services.jetbrains.com" + ) + error_message = "major_version, channel, and releases_base_link have no effect when ide_config is set. Remove them or unset ide_config." } } locals { + # Static IDE metadata for name and icon lookups when ide_config is null. + ide_metadata = { + "CL" = { name = "CLion", icon = "/icon/clion.svg" } + "GO" = { name = "GoLand", icon = "/icon/goland.svg" } + "IU" = { name = "IntelliJ IDEA", icon = "/icon/intellij.svg" } + "PS" = { name = "PhpStorm", icon = "/icon/phpstorm.svg" } + "PY" = { name = "PyCharm", icon = "/icon/pycharm.svg" } + "RD" = { name = "Rider", icon = "/icon/rider.svg" } + "RM" = { name = "RubyMine", icon = "/icon/rubymine.svg" } + "RR" = { name = "RustRover", icon = "/icon/rustrover.svg" } + "WS" = { name = "WebStorm", icon = "/icon/webstorm.svg" } + } + # Determine the user's actual IDE selection. # This is computed before the HTTP data source so that version lookups # are only performed for IDEs the user chose — not every option. selected_ides = length(var.default) == 0 ? toset(jsondecode(coalesce(data.coder_parameter.jetbrains_ides[0].value, "[]"))) : toset(var.default) - # Parse HTTP responses once with error handling for air-gapped environments + # Parse HTTP responses. Only populated when ide_config is null + # and the module fetches versions from the JetBrains API. + # No try() fallback — if the API is expected and fails, Terraform + # should error rather than silently using stale build numbers. parsed_responses = { - for code in local.selected_ides : code => try( - jsondecode(data.http.jetbrains_ide_versions[code].response_body), - {} # Return empty object if API call fails - ) + for code, response in data.http.jetbrains_ide_versions : + code => jsondecode(response.response_body) } # Filter the parsed response for the requested major version if not "latest" filtered_releases = { - for code in local.selected_ides : code => [ - for r in try(local.parsed_responses[code][keys(local.parsed_responses[code])[0]], []) : + for code, parsed in local.parsed_responses : code => [ + for r in parsed[keys(parsed)[0]] : r if var.major_version == "latest" || r.majorVersion == var.major_version ] } # Select the latest release for the requested major version (first item in the filtered list) selected_releases = { - for code in local.selected_ides : code => - length(local.filtered_releases[code]) > 0 ? local.filtered_releases[code][0] : null + for code, releases in local.filtered_releases : + code => length(releases) > 0 ? releases[0] : null } - # Dynamically generate IDE configurations based on selected IDEs with fallback to ide_config + # Dynamically generate IDE configurations based on selected IDEs options_metadata = { for code in local.selected_ides : code => { - icon = var.ide_config[code].icon - name = var.ide_config[code].name + icon = var.ide_config != null ? coalesce(var.ide_config[code].icon, local.ide_metadata[code].icon) : local.ide_metadata[code].icon + name = var.ide_config != null ? coalesce(var.ide_config[code].name, local.ide_metadata[code].name) : local.ide_metadata[code].name identifier = code key = code - # Use API build number if available, otherwise fall back to ide_config build number - build = local.selected_releases[code] != null ? local.selected_releases[code].build : var.ide_config[code].build + # When ide_config is set, use the pinned build number directly. + # When fetching from API, use the API result (fails if unavailable). + build = var.ide_config != null ? var.ide_config[code].build : local.selected_releases[code].build - # Store API data for potential future use - json_data = local.selected_releases[code] + # API response data, null when using ide_config. + json_data = var.ide_config != null ? null : local.selected_releases[code] } } } @@ -233,8 +264,8 @@ data "coder_parameter" "jetbrains_ides" { dynamic "option" { for_each = var.options content { - icon = var.ide_config[option.value].icon - name = var.ide_config[option.value].name + icon = var.ide_config != null ? coalesce(var.ide_config[option.value].icon, local.ide_metadata[option.value].icon) : local.ide_metadata[option.value].icon + name = var.ide_config != null ? coalesce(var.ide_config[option.value].name, local.ide_metadata[option.value].name) : local.ide_metadata[option.value].name value = option.value } }