diff --git a/registry/coder/modules/aws-region/README.md b/registry/coder/modules/aws-region/README.md index c2527a306..ceb82f291 100644 --- a/registry/coder/modules/aws-region/README.md +++ b/registry/coder/modules/aws-region/README.md @@ -14,15 +14,15 @@ the region closest to them. Customize the preselected parameter value: ```tf -module "aws-region" { +module "aws_region" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/aws-region/coder" - version = "1.0.31" + version = "1.1.0" default = "us-east-1" } provider "aws" { - region = module.aws_region.value + region = module.aws_region[0].value } ``` @@ -30,15 +30,62 @@ provider "aws" { ## Examples +### Provision in the selected region's availability zone + +The `default_availability_zone` output resolves the selected region to a +concrete zone (for example `us-east-1a`), so templates no longer have to guess +it by appending a letter to the region ID: + +```tf +module "aws_region" { + source = "registry.coder.com/coder/aws-region/coder" + version = "1.1.0" + default = "us-east-1" +} + +provider "aws" { + region = module.aws_region.value +} + +resource "aws_instance" "dev" { + ami = data.aws_ami.ubuntu.id + instance_type = "t3.micro" + availability_zone = module.aws_region.default_availability_zone + # ... +} +``` + +### Use the outputs without a parameter + +Set `create_parameter = false` to skip the region picker and pin a region +yourself, while still using the module's outputs (for example +`default_availability_zone` or the full `regions` catalog): + +```tf +module "aws_region" { + source = "registry.coder.com/coder/aws-region/coder" + version = "1.1.0" + create_parameter = false + default = "us-east-1" +} + +provider "aws" { + region = module.aws_region.value # "us-east-1" +} + +# module.aws_region.default_availability_zone => "us-east-1a" +# module.aws_region.regions => full catalog keyed by region ID +``` + ### Customize regions Change the display name and icon for a region using the corresponding maps: ```tf -module "aws-region" { +module "aws_region" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/aws-region/coder" - version = "1.0.31" + version = "1.1.0" default = "ap-south-1" custom_names = { @@ -51,7 +98,7 @@ module "aws-region" { } provider "aws" { - region = module.aws_region.value + region = module.aws_region[0].value } ``` @@ -62,20 +109,68 @@ provider "aws" { Hide the Asia Pacific regions Seoul and Osaka: ```tf -module "aws-region" { +module "aws_region" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/aws-region/coder" - version = "1.0.31" + version = "1.1.0" exclude = ["ap-northeast-2", "ap-northeast-3"] } provider "aws" { - region = module.aws_region.value + region = module.aws_region[0].value } ``` ![AWS Exclude](../../.images/aws-exclude.png) +## Outputs + +| Output | Description | +| --------------------------- | ------------------------------------------------------------------------------------ | +| `value` | The ID of the selected region, e.g. `us-east-1`. | +| `default_availability_zone` | The default availability zone for the selected region, e.g. `us-east-1a`. | +| `regions` | Every region keyed by ID, each with `name`, `icon`, and `default_availability_zone`. | + +## Updating regions.json + +`regions.json` is a generated catalog of region IDs, display names, and flag +icons, so the module needs no AWS provider or credentials at plan time. +Terraform only reads the file; all the flag logic lives in the script below. + +Regenerate the whole file from AWS with the AWS CLI (any credentials) and `jq`, +run from this module's directory. Names and country codes come from the public +`global-infrastructure` SSM parameters in `us-east-1`: + +```bash +#!/usr/bin/env bash +set -euo pipefail + +# Two-letter country code -> Coder flag emoji asset (regional indicator pair). +icon() { + local a b + a=$(printf '%x' $((0x1f1e6 + $(printf '%d' "'${1:0:1}") - 0x61))) + b=$(printf '%x' $((0x1f1e6 + $(printf '%d' "'${1:1:1}") - 0x61))) + printf '/emojis/%s-%s.png' "$a" "$b" +} + +for region in $(aws ec2 describe-regions --all-regions \ + --query 'Regions[].RegionName' --output text | tr '\t' '\n' | sort); do + name=$(aws ssm get-parameter --region us-east-1 \ + --name "/aws/service/global-infrastructure/regions/$region/longName" \ + --query Parameter.Value --output text) + # European regions share the EU flag; every other region uses its country flag. + if [[ $region == eu-* ]]; then + country=eu + else + country=$(aws ssm get-parameter --region us-east-1 \ + --name "/aws/service/global-infrastructure/regions/$region/geolocationCountry" \ + --query Parameter.Value --output text | tr '[:upper:]' '[:lower:]') + fi + jq -n --arg value "$region" --arg name "$name" --arg icon "$(icon "$country")" \ + '{$value, $name, $icon}' +done | jq -s '.' > regions.json +``` + ## Related templates For a complete AWS EC2 template, see the following examples in the [Coder Registry](https://registry.coder.com/). diff --git a/registry/coder/modules/aws-region/main.test.ts b/registry/coder/modules/aws-region/main.test.ts deleted file mode 100644 index b7768cf2e..000000000 --- a/registry/coder/modules/aws-region/main.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { describe, expect, it } from "bun:test"; -import { - runTerraformApply, - runTerraformInit, - testRequiredVariables, -} from "~test"; - -describe("aws-region", async () => { - await runTerraformInit(import.meta.dir); - - testRequiredVariables(import.meta.dir, {}); - - it("default output", async () => { - const state = await runTerraformApply(import.meta.dir, {}); - expect(state.outputs.value.value).toBe(""); - }); - - it("customized default", async () => { - const state = await runTerraformApply(import.meta.dir, { - default: "us-west-2", - }); - 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/registry/coder/modules/aws-region/main.tf b/registry/coder/modules/aws-region/main.tf index 12a01fe76..3edd76134 100644 --- a/registry/coder/modules/aws-region/main.tf +++ b/registry/coder/modules/aws-region/main.tf @@ -22,9 +22,14 @@ variable "description" { } variable "default" { - default = "" - description = "The default region to use if no region is specified." + default = null + description = "The default region to preselect, e.g. \"us-east-1\". Leave unset for no preselection; also used as the selected region when create_parameter is false." type = string + + validation { + condition = var.default == null || can(regex("^[a-z]{2}-[a-z]+-[0-9]+$", var.default)) + error_message = "default must be null or a valid AWS region ID, e.g. \"us-east-1\"." + } } variable "mutable" { @@ -49,6 +54,11 @@ variable "exclude" { default = [] description = "A list of region IDs to exclude." type = list(string) + + validation { + condition = alltrue([for region in var.exclude : can(regex("^[a-z]{2}-[a-z]+-[0-9]+$", region))]) + error_message = "exclude must contain valid AWS region IDs, e.g. \"ap-northeast-2\"." + } } variable "coder_parameter_order" { @@ -57,143 +67,59 @@ variable "coder_parameter_order" { default = null } +variable "create_parameter" { + type = bool + description = "Whether to create the coder_parameter. Set to false to skip the region picker and use the module only for its outputs, e.g. the regions catalog or a default_availability_zone for a fixed default region." + default = true +} + locals { - # This is a static list because the regions don't change _that_ - # frequently and including the `aws_regions` data source requires - # the provider, which requires a region. - regions = { - "af-south-1" = { - name = "Africa (Cape Town)" - icon = "/emojis/1f1ff-1f1e6.png" - } - "ap-east-1" = { - name = "Asia Pacific (Hong Kong)" - icon = "/emojis/1f1ed-1f1f0.png" - } - "ap-northeast-1" = { - name = "Asia Pacific (Tokyo)" - icon = "/emojis/1f1ef-1f1f5.png" - } - "ap-northeast-2" = { - name = "Asia Pacific (Seoul)" - icon = "/emojis/1f1f0-1f1f7.png" - } - "ap-northeast-3" = { - name = "Asia Pacific (Osaka)" - icon = "/emojis/1f1ef-1f1f5.png" - } - "ap-south-1" = { - name = "Asia Pacific (Mumbai)" - icon = "/emojis/1f1ee-1f1f3.png" - } - "ap-south-2" = { - name = "Asia Pacific (Hyderabad)" - icon = "/emojis/1f1ee-1f1f3.png" - } - "ap-southeast-1" = { - name = "Asia Pacific (Singapore)" - icon = "/emojis/1f1f8-1f1ec.png" - } - "ap-southeast-2" = { - name = "Asia Pacific (Sydney)" - icon = "/emojis/1f1e6-1f1fa.png" - } - "ap-southeast-3" = { - name = "Asia Pacific (Jakarta)" - icon = "/emojis/1f1ee-1f1e9.png" - } - "ap-southeast-4" = { - name = "Asia Pacific (Melbourne)" - icon = "/emojis/1f1e6-1f1fa.png" - } - "ca-central-1" = { - name = "Canada (Central)" - icon = "/emojis/1f1e8-1f1e6.png" - } - "ca-west-1" = { - name = "Canada West (Calgary)" - icon = "/emojis/1f1e8-1f1e6.png" - } - "eu-central-1" = { - name = "EU (Frankfurt)" - icon = "/emojis/1f1ea-1f1fa.png" - } - "eu-central-2" = { - name = "Europe (Zurich)" - icon = "/emojis/1f1ea-1f1fa.png" - } - "eu-north-1" = { - name = "EU (Stockholm)" - icon = "/emojis/1f1ea-1f1fa.png" - } - "eu-south-1" = { - name = "Europe (Milan)" - icon = "/emojis/1f1ea-1f1fa.png" - } - "eu-south-2" = { - name = "Europe (Spain)" - icon = "/emojis/1f1ea-1f1fa.png" - } - "eu-west-1" = { - name = "EU (Ireland)" - icon = "/emojis/1f1ea-1f1fa.png" - } - "eu-west-2" = { - name = "EU (London)" - icon = "/emojis/1f1ea-1f1fa.png" - } - "eu-west-3" = { - name = "EU (Paris)" - icon = "/emojis/1f1ea-1f1fa.png" - } - "il-central-1" = { - name = "Israel (Tel Aviv)" - icon = "/emojis/1f1ee-1f1f1.png" - } - "me-south-1" = { - name = "Middle East (Bahrain)" - icon = "/emojis/1f1e7-1f1ed.png" - } - "sa-east-1" = { - name = "South America (São Paulo)" - icon = "/emojis/1f1e7-1f1f7.png" - } - "us-east-1" = { - name = "US East (N. Virginia)" - icon = "/emojis/1f1fa-1f1f8.png" - } - "us-east-2" = { - name = "US East (Ohio)" - icon = "/emojis/1f1fa-1f1f8.png" - } - "us-west-1" = { - name = "US West (N. California)" - icon = "/emojis/1f1fa-1f1f8.png" - } - "us-west-2" = { - name = "US West (Oregon)" - icon = "/emojis/1f1fa-1f1f8.png" + # Read regions.json with a fallback: Terraform resolves file() from the root + # module, but Coder's dynamic parameters preview resolves it from this module's + # directory, so neither path works on its own. + regions = jsondecode(try(file("${path.module}/regions.json"), file("regions.json"))) + + regions_by_id = { + for region in local.regions : region.value => { + value = region.value + name = region.name + icon = region.icon + default_availability_zone = "${region.value}a" } } + + selected_region = var.create_parameter ? one(data.coder_parameter.region[*].value) : var.default } data "coder_parameter" "region" { + count = var.create_parameter ? 1 : 0 name = "aws_region" display_name = var.display_name description = var.description - default = var.default == "" ? null : var.default + default = 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)) } + for_each = [for region in local.regions : region if !contains(var.exclude, region.value)] content { - name = try(var.custom_names[option.key], option.value.name) - icon = try(var.custom_icons[option.key], option.value.icon) - value = option.key + name = try(var.custom_names[option.value.value], option.value.name) + icon = try(var.custom_icons[option.value.value], option.value.icon) + value = option.value.value } } } output "value" { - value = data.coder_parameter.region.value -} \ No newline at end of file + description = "The ID of the selected AWS region, e.g. \"us-east-1\". Falls back to var.default when create_parameter is false." + value = local.selected_region +} + +output "default_availability_zone" { + description = "The default availability zone for the selected region, e.g. \"us-east-1a\". Empty when no region is selected." + value = try(local.regions_by_id[local.selected_region].default_availability_zone, "") +} + +output "regions" { + description = "All AWS regions keyed by region ID, each with name, icon, and default_availability_zone." + value = local.regions_by_id +} diff --git a/registry/coder/modules/aws-region/main.tftest.hcl b/registry/coder/modules/aws-region/main.tftest.hcl new file mode 100644 index 000000000..781439a65 --- /dev/null +++ b/registry/coder/modules/aws-region/main.tftest.hcl @@ -0,0 +1,136 @@ +run "parameter_name" { + command = plan + + assert { + condition = data.coder_parameter.region[0].name == "aws_region" + error_message = "Parameter name should be aws_region" + } +} + +run "custom_order" { + command = plan + + variables { + coder_parameter_order = 99 + } + + assert { + condition = data.coder_parameter.region[0].order == 99 + error_message = "coder_parameter_order should propagate to the parameter order" + } +} + +run "all_regions_are_options" { + command = plan + + assert { + condition = length(data.coder_parameter.region[0].option) == length(local.regions) + error_message = "Every catalog region should be rendered as a selectable option" + } +} + +run "default_output_empty" { + command = apply + + assert { + condition = output.value == "" && output.default_availability_zone == "" + error_message = "With no default and no selection, value and default_availability_zone should be empty" + } +} + +run "custom_default" { + command = apply + + variables { + default = "us-west-2" + } + + assert { + condition = output.value == "us-west-2" && output.default_availability_zone == "us-west-2a" + error_message = "value and default_availability_zone should follow the configured default" + } +} + +run "regions_output_exposes_catalog" { + command = plan + + assert { + condition = length(output.regions) == length(local.regions) + error_message = "regions output should expose every catalog entry keyed by ID" + } + + assert { + condition = ( + output.regions["ap-northeast-1"].name == "Asia Pacific (Tokyo)" && + output.regions["ap-northeast-1"].icon == "/emojis/1f1ef-1f1f5.png" && + output.regions["ap-northeast-1"].default_availability_zone == "ap-northeast-1a" + ) + error_message = "regions entries should expose name, icon, and default_availability_zone" + } +} + +run "exclude_removes_option" { + command = apply + + variables { + exclude = ["ap-northeast-2", "ap-northeast-3"] + } + + assert { + condition = ( + !contains([for o in data.coder_parameter.region[0].option : o.value], "ap-northeast-2") && + !contains([for o in data.coder_parameter.region[0].option : o.value], "ap-northeast-3") + ) + error_message = "Excluded regions should not appear as options" + } +} + +run "custom_names_and_icons_override" { + command = apply + + variables { + custom_names = { + "ap-south-1" = "Awesome Mumbai!" + } + custom_icons = { + "ap-south-1" = "/emojis/1f33a.png" + } + } + + assert { + condition = length([for o in data.coder_parameter.region[0].option : o if o.value == "ap-south-1" && o.name == "Awesome Mumbai!" && o.icon == "/emojis/1f33a.png"]) == 1 + error_message = "custom_names and custom_icons should override the defaults for a region" + } +} + +run "outputs_without_parameter" { + command = apply + + variables { + create_parameter = false + default = "eu-west-1" + } + + assert { + condition = length(data.coder_parameter.region) == 0 + error_message = "create_parameter = false should not create the coder_parameter" + } + + assert { + condition = output.value == "eu-west-1" && output.default_availability_zone == "eu-west-1a" + error_message = "With create_parameter = false, outputs should fall back to var.default" + } +} + +run "no_parameter_and_no_default" { + command = apply + + variables { + create_parameter = false + } + + assert { + condition = output.value == null && output.default_availability_zone == "" + error_message = "With no parameter and no default, value is null and default_availability_zone is empty" + } +} diff --git a/registry/coder/modules/aws-region/regions.json b/registry/coder/modules/aws-region/regions.json new file mode 100644 index 000000000..7ae421da6 --- /dev/null +++ b/registry/coder/modules/aws-region/regions.json @@ -0,0 +1,142 @@ +[ + { + "value": "af-south-1", + "name": "Africa (Cape Town)", + "icon": "/emojis/1f1ff-1f1e6.png" + }, + { + "value": "ap-east-1", + "name": "Asia Pacific (Hong Kong)", + "icon": "/emojis/1f1ed-1f1f0.png" + }, + { + "value": "ap-northeast-1", + "name": "Asia Pacific (Tokyo)", + "icon": "/emojis/1f1ef-1f1f5.png" + }, + { + "value": "ap-northeast-2", + "name": "Asia Pacific (Seoul)", + "icon": "/emojis/1f1f0-1f1f7.png" + }, + { + "value": "ap-northeast-3", + "name": "Asia Pacific (Osaka)", + "icon": "/emojis/1f1ef-1f1f5.png" + }, + { + "value": "ap-south-1", + "name": "Asia Pacific (Mumbai)", + "icon": "/emojis/1f1ee-1f1f3.png" + }, + { + "value": "ap-south-2", + "name": "Asia Pacific (Hyderabad)", + "icon": "/emojis/1f1ee-1f1f3.png" + }, + { + "value": "ap-southeast-1", + "name": "Asia Pacific (Singapore)", + "icon": "/emojis/1f1f8-1f1ec.png" + }, + { + "value": "ap-southeast-2", + "name": "Asia Pacific (Sydney)", + "icon": "/emojis/1f1e6-1f1fa.png" + }, + { + "value": "ap-southeast-3", + "name": "Asia Pacific (Jakarta)", + "icon": "/emojis/1f1ee-1f1e9.png" + }, + { + "value": "ap-southeast-4", + "name": "Asia Pacific (Melbourne)", + "icon": "/emojis/1f1e6-1f1fa.png" + }, + { + "value": "ca-central-1", + "name": "Canada (Central)", + "icon": "/emojis/1f1e8-1f1e6.png" + }, + { + "value": "ca-west-1", + "name": "Canada West (Calgary)", + "icon": "/emojis/1f1e8-1f1e6.png" + }, + { + "value": "eu-central-1", + "name": "Europe (Frankfurt)", + "icon": "/emojis/1f1ea-1f1fa.png" + }, + { + "value": "eu-central-2", + "name": "Europe (Zurich)", + "icon": "/emojis/1f1ea-1f1fa.png" + }, + { + "value": "eu-north-1", + "name": "Europe (Stockholm)", + "icon": "/emojis/1f1ea-1f1fa.png" + }, + { + "value": "eu-south-1", + "name": "Europe (Milan)", + "icon": "/emojis/1f1ea-1f1fa.png" + }, + { + "value": "eu-south-2", + "name": "Europe (Spain)", + "icon": "/emojis/1f1ea-1f1fa.png" + }, + { + "value": "eu-west-1", + "name": "Europe (Ireland)", + "icon": "/emojis/1f1ea-1f1fa.png" + }, + { + "value": "eu-west-2", + "name": "Europe (London)", + "icon": "/emojis/1f1ea-1f1fa.png" + }, + { + "value": "eu-west-3", + "name": "Europe (Paris)", + "icon": "/emojis/1f1ea-1f1fa.png" + }, + { + "value": "il-central-1", + "name": "Israel (Tel Aviv)", + "icon": "/emojis/1f1ee-1f1f1.png" + }, + { + "value": "me-south-1", + "name": "Middle East (Bahrain)", + "icon": "/emojis/1f1e7-1f1ed.png" + }, + { + "value": "sa-east-1", + "name": "South America (São Paulo)", + "icon": "/emojis/1f1e7-1f1f7.png" + }, + { + "value": "us-east-1", + "name": "US East (N. Virginia)", + "icon": "/emojis/1f1fa-1f1f8.png" + }, + { + "value": "us-east-2", + "name": "US East (Ohio)", + "icon": "/emojis/1f1fa-1f1f8.png" + }, + { + "value": "us-west-1", + "name": "US West (N. California)", + "icon": "/emojis/1f1fa-1f1f8.png" + }, + { + "value": "us-west-2", + "name": "US West (Oregon)", + "icon": "/emojis/1f1fa-1f1f8.png" + } +]