feat(jetbrains): skip HTTP calls when ide_config is set (#836)
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. 🧑💻
This commit is contained in:
parent
d3885a5047
commit
b76b544e78
@ -14,7 +14,7 @@ This module adds JetBrains IDE buttons to launch IDEs directly from the dashboar
|
|||||||
module "jetbrains" {
|
module "jetbrains" {
|
||||||
count = data.coder_workspace.me.start_count
|
count = data.coder_workspace.me.start_count
|
||||||
source = "registry.coder.com/coder/jetbrains/coder"
|
source = "registry.coder.com/coder/jetbrains/coder"
|
||||||
version = "1.3.1"
|
version = "1.4.0"
|
||||||
agent_id = coder_agent.main.id
|
agent_id = coder_agent.main.id
|
||||||
folder = "/home/coder/project"
|
folder = "/home/coder/project"
|
||||||
}
|
}
|
||||||
@ -39,7 +39,7 @@ When `default` contains IDE codes, those IDEs are created directly without user
|
|||||||
module "jetbrains" {
|
module "jetbrains" {
|
||||||
count = data.coder_workspace.me.start_count
|
count = data.coder_workspace.me.start_count
|
||||||
source = "registry.coder.com/coder/jetbrains/coder"
|
source = "registry.coder.com/coder/jetbrains/coder"
|
||||||
version = "1.3.1"
|
version = "1.4.0"
|
||||||
agent_id = coder_agent.main.id
|
agent_id = coder_agent.main.id
|
||||||
folder = "/home/coder/project"
|
folder = "/home/coder/project"
|
||||||
default = ["PY", "IU"] # Pre-configure PyCharm and IntelliJ IDEA
|
default = ["PY", "IU"] # Pre-configure PyCharm and IntelliJ IDEA
|
||||||
@ -52,7 +52,7 @@ module "jetbrains" {
|
|||||||
module "jetbrains" {
|
module "jetbrains" {
|
||||||
count = data.coder_workspace.me.start_count
|
count = data.coder_workspace.me.start_count
|
||||||
source = "registry.coder.com/coder/jetbrains/coder"
|
source = "registry.coder.com/coder/jetbrains/coder"
|
||||||
version = "1.3.1"
|
version = "1.4.0"
|
||||||
agent_id = coder_agent.main.id
|
agent_id = coder_agent.main.id
|
||||||
folder = "/home/coder/project"
|
folder = "/home/coder/project"
|
||||||
# Show parameter with limited options
|
# Show parameter with limited options
|
||||||
@ -66,7 +66,7 @@ module "jetbrains" {
|
|||||||
module "jetbrains" {
|
module "jetbrains" {
|
||||||
count = data.coder_workspace.me.start_count
|
count = data.coder_workspace.me.start_count
|
||||||
source = "registry.coder.com/coder/jetbrains/coder"
|
source = "registry.coder.com/coder/jetbrains/coder"
|
||||||
version = "1.3.1"
|
version = "1.4.0"
|
||||||
agent_id = coder_agent.main.id
|
agent_id = coder_agent.main.id
|
||||||
folder = "/home/coder/project"
|
folder = "/home/coder/project"
|
||||||
default = ["IU", "PY"]
|
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
|
```tf
|
||||||
module "jetbrains" {
|
module "jetbrains" {
|
||||||
count = data.coder_workspace.me.start_count
|
count = data.coder_workspace.me.start_count
|
||||||
source = "registry.coder.com/coder/jetbrains/coder"
|
source = "registry.coder.com/coder/jetbrains/coder"
|
||||||
version = "1.3.1"
|
version = "1.4.0"
|
||||||
agent_id = coder_agent.main.id
|
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 = {
|
ide_config = {
|
||||||
"IU" = {
|
"GO" = { build = "261.22158.291" }
|
||||||
name = "IntelliJ IDEA"
|
"PY" = { build = "261.22158.340" }
|
||||||
icon = "/custom/icons/intellij.svg"
|
# Add entries for other IDEs as needed.
|
||||||
build = "251.26927.53"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
"PY" = {
|
options = ["GO", "PY"] # Must match the keys in ide_config.
|
||||||
name = "PyCharm"
|
|
||||||
icon = "/custom/icons/pycharm.svg"
|
|
||||||
build = "251.23774.211"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -108,7 +115,7 @@ module "jetbrains" {
|
|||||||
module "jetbrains_pycharm" {
|
module "jetbrains_pycharm" {
|
||||||
count = data.coder_workspace.me.start_count
|
count = data.coder_workspace.me.start_count
|
||||||
source = "registry.coder.com/coder/jetbrains/coder"
|
source = "registry.coder.com/coder/jetbrains/coder"
|
||||||
version = "1.3.1"
|
version = "1.4.0"
|
||||||
agent_id = coder_agent.main.id
|
agent_id = coder_agent.main.id
|
||||||
folder = "/workspace/project"
|
folder = "/workspace/project"
|
||||||
|
|
||||||
@ -128,7 +135,7 @@ Add helpful tooltip text that appears when users hover over the IDE app buttons:
|
|||||||
module "jetbrains" {
|
module "jetbrains" {
|
||||||
count = data.coder_workspace.me.start_count
|
count = data.coder_workspace.me.start_count
|
||||||
source = "registry.coder.com/coder/jetbrains/coder"
|
source = "registry.coder.com/coder/jetbrains/coder"
|
||||||
version = "1.3.1"
|
version = "1.4.0"
|
||||||
agent_id = coder_agent.main.id
|
agent_id = coder_agent.main.id
|
||||||
folder = "/home/coder/project"
|
folder = "/home/coder/project"
|
||||||
default = ["IU", "PY"]
|
default = ["IU", "PY"]
|
||||||
@ -165,9 +172,9 @@ resource "coder_metadata" "container_info" {
|
|||||||
|
|
||||||
### Version Resolution
|
### Version Resolution
|
||||||
|
|
||||||
- Build numbers are fetched from the JetBrains API for the latest compatible versions when internet 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.
|
||||||
- If the API is unreachable (air-gapped environments), the module automatically falls back to build numbers from `ide_config`
|
- **`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 (when API access is available)
|
- `major_version` and `channel` control which API endpoint is queried (only when `ide_config` is not set).
|
||||||
|
|
||||||
## Supported IDEs
|
## Supported IDEs
|
||||||
|
|
||||||
|
|||||||
@ -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" {
|
run "requires_agent_and_folder" {
|
||||||
command = plan
|
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
|
command = plan
|
||||||
|
|
||||||
variables {
|
variables {
|
||||||
agent_id = "foo"
|
agent_id = "foo"
|
||||||
folder = "/home/coder"
|
folder = "/home/coder"
|
||||||
default = ["GO"]
|
default = ["GO"]
|
||||||
# Force HTTP data source to fail to test fallback logic
|
options = ["GO"]
|
||||||
releases_base_link = "https://coder.com"
|
ide_config = {
|
||||||
|
"GO" = { name = "GoLand Custom", icon = "/icon/goland.svg", build = "999.99999.999" }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
assert {
|
assert {
|
||||||
@ -281,30 +233,38 @@ run "output_single_ide_uses_fallback_build" {
|
|||||||
}
|
}
|
||||||
|
|
||||||
assert {
|
assert {
|
||||||
condition = output.ide_metadata["GO"].name == var.expected_ide_config["GO"].name
|
condition = output.ide_metadata["GO"].name == "GoLand Custom"
|
||||||
error_message = "Expected ide_metadata['GO'].name to be '${var.expected_ide_config["GO"].name}'"
|
error_message = "Expected ide_metadata['GO'].name to be 'GoLand Custom'"
|
||||||
}
|
}
|
||||||
|
|
||||||
assert {
|
assert {
|
||||||
condition = output.ide_metadata["GO"].build == 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 fallback '${var.expected_ide_config["GO"].build}'"
|
error_message = "Expected ide_metadata['GO'].build to use the pinned build '999.99999.999'"
|
||||||
}
|
}
|
||||||
|
|
||||||
assert {
|
assert {
|
||||||
condition = output.ide_metadata["GO"].icon == var.expected_ide_config["GO"].icon
|
condition = output.ide_metadata["GO"].icon == "/icon/goland.svg"
|
||||||
error_message = "Expected ide_metadata['GO'].icon to be '${var.expected_ide_config["GO"].icon}'"
|
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
|
command = plan
|
||||||
|
|
||||||
variables {
|
variables {
|
||||||
agent_id = "foo"
|
agent_id = "foo"
|
||||||
folder = "/home/coder"
|
folder = "/home/coder"
|
||||||
default = ["IU", "PY"]
|
default = ["IU", "PY"]
|
||||||
# Force HTTP data source to fail to test fallback logic
|
options = ["IU", "PY"]
|
||||||
releases_base_link = "https://coder.com"
|
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 {
|
assert {
|
||||||
@ -318,15 +278,50 @@ run "output_multiple_ides" {
|
|||||||
}
|
}
|
||||||
|
|
||||||
assert {
|
assert {
|
||||||
condition = output.ide_metadata["PY"].name == var.expected_ide_config["PY"].name
|
condition = output.ide_metadata["PY"].name == "PyCharm"
|
||||||
error_message = "Expected ide_metadata['PY'].name to be '${var.expected_ide_config["PY"].name}'"
|
error_message = "Expected ide_metadata['PY'].name to be 'PyCharm'"
|
||||||
}
|
}
|
||||||
|
|
||||||
assert {
|
assert {
|
||||||
condition = output.ide_metadata["PY"].build == 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 fallback '${var.expected_ide_config["PY"].build}'"
|
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" {
|
run "validate_output_schema" {
|
||||||
command = plan
|
command = plan
|
||||||
|
|
||||||
@ -334,6 +329,10 @@ run "validate_output_schema" {
|
|||||||
agent_id = "foo"
|
agent_id = "foo"
|
||||||
folder = "/home/coder"
|
folder = "/home/coder"
|
||||||
default = ["GO"]
|
default = ["GO"]
|
||||||
|
options = ["GO"]
|
||||||
|
ide_config = {
|
||||||
|
"GO" = { name = "GoLand", icon = "/icon/goland.svg", build = "253.28294.337" }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
assert {
|
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."
|
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,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|||||||
@ -125,95 +125,126 @@ variable "download_base_link" {
|
|||||||
}
|
}
|
||||||
|
|
||||||
data "http" "jetbrains_ide_versions" {
|
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" : ""}"
|
url = "${var.releases_base_link}/products/releases?code=${each.key}&type=${var.channel}${var.major_version == "latest" ? "&latest=true" : ""}"
|
||||||
}
|
}
|
||||||
|
|
||||||
variable "ide_config" {
|
variable "ide_config" {
|
||||||
description = <<-EOT
|
description = <<-EOT
|
||||||
A map of JetBrains IDE configurations.
|
Optional map of JetBrains IDE configurations keyed by product code.
|
||||||
The key is the product code and the value is an object with the following properties:
|
When null (default), the module fetches the latest build numbers from
|
||||||
- name: The name of the IDE.
|
the JetBrains API at plan time. When set, all HTTP calls are skipped
|
||||||
- icon: The icon of the IDE.
|
and the provided build numbers are used directly — useful for
|
||||||
- build: The build number of the IDE.
|
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:
|
Example:
|
||||||
{
|
{
|
||||||
"CL" = { name = "CLion", icon = "/icon/clion.svg", build = "253.29346.141" },
|
"GO" = { build = "261.22158.291" },
|
||||||
"GO" = { name = "GoLand", icon = "/icon/goland.svg", build = "253.28294.337" },
|
"IU" = { build = "261.22158.277" },
|
||||||
"IU" = { name = "IntelliJ IDEA", icon = "/icon/intellij.svg", build = "253.29346.138" },
|
|
||||||
}
|
}
|
||||||
EOT
|
EOT
|
||||||
type = map(object({
|
type = map(object({
|
||||||
name = string
|
|
||||||
icon = string
|
|
||||||
build = string
|
build = string
|
||||||
|
name = optional(string)
|
||||||
|
icon = optional(string)
|
||||||
}))
|
}))
|
||||||
default = {
|
default = null
|
||||||
"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" }
|
|
||||||
}
|
|
||||||
validation {
|
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."
|
error_message = "The ide_config must not be empty."
|
||||||
}
|
}
|
||||||
# ide_config must be a superset of var.options
|
# ide_config must be a superset of var.options
|
||||||
# Requires Terraform 1.9+ for cross-variable validation references
|
# Requires Terraform 1.9+ for cross-variable validation references
|
||||||
validation {
|
validation {
|
||||||
condition = alltrue([
|
condition = var.ide_config == null || alltrue([
|
||||||
for code in var.options : contains(keys(var.ide_config), code)
|
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 {
|
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.
|
# Determine the user's actual IDE selection.
|
||||||
# This is computed before the HTTP data source so that version lookups
|
# This is computed before the HTTP data source so that version lookups
|
||||||
# are only performed for IDEs the user chose — not every option.
|
# 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)
|
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 = {
|
parsed_responses = {
|
||||||
for code in local.selected_ides : code => try(
|
for code, response in data.http.jetbrains_ide_versions :
|
||||||
jsondecode(data.http.jetbrains_ide_versions[code].response_body),
|
code => jsondecode(response.response_body)
|
||||||
{} # Return empty object if API call fails
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Filter the parsed response for the requested major version if not "latest"
|
# Filter the parsed response for the requested major version if not "latest"
|
||||||
filtered_releases = {
|
filtered_releases = {
|
||||||
for code in local.selected_ides : code => [
|
for code, parsed in local.parsed_responses : code => [
|
||||||
for r in try(local.parsed_responses[code][keys(local.parsed_responses[code])[0]], []) :
|
for r in parsed[keys(parsed)[0]] :
|
||||||
r if var.major_version == "latest" || r.majorVersion == var.major_version
|
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)
|
# Select the latest release for the requested major version (first item in the filtered list)
|
||||||
selected_releases = {
|
selected_releases = {
|
||||||
for code in local.selected_ides : code =>
|
for code, releases in local.filtered_releases :
|
||||||
length(local.filtered_releases[code]) > 0 ? local.filtered_releases[code][0] : null
|
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 = {
|
options_metadata = {
|
||||||
for code in local.selected_ides : code => {
|
for code in local.selected_ides : code => {
|
||||||
icon = var.ide_config[code].icon
|
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[code].name
|
name = var.ide_config != null ? coalesce(var.ide_config[code].name, local.ide_metadata[code].name) : local.ide_metadata[code].name
|
||||||
identifier = code
|
identifier = code
|
||||||
key = code
|
key = code
|
||||||
|
|
||||||
# Use API build number if available, otherwise fall back to ide_config build number
|
# When ide_config is set, use the pinned build number directly.
|
||||||
build = local.selected_releases[code] != null ? local.selected_releases[code].build : var.ide_config[code].build
|
# 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
|
# API response data, null when using ide_config.
|
||||||
json_data = local.selected_releases[code]
|
json_data = var.ide_config != null ? null : local.selected_releases[code]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -233,8 +264,8 @@ data "coder_parameter" "jetbrains_ides" {
|
|||||||
dynamic "option" {
|
dynamic "option" {
|
||||||
for_each = var.options
|
for_each = var.options
|
||||||
content {
|
content {
|
||||||
icon = var.ide_config[option.value].icon
|
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[option.value].name
|
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
|
value = option.value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user