LAB49_AWS_Advanced HCL: Dynamic Blocks, Complex Expressions, and Custom Conditions

Advanced HCL: Dynamic Blocks, Complex Expressions, and Custom Conditions

☁ AWS 🔥 Advanced Dynamic Blocks Expressions precondition postcondition Custom Validation HCL Mastery

Scenario

You manage security groups for 12 different microservices. Each service has a different set of ingress rules defined in a YAML-like variable structure. Writing 12 separate aws_security_group resources with 6 ingress blocks each would be 240 lines of repetitive HCL. You need to use advanced Terraform HCL patterns — dynamic blocks, complex expressions, and lifecycle conditions — to keep the configuration concise and safe.

Your Objectives
  • Define a variable that is a map(object) where each object has a service name, a list of ingress rules (protocol, port, CIDR), and a boolean allow_all_egress.
  • Use for_each to create one security group per service. Within each resource, use a dynamic "ingress" block to generate all ingress rules from the list.
  • Use a dynamic "egress" block that conditionally adds an allow-all rule only when allow_all_egress == true.
  • Add a lifecycle { precondition } that validates no service exposes port 22 to 0.0.0.0/0. Add a postcondition that verifies the SG was actually created in the correct VPC.
  • Use a complex output with for expressions to produce a map of service name → list of ingress ports.

Additional Context

Dynamic blocks are for generating repeated nested blocks (like ingress, rule, setting) from a list or map. They use an iterator variable (default: the block label name) to reference the current item. Without dynamic blocks, you'd need a separate resource for each combination.

precondition vs variable validation: Variable validation runs at plan time for input values. lifecycle { precondition } runs during plan after the full configuration is evaluated — it can reference other resources, data sources, and computed values. postcondition runs during apply after the resource is created, verifying the actual result from AWS.

variables.tf

variable "vpc_id" { type = string }

variable "services" {
  description = "Map of microservices with their security group rules."
  type = map(object({
    description     = string
    allow_all_egress = bool
    ingress_rules   = list(object({
      port        = number
      protocol    = string
      cidr_blocks = list(string)
    }))
  }))

  default = {
    api = {
      description      = "Public-facing API"
      allow_all_egress = true
      ingress_rules = [
        { port = 443; protocol = "tcp"; cidr_blocks = ["0.0.0.0/0"] }
        { port = 80;  protocol = "tcp"; cidr_blocks = ["0.0.0.0/0"] }
      ]
    }
    worker = {
      description      = "Background job worker"
      allow_all_egress = true
      ingress_rules = [
        { port = 8080; protocol = "tcp"; cidr_blocks = ["10.0.0.0/8"] }
      ]
    }
    db_proxy = {
      description      = "Database proxy — no outbound internet"
      allow_all_egress = false  # restricted egress
      ingress_rules = [
        { port = 5432; protocol = "tcp"; cidr_blocks = ["10.0.0.0/8"] }
      ]
    }
  }
}

main.tf

provider "aws" { region = "us-east-1" }

data "aws_vpc" "selected" { id = var.vpc_id }

resource "aws_security_group" "services" {
  for_each    = var.services
  name        = "sg-${each.key}"
  description = each.value.description
  vpc_id      = var.vpc_id

  # Dynamic ingress — generates one ingress block per rule in the list.
  dynamic "ingress" {
    for_each = each.value.ingress_rules
    content {
      from_port   = ingress.value.port
      to_port     = ingress.value.port
      protocol    = ingress.value.protocol
      cidr_blocks = ingress.value.cidr_blocks
    }
  }

  # Dynamic egress — only adds the allow-all rule when explicitly enabled.
  dynamic "egress" {
    for_each = each.value.allow_all_egress ? [1] : []
    content {
      from_port   = 0
      to_port     = 0
      protocol    = "-1"
      cidr_blocks = ["0.0.0.0/0"]
    }
  }

  tags = { Name = "sg-${each.key}", ManagedBy = "terraform" }

  lifecycle {
    # precondition: evaluated at plan time — catch SSH-to-world before apply.
    precondition {
      condition = !anytrue([
        for rule in each.value.ingress_rules :
        rule.port == 22 && contains(rule.cidr_blocks, "0.0.0.0/0")
      ])
      error_message = "Service '${each.key}': Exposing port 22 to 0.0.0.0/0 is not permitted."
    }

    # postcondition: evaluated after apply — verify the SG is in the right VPC.
    postcondition {
      condition     = self.vpc_id == var.vpc_id
      error_message = "Security group was created in VPC ${self.vpc_id} but expected ${var.vpc_id}."
    }
  }
}

outputs.tf

# Map of service → list of ingress ports (complex for expression).
output "service_ports" {
  value = {
    for name, sg in aws_security_group.services :
    name => [
      for rule in var.services[name].ingress_rules : rule.port
    ]
  }
}

# Map of service → SG ID.
output "security_group_ids" {
  value = { for name, sg in aws_security_group.services : name => sg.id }
}

# Services with restricted egress — filter using for + if.
output "restricted_egress_services" {
  value = [
    for name, svc in var.services : name if !svc.allow_all_egress
  ]
}

Workflow Commands

terraform init && terraform apply -var="vpc_id=vpc-xxxx"

# Test precondition by adding a bad rule (should fail at plan, not apply)
# Temporarily add to variables:
# bad_service = {
#   description = "test"; allow_all_egress = true
#   ingress_rules = [{ port = 22; protocol = "tcp"; cidr_blocks = ["0.0.0.0/0"] }]
# }
terraform plan -var="vpc_id=vpc-xxxx" # → Error: port 22 to 0.0.0.0/0 is not permitted

# View complex outputs
terraform output -json service_ports
# { "api": [443, 80], "db_proxy": [5432], "worker": [8080] }

terraform output -json restricted_egress_services
# ["db_proxy"]

terraform output -json security_group_ids

terraform destroy -var="vpc_id=vpc-xxxx"
✓ Expected Output After Apply
Apply complete! Resources: 3 added, 0 changed, 0 destroyed.

Outputs:

restricted_egress_services = ["db_proxy"]
security_group_ids = {
  "api"      = "sg-0aaa..."
  "db_proxy" = "sg-0bbb..."
  "worker"   = "sg-0ccc..."
}
service_ports = {
  "api"      = [443, 80]
  "db_proxy" = [5432]
  "worker"   = [8080]
}