Onde trabalho, a maior parte da infra-estrutura (se não toda) é implementada com Terraform, andar a clicar na interface não é de todo incentivado, e por isso, fui questionado sobre a possibilidade de gerar um novo certificado TLS para encaminhar os utilizadores de um determinado domínio para o domínio principal.

Fáááácil! O certificado já é criado e validado com o Terraform. Alguns ajustes no módulo e isto fica despachado num instante!

Criei uma nova variável para o modulo receber a lista de domínios alternativos:

variable "secondary_domains" {
  tipo = list(string)
  description = "Domínios secundários para os quais criar o certificado"
  padrão = []
}

Actualizei uma variável já existente, responsável por gerar o nome alternativo de cada domino especifico e incluir os novos domínios secundários

env_alternative_names = var.primary_env == "true" ? flatten(["*.${var.domain}", var.secondary_domains]): ["*.${var.environment}.${var.domain}"]

Aproveitei, como quem não quer a coisa, para corrigir um aviso descontinuação, fiz upload do código para o repositório e juntei com a master branch e tive lançada a nova versão: v1.3.0.

  • Alterações aplicadas ao ambiente de desenvolvimento … Impecável! funciona bem.
  • Alterações aplicadas ao ambiente de pré-produção … só rosas e arco-íris! sou uma máquina!!!
  • Alterações aplicadas ao ambiente de pro … Falha na validação do certificado! Porra!! Devia ter percebido isto … esqueci-me completamente deste detalhe!

A vantagem de ter infra-estrutura em códico, devidamente versionada e com boas políticas de ciclo de vida dos recursos? Reverti para a v1.2.0, tudo voltou ao normal e sem interrupções!

E sim, em Produção!!! altamente, ou não?

De volta ao quadro branco!!

Então, agora tenho um problema! Como raio vou resolver isto?

O problema:

Agora tenho vários domínios para incluir no certificado e criar registos de validação de DNS em várias zonas do route53 e já que estou com a mão na massa, deve ser dinâmico o suficiente para adicionar tantos quanto façam falta no futuro.

Sendo que esta é uma prática bastante comum, tenho a certeza que alguém já arranjou forma de resolver isto! É hora de vestir o quimono e praticar todas as lições dos antigos mestres na arte do google-fu!

Não demorou muito para encontrar um módulo que fizesse exactamente o que eu queria ringanta/terraform-aws-acm-multiple-hosted-zone

No entanto, não se encaixa totalmente no nosso design e não posso simplesmente utilizar este módulo. A parte boa sobre código aberto? é aberto!

Dito isto, ainda passei um bom tempo a procurar outras abordagens, mas nenhuma me convenceu e optei por tentar entender melhor a abordagem do módulo acima.

O meu objectivo é sempre apontar para simplicidade, mas depois de passar algum tempo a brincar com as variáveis, acabei por chegar a solução mais complexa que implementei até a data com Terraform mas ainda assim (acredito eu) limpa e elegante o suficiente para ser entendida!

A solução

Algumas coisas não vão fazer muito sentido enquanto eu as explico, mas como em qualquer bom filme, no fim, tudo se encaixa perfeitamente!

1 - Gerar o nome alternativo específico do ambiente (esta já existia, mas foi ajustada para o valor anterior).

env_alternative_name = var.primary_env == "true" ? "*.${var.domain}" : "*.${var.environment}.${var.domain}"

2 - Atualizei a variável alternative_domains para receber a lista de mapas domínio => zona

variable "alternative_domains" {
  tipo = lista(mapa(string))
  description = "Domínios secundários para os quais criar o certificado"
  padrão = []
}

3 - Criei uma lista com todos os nomes alternativos para o certificado.

Para isso, fiz a variável local.env_alternative_name parte de uma lista com um único item e depois um loop pelos domínios dentro da variável alternative_domains cria outra lista com os domínios alternativos fornecidos ao módulo e no fim junta-se tudo numa lista!

all_alternative_names = concat(
    [local.env_alternative_name],
    [for an in var.alternative_domains : an.domain]
  )

4 - Criei uma lista de todos os domínios na ordem correta. Este segue a mesma lógica de variável anterior, mas desta vez o primeiro elemento precisa ser o domínio primário do ambiente, depois o nome alternativo específico do ambiente e, em seguida, todos os domínios alternativos fornecidos.

all_domains = concat(
    [local.env_domain],
    [local.env_alternative_name],
    [for d in var.alternative_domains : d.domain]
  )

5 - Criei uma lista de todas as zonas de DNS na ordem correta, seguindo a mesma lógica de antes, primeiro a zona DNS do domínio primário, depois a específica do ambiente e finalmente as zonas hospedadas para cada domínio alternativo existentes na variável alternative_domains.

all_zones = concat(
    [var.zone_name],
    [var.zone_name],
    [for z in var.alternative_domains : z.zone]
  )

6 - Com a função distinct(), criei uma lista apenas com valores úicos de todas as zonas de DNS

distinct_zones = distinct(local.all_zones)

7 - Criei uma nova variável com o mapa de cada zona DNS distinta com seus respectivos IDs. Esses IDs são exclusivos por zona DNS e serão necessários posteriormente para criar registos de validação.

zone_name_to_id_map = zipmap(local.distinct_zones, data.aws_route53_zone.acm_zones[*].zone_id)

Os IDs das zonas sao obtidos com o que se chama de “data source

data "aws_route53_zone" "acm_zones" {
  count = length(local.distinct_zones)
  name         = local.distinct_zones[count.index]
  private_zone = false
}

8 - Criei uma variável que mapeia cada domínio com suas respectivas zonas DNS

domain_to_zone_map = zipmap(local.all_domains, local.all_zones)

Então mas que raio de bruxaria é esta? Lembra-se das listas todas que criei anteriormente? Vamos lá então tentar perceber que raio se passou aqui!

A lista all_domains inclui, por exemplo, os seguintes valores:

["domain.com", "*.domain.com", "alternative.com", "www.alternative.com"]

E a lista all_zones contem, por exemplo, os seguintes valores:

["domain.com", "domain.com", "alternative.com", "alternative.com"]

Lembra-se também que disse anteriormente que queria os valores na ordem correta? Esta é a razão!

De acordo com a documentação, a função zipmap() constrói um mapa a partir de uma lista de chaves e uma lista de valores correspondentes.

quando usamos zipmap() na definição da variável domain_to_zone_map = zipmap(local.all_domains, local.all_zones) criamos uma variável que contém, por exemplo, os seguintes valores:

{
"domain.com" = "domain.com",
"*.domain.com" = "domain.com",
"alternative.com" = "alternative.com",
"www.alternative.com" = "alternative.com"
}

Resumindo, aqui fica a primeira parte do código:

locals {
  
  ... Other Local variables ... 

  env_alternative_name = var.primary_env == "true" ? "*.${var.domain}" : "*.${var.environment}.${var.domain}"

  all_alternative_names = concat(
    [local.env_alternative_name],
    [for an in var.alternative_domains : an.domain]
  )

  all_domains = concat(
    [local.env_domain],
    [local.env_alternative_name],
    [for d in var.alternative_domains : d.domain]
  )

  all_zones = concat(
    [var.zone_name],
    [var.zone_name],
    [for z in var.alternative_domains : z.zone]
  )

  distinct_zones      = distinct(local.all_zones)
  zone_name_to_id_map = zipmap(local.distinct_zones, data.aws_route53_zone.acm_zones[*].zone_id)
  domain_to_zone_map  = zipmap(local.all_domains, local.all_zones)

}

Agora vamos lá …

por isto tudo a funcionar!

1 - Criar o certificado com o domínio do ambiente em questão e com todos os nomes alternativos.

# certificate creation
resource "aws_acm_certificate" "cert" {
  domain_name               = local.env_domain
  subject_alternative_names = local.all_alternative_names
  validation_method         = "DNS"

  lifecycle {
    create_before_destroy = true
  }
}

2 - Criar os registos de DNS necessários para validar o certificado

  • Iterar por cada opção de validação e atribuir a variáveis internas
  • Procurar a zona DNS correta para criar os registos no mapa zone_name_to_id_map
# certificate validation
resource "aws_route53_record" "cert_validation" {
  depends_on = [aws_acm_certificate.cert]

  for_each = {
    for dvo in aws_acm_certificate.cert.domain_validation_options : dvo.domain_name => {
      name   = dvo.resource_record_name
      record = dvo.resource_record_value
      type   = dvo.resource_record_type
      domain = dvo.domain_name
    }
  }

  allow_overwrite = true
  name            = each.value.name
  type            = each.value.type
  records         = [each.value.record]
  zone_id         = lookup(local.zone_name_to_id_map, lookup(local.domain_to_zone_map, each.value.domain))
  ttl             = 60
}

3 - Iterar pelos registos e fazer a validação do certificado

resource "aws_acm_certificate_validation" "cert" {
  depends_on              = [aws_acm_certificate.cert, aws_route53_record.cert_validation]
  certificate_arn         = aws_acm_certificate.cert.arn
  validation_record_fqdns = [for record in aws_route53_record.cert_validation : record.fqdn]
}

Tudo junto, fica assim:

# certificate creation
resource "aws_acm_certificate" "cert" {
  domain_name               = local.env_domain
  subject_alternative_names = local.all_alternative_names
  validation_method         = "DNS"

  lifecycle {
    create_before_destroy = true
  }

  tags = {
    Client      = var.client
    Environment = var.environment
  }
}

# certificate validation
resource "aws_route53_record" "cert_validation" {
  depends_on = [aws_acm_certificate.cert]

  for_each = {
    for dvo in aws_acm_certificate.cert.domain_validation_options : dvo.domain_name => {
      name   = dvo.resource_record_name
      record = dvo.resource_record_value
      type   = dvo.resource_record_type
      domain = dvo.domain_name
    }
  }

  allow_overwrite = true
  name            = each.value.name
  type            = each.value.type
  records         = [each.value.record]
  zone_id         = lookup(local.zone_name_to_id_map, lookup(local.domain_to_zone_map, each.value.domain))
  ttl             = 60
}

resource "aws_acm_certificate_validation" "cert" {
  depends_on              = [aws_acm_certificate.cert, aws_route53_record.cert_validation]
  certificate_arn         = aws_acm_certificate.cert.arn
  validation_record_fqdns = [for record in aws_route53_record.cert_validation : record.fqdn]
}

4 - Por fim mas não menos importante, com uma regra adicional no Load Balancer redirecciono os utilizadores dos domínios alternativos para o domínio principal

resource "aws_alb_listener_rule" "redirect_alternative_domains" {
  count = length(var.alternative_domains) > 0 ? 1 : 0
  listener_arn = aws_alb_listener.listener_443.arn
  priority     = 97
  action {
    type             = "redirect"
    target_group_arn = aws_alb_target_group.dashboard2_target_group.id
    redirect {
      host        = local.env_domain
      status_code = "HTTP_302"
    }
  }
  condition {
    host_header {
      values = [for ad in var.alternative_domains : ad.domain]
    }
  }
}

Pontos extra para quem perceber qual vai ser o próximo problema a resolver relacionado com o bocado de código acima! Não demorou muito para ter mais um berbicacho para resolver, podem ler mais aqui !

Depois de tudo isto, submeti o código no git local, corri mais uns quantos testes no ambiente de desenvolvimento e pré-produção para garantir que não ia estragar nada nos ambientes onde esta alteração não se aplicava, corri um teste no ambiente de produção (terraform plan),confirmei que as alterações previstas batiam certo com o que esperava, fiz upload do código para o repositório git, partilhei com a equipa e juntamos o código para “master branch”. Os scripts de automatização fizeram a magia deles, e uma nova versão foi lançada, v1.3.1

Aplicar as alterações ao ambiente de produção foi um “passeio no parque”, sem erros, e com estas alterações basta adicionar novos domínios e as respectivas zonas ao mapa de domínios alternativos no código especifico do ambiente em questão.

alternative_domains = [
    {
        domain = "alternative.com"
        zone   = "alternative.com"
    },
    {   domain = "www.alternative.com"
        zone   = "alternative.com"
    },
    {   domain = "alternative2.com"
        zone   = "alternative2.com"
    }
  ]

Há abordagens melhores? Provavelmente sim, mas durante a minha pesquisa nenhuma das outras abordagens me convenceu e esta pareceu simples e elegante o suficiente para ser facilmente entendida e, bastante importante, sem introduzir alterações problemáticas.

Tem outras ideias? Como é que resolviam este problema? Estejam a vontade para me dizer, sabem onde me encontrar!