diff --git a/README.md b/README.md index 8ddd328..197d765 100644 --- a/README.md +++ b/README.md @@ -134,13 +134,16 @@ docker run --rm -v ${pwd}:/src -w /src -e ARM_SUBSCRIPTION_ID -e ARM_TENANT_ID - | Name | Version | |------|---------| | [terraform](#requirement\_terraform) | >= 1.2 | +| [azapi](#requirement\_azapi) | ~> 2.0 | | [azurerm](#requirement\_azurerm) | >= 3.98, < 4.0 | ## Providers | Name | Version | |------|---------| +| [azapi](#provider\_azapi) | ~> 2.0 | | [azurerm](#provider\_azurerm) | >= 3.98, < 4.0 | +| [terraform](#provider\_terraform) | n/a | ## Modules @@ -150,11 +153,13 @@ No modules. | Name | Type | |------|------| +| [azapi_update_resource.container_app_ingress_additional_port_mappings](https://registry.terraform.io/providers/Azure/azapi/latest/docs/resources/update_resource) | resource | | [azurerm_container_app.container_app](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/container_app) | resource | | [azurerm_container_app_environment.container_env](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/container_app_environment) | resource | | [azurerm_container_app_environment_dapr_component.dapr](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/container_app_environment_dapr_component) | resource | | [azurerm_container_app_environment_storage.storage](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/container_app_environment_storage) | resource | | [azurerm_log_analytics_workspace.laws](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/log_analytics_workspace) | resource | +| [terraform_data.container_app_ingress_additional_port_mappings_keeper](https://registry.terraform.io/providers/hashicorp/terraform/latest/docs/resources/data) | resource | | [azurerm_container_app_environment.container_env](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/data-sources/container_app_environment) | data source | ## Inputs @@ -167,7 +172,7 @@ No modules. | [container\_app\_environment\_name](#input\_container\_app\_environment\_name) | (Required) The name of the container apps managed environment. Changing this forces a new resource to be created. | `string` | n/a | yes | | [container\_app\_environment\_tags](#input\_container\_app\_environment\_tags) | A map of the tags to use on the resources that are deployed with this module. | `map(string)` | `{}` | no | | [container\_app\_secrets](#input\_container\_app\_secrets) | (Optional) The secrets of the container apps. The key of the map should be aligned with the corresponding container app. |
map(list(object({
name = string
value = optional(string, null)
identity = optional(string, null)
key_vault_secret_id = optional(string, null)
})))
| `{}` | no | -| [container\_apps](#input\_container\_apps) | The container apps to deploy. |
map(object({
name = string
tags = optional(map(string))
revision_mode = string
workload_profile_name = optional(string)

template = object({
init_containers = optional(set(object({
args = optional(list(string))
command = optional(list(string))
cpu = optional(number)
image = string
name = string
memory = optional(string)
env = optional(list(object({
name = string
secret_name = optional(string)
value = optional(string)
})))
volume_mounts = optional(list(object({
name = string
path = string
})))
})), [])
containers = set(object({
name = string
image = string
args = optional(list(string))
command = optional(list(string))
cpu = string
memory = string
env = optional(set(object({
name = string
secret_name = optional(string)
value = optional(string)
})))
liveness_probe = optional(object({
failure_count_threshold = optional(number)
header = optional(object({
name = string
value = string
}))
host = optional(string)
initial_delay = optional(number, 1)
interval_seconds = optional(number, 10)
path = optional(string)
port = number
timeout = optional(number, 1)
transport = string
}))
readiness_probe = optional(object({
failure_count_threshold = optional(number)
header = optional(object({
name = string
value = string
}))
host = optional(string)
interval_seconds = optional(number, 10)
path = optional(string)
port = number
success_count_threshold = optional(number, 3)
timeout = optional(number)
transport = string
}))
startup_probe = optional(object({
failure_count_threshold = optional(number)
header = optional(object({
name = string
value = string
}))
host = optional(string)
interval_seconds = optional(number, 10)
path = optional(string)
port = number
timeout = optional(number)
transport = string
}))
volume_mounts = optional(list(object({
name = string
path = string
})))
}))
max_replicas = optional(number)
min_replicas = optional(number)
revision_suffix = optional(string)
custom_scale_rule = optional(list(object({
custom_rule_type = string
metadata = map(string)
name = string
authentication = optional(list(object({
secret_name = string
trigger_parameter = string
})))
})))
http_scale_rule = optional(list(object({
concurrent_requests = string
name = string
authentication = optional(list(object({
secret_name = string
trigger_parameter = optional(string)
})))
})))
volume = optional(set(object({
name = string
storage_name = optional(string)
storage_type = optional(string)
})))
})

ingress = optional(object({
allow_insecure_connections = optional(bool, false)
external_enabled = optional(bool, false)
ip_security_restrictions = optional(list(object({
action = string
ip_address_range = string
name = string
description = optional(string)
})), [])
target_port = number
exposed_port = optional(number)
transport = optional(string)
traffic_weight = object({
label = optional(string)
latest_revision = optional(string)
revision_suffix = optional(string)
percentage = number
})
}))

identity = optional(object({
type = string
identity_ids = optional(list(string))
}))

dapr = optional(object({
app_id = string
app_port = number
app_protocol = optional(string)
}))

registry = optional(list(object({
server = string
username = optional(string)
password_secret_name = optional(string)
identity = optional(string)
})))

}))
| n/a | yes | +| [container\_apps](#input\_container\_apps) | The container apps to deploy. |
map(object({
name = string
tags = optional(map(string))
revision_mode = string
workload_profile_name = optional(string)

template = object({
init_containers = optional(set(object({
args = optional(list(string))
command = optional(list(string))
cpu = optional(number)
image = string
name = string
memory = optional(string)
env = optional(list(object({
name = string
secret_name = optional(string)
value = optional(string)
})))
volume_mounts = optional(list(object({
name = string
path = string
})))
})), [])
containers = set(object({
name = string
image = string
args = optional(list(string))
command = optional(list(string))
cpu = string
memory = string
env = optional(set(object({
name = string
secret_name = optional(string)
value = optional(string)
})))
liveness_probe = optional(object({
failure_count_threshold = optional(number)
header = optional(object({
name = string
value = string
}))
host = optional(string)
initial_delay = optional(number, 1)
interval_seconds = optional(number, 10)
path = optional(string)
port = number
timeout = optional(number, 1)
transport = string
}))
readiness_probe = optional(object({
failure_count_threshold = optional(number)
header = optional(object({
name = string
value = string
}))
host = optional(string)
interval_seconds = optional(number, 10)
path = optional(string)
port = number
success_count_threshold = optional(number, 3)
timeout = optional(number)
transport = string
}))
startup_probe = optional(object({
failure_count_threshold = optional(number)
header = optional(object({
name = string
value = string
}))
host = optional(string)
interval_seconds = optional(number, 10)
path = optional(string)
port = number
timeout = optional(number)
transport = string
}))
volume_mounts = optional(list(object({
name = string
path = string
})))
}))
max_replicas = optional(number)
min_replicas = optional(number)
revision_suffix = optional(string)
custom_scale_rule = optional(list(object({
custom_rule_type = string
metadata = map(string)
name = string
authentication = optional(list(object({
secret_name = string
trigger_parameter = string
})))
})))
http_scale_rule = optional(list(object({
concurrent_requests = string
name = string
authentication = optional(list(object({
secret_name = string
trigger_parameter = optional(string)
})))
})))
volume = optional(set(object({
name = string
storage_name = optional(string)
storage_type = optional(string)
})))
})

ingress = optional(object({
allow_insecure_connections = optional(bool, false)
external_enabled = optional(bool, false)
ip_security_restrictions = optional(list(object({
action = string
ip_address_range = string
name = string
description = optional(string)
})), [])
target_port = number
exposed_port = optional(number)
transport = optional(string)
traffic_weight = object({
label = optional(string)
latest_revision = optional(string)
revision_suffix = optional(string)
percentage = number
})
additional_port_mappings = optional(list(object({
external = bool
target_port = number
exposed_port = optional(number)
})), [])
}))

identity = optional(object({
type = string
identity_ids = optional(list(string))
}))

dapr = optional(object({
app_id = string
app_port = number
app_protocol = optional(string)
}))

registry = optional(list(object({
server = string
username = optional(string)
password_secret_name = optional(string)
identity = optional(string)
})))

}))
| n/a | yes | | [dapr\_component](#input\_dapr\_component) | (Optional) The Dapr component to deploy. |
map(object({
name = string
component_type = string
version = string
ignore_errors = optional(bool, false)
init_timeout = optional(string, "5s")
scopes = optional(list(string))
metadata = optional(set(object({
name = string
secret_name = optional(string)
value = string
})))
}))
| `{}` | no | | [dapr\_component\_secrets](#input\_dapr\_component\_secrets) | (Optional) The secrets of the Dapr components. The key of the map should be aligned with the corresponding Dapr component. |
map(list(object({
name = string
value = string
})))
| `{}` | no | | [env\_storage](#input\_env\_storage) | (Optional) Manages a Container App Environment Storage, writing files to this file share to make data accessible by other systems. |
map(object({
name = string
account_name = string
share_name = string
access_mode = string
}))
| `{}` | no | diff --git a/examples/additional_port/main.tf b/examples/additional_port/main.tf new file mode 100644 index 0000000..b2ada0b --- /dev/null +++ b/examples/additional_port/main.tf @@ -0,0 +1,93 @@ +resource "random_id" "rg_name" { + byte_length = 8 +} + +resource "random_id" "env_name" { + byte_length = 8 +} + +resource "random_id" "container_name" { + byte_length = 4 +} + +resource "azurerm_resource_group" "test" { + location = var.location + name = "example-container-app-${random_id.rg_name.hex}" +} + + +resource "azurerm_virtual_network" "vnet" { + address_space = ["10.0.0.0/16"] + location = azurerm_resource_group.test.location + name = "virtualnetwork1" + resource_group_name = azurerm_resource_group.test.name +} + +resource "azurerm_subnet" "subnet" { + address_prefixes = ["10.0.0.0/16"] + name = "subnet1" + resource_group_name = azurerm_resource_group.test.name + virtual_network_name = azurerm_virtual_network.vnet.name + private_endpoint_network_policies = "Disabled" + private_link_service_network_policies_enabled = false +} + +locals { + counting_app_name = "counting-${random_id.container_name.hex}" + dashboard_app_name = "dashboard-${random_id.container_name.hex}" +} + +module "container_apps" { + source = "../.." + resource_group_name = azurerm_resource_group.test.name + location = var.location + container_app_environment_name = "example-env-${random_id.env_name.hex}" + container_app_environment_infrastructure_subnet_id = azurerm_subnet.subnet.id + container_apps = { + dashboard = { + name = local.dashboard_app_name + revision_mode = "Single" + + template = { + containers = [ + { + name = "testdashboard" + memory = "1Gi" + cpu = 0.5 + image = "docker.io/hashicorp/dashboard-service:0.0.4" + env = [ + { + name = "PORT" + value = "8080" + }, + { + name = "COUNTING_SERVICE_URL" + value = "http://${local.counting_app_name}" + } + ] + }, + ] + } + + ingress = { + allow_insecure_connections = false + target_port = 8080 + external_enabled = true + transport = "tcp" + additional_port_mappings = [{ + external = true + target_port = 8082 + exposed_port = 8082 + }] + traffic_weight = { + latest_revision = true + percentage = 100 + } + } + identity = { + type = "SystemAssigned" + } + }, + } + log_analytics_workspace_name = "testlaws" +} \ No newline at end of file diff --git a/examples/additional_port/outputs.tf b/examples/additional_port/outputs.tf new file mode 100644 index 0000000..73d6323 --- /dev/null +++ b/examples/additional_port/outputs.tf @@ -0,0 +1,3 @@ +output "dashboard_url" { + value = try(module.container_apps.container_app_uri["dashboard"], "") +} diff --git a/examples/additional_port/variables.tf b/examples/additional_port/variables.tf new file mode 100644 index 0000000..817025c --- /dev/null +++ b/examples/additional_port/variables.tf @@ -0,0 +1,3 @@ +variable "location" { + default = "eastus" +} diff --git a/examples/additional_port/versions.tf b/examples/additional_port/versions.tf new file mode 100644 index 0000000..09d744b --- /dev/null +++ b/examples/additional_port/versions.tf @@ -0,0 +1,26 @@ +terraform { + required_version = ">= 1.2" + + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = ">= 3.98, < 4.0" + } + modtm = { + source = "Azure/modtm" + version = ">= 0.2.0, < 1.0" + } + random = { + source = "hashicorp/random" + version = ">= 3.0.0" + } + } +} + +provider "azurerm" { + features {} +} + +provider "modtm" { + enabled = false +} \ No newline at end of file diff --git a/locals.tf b/locals.tf index 7a00cfa..86978d5 100644 --- a/locals.tf +++ b/locals.tf @@ -2,5 +2,5 @@ locals { container_app_secrets = { for k, v in var.container_app_secrets : k => { for i in v : i.name => i } } dapr_component_secrets = { for k, v in var.dapr_component_secrets : k => { for i in v : i.name => i.value } } fqdns = { for name, container in azurerm_container_app.container_app : name => try(container.ingress[0].fqdn, "") if can(container.ingress[0].fqdn) } - uris = { for name, fqdn in local.fqdns : name => "https://${fqdn}" } + uris = try({ for name, fqdn in local.fqdns : name => "https://${fqdn}" }, {}) } \ No newline at end of file diff --git a/main.tf b/main.tf index 9b6629e..0481a8e 100644 --- a/main.tf +++ b/main.tf @@ -371,3 +371,43 @@ resource "azurerm_container_app" "container_app" { } } } + +resource "terraform_data" "container_app_ingress_additional_port_mappings_keeper" { + for_each = var.container_apps + triggers_replace = { + ingress = try([for p in each.value.ingress.additional_port_mappings : ({ + additionalPortMappings = [{ + external = p.external + targetPort = p.target_port + exposedPort = p.exposed_port + }] + })], null) + } +} + +resource "azapi_update_resource" "container_app_ingress_additional_port_mappings" { + for_each = true ? var.container_apps : {} + + type = "Microsoft.App/containerApps@2024-03-01" + body = { + properties = { + configuration = { + ingress = try({ + additionalPortMappings = [for p in each.value.ingress.additional_port_mappings : { + external = p.external + targetPort = p.target_port + exposedPort = p.exposed_port + }] + }, null) + } + } + } + resource_id = azurerm_container_app.container_app[each.key].id + + lifecycle { + ignore_changes = all + replace_triggered_by = [ + terraform_data.container_app_ingress_additional_port_mappings_keeper[each.key] + ] + } +} \ No newline at end of file diff --git a/test/e2e/terraform_e2e_test.go b/test/e2e/terraform_e2e_test.go index 7d70ed4..911fcce 100644 --- a/test/e2e/terraform_e2e_test.go +++ b/test/e2e/terraform_e2e_test.go @@ -66,6 +66,16 @@ func TestInitContainer(t *testing.T) { }) } +func TestExamplesAdditionalPort(t *testing.T) { + t.Parallel() + vars := make(map[string]interface{}) + + test_helper.RunE2ETest(t, "../../", "examples/additional_port", terraform.Options{ + Upgrade: true, + Vars: vars, + }, func(t *testing.T, output test_helper.TerraformOutput) {}) +} + func getHTML(url string) (string, error) { resp, err := http.Get(url) // #nosec G107 if err != nil { diff --git a/variables.tf b/variables.tf index 8fff611..6d8c2a4 100644 --- a/variables.tf +++ b/variables.tf @@ -132,6 +132,11 @@ variable "container_apps" { revision_suffix = optional(string) percentage = number }) + additional_port_mappings = optional(list(object({ + external = bool + target_port = number + exposed_port = optional(number) + })), []) })) identity = optional(object({ @@ -176,6 +181,10 @@ variable "container_apps" { condition = alltrue([for n, c in var.container_apps : c.ingress == null ? true : c.ingress.transport == "tcp" || c.ingress.exposed_port == null]) error_message = "`exposed_port` can only be specified when `transport` is set to `tcp`." } + validation { + condition = alltrue([for n, c in var.container_apps : c.ingress == null ? true : length(distinct(concat(try([c.ingress.target_port], []), try([for p in c.ingress.additional_port_mappings : p.target_port], [])))) == length(concat(try([c.ingress.target_port], []), try([for p in c.ingress.additional_port_mappings : p.target_port], [])))]) + error_message = "`target_port` in `ingress` must be unique." + } } variable "location" { diff --git a/versions.tf b/versions.tf index 2c82f76..c8086e3 100644 --- a/versions.tf +++ b/versions.tf @@ -2,6 +2,10 @@ terraform { required_version = ">= 1.2" required_providers { + azapi = { + source = "Azure/azapi" + version = "~> 2.0" + } azurerm = { source = "hashicorp/azurerm" version = ">= 3.98, < 4.0"