Antes que qualquer um dos quatro agentes da Parte 1 possa olhar para um log, precificar uma carga de trabalho ou propor uma correção, esta plataforma precisa de uma fronteira de conta e duas roles IAM que tornem o "somente leitura por padrão" uma propriedade imposta pela AWS, não uma promessa feita em um system prompt. Essa fronteira, a solicitação de acesso a modelos que precisa acontecer dias antes de alguém planejar demonstrar qualquer coisa, e a decisão entre inferência sob demanda, throughput provisionado e inference profiles entre regiões são o que se constrói nesta parte, inteiramente em Terraform, antes de existir uma única linha de código de agente.

A Parte 1 montou o cenário: uma empresa SaaS de porte médio com 30 contas AWS e 40 chamados por semana, escolhendo construir uma plataforma de ops multi-agente sobre Bedrock AgentCore e Strands em vez de comprar uma pronta, com o somente-leitura imposto pelo IAM como restrição de design não negociável. Esta parte é onde essa restrição deixa de ser um diagrama e vira HCL. O código complementar vive em github.com/flightlesstux/agents-on-call, em terraform/00-foundation/, e cada trecho abaixo é extraído diretamente desse diretório, não simplificado para o post.

Por que uma conta dedicada de ops-tooling

O layout de contas da Parte 1 coloca toda a plataforma de agentes em uma única conta, separada das ~30 contas de workload sobre as quais ela opera:

ContaFunção
managementRaiz da org, SCPs, sem workloads
securityCloudTrail da org, admin delegado do GuardDuty e do Security Hub
ops-toolingToda a plataforma de agentes: Runtime, Gateway, Memory, a state machine de aprovação do Step Functions
workload × ~30Spokes. Expõem exatamente duas roles para o ops-tooling

Uma única conta dedicada, em vez de implantar a infraestrutura de agentes em cada conta de workload, compra três coisas que uma conta compartilhada não consegue. Primeiro, raio de impacto: uma Lambda mal configurada ou uma credencial vazada no ops-tooling não consegue tocar diretamente em nada num spoke, porque alcançar um spoke sempre exige um sts:AssumeRole explícito atravessando uma fronteira de conta, não uma permissão implícita dentro da mesma conta. Segundo, um único lugar para auditar: o CloudTrail na conta security vê toda chamada de assume-role entre contas partindo de uma única conta de origem, em vez de correlacionar atividade de agentes espalhada por 30 trilhas. Terceiro, raio de impacto independente para a própria plataforma: se o ops-tooling tiver um incidente, tudo o mais degrada para o status quo, exatamente a propriedade de aditivo-e-não-caminho-crítico que a Parte 1 definiu como requisito.

As duas roles que todo spoke expõe

Cada conta de workload expõe exatamente duas roles para o ops-tooling, e nada além disso. Ambas vivem em um módulo Terraform reutilizável, modules/spoke-roles/, pensado para ser aplicado uma vez por conta spoke: um provider alias por conta funciona para um punhado de spokes, e em cerca de 30 contas isso passa a rodar a partir de um pipeline por conta, por exemplo um workspace do Terraform Cloud por conta ou uma customização do Landing Zone Account Factory. O root module deste repositório o instancia uma vez contra um único provider, para que o exemplo continue executável com terraform validate.

ops-readonly: construída a partir de managed policies, limitada por um deny explícito

As tools de leitura (cloudwatch-read, logs-read, cost-read) assumem todas essa única role. A trust policy dela nomeia os ARNs exatos das roles de execução de Lambda autorizadas a assumi-la, mais um external ID compartilhado como segundo fator contra o problema do confused deputy:

data "aws_iam_policy_document" "ops_readonly_trust" {
  statement {
    sid     = "AssumeFromOpsToolingReadTools"
    effect  = "Allow"
    actions = ["sts:AssumeRole"]

    principals {
      type        = "AWS"
      identifiers = var.ops_readonly_assumer_role_arns
    }

    condition {
      test     = "StringEquals"
      variable = "sts:ExternalId"
      values   = [var.external_id]
    }
  }
}

resource "aws_iam_role" "ops_readonly" {
  name                 = "ops-readonly"
  assume_role_policy   = data.aws_iam_policy_document.ops_readonly_trust.json
  max_session_duration = 3600
}

As permissões vêm de duas managed policies da AWS: a CloudWatchReadOnlyAccess cobre tanto leituras de métricas do CloudWatch quanto do CloudWatch Logs num único attachment, motivo pelo qual cloudwatch-read e logs-read conseguem compartilhar uma única role IAM em vez de precisar de duas, e a AWSBillingReadOnlyAccess cobre as chamadas do Cost Explorer que cost-read precisa. Por cima das duas, uma statement de deny explícito bloqueia uma lista fixa de ações de mutação (iam:*, ssm:StartAutomationExecution, ec2:TerminateInstances, s3:PutObject*, e mais uma dúzia) independentemente do que as managed policies anexadas concedam. Essa última parte não é decoração. As managed policies da AWS ganham novas actions ao longo do tempo, conforme os serviços adicionam recursos, e um deny explícito é a única coisa que uma avaliação de IAM não consegue sobrepor com um allow, não importa qual policy, ou qual versão futura de uma policy, o produziu.

ops-mutate: um único chamador, uma allowlist, nenhuma managed policy

A trust policy é ainda mais estreita: exatamente um principal, a Lambda executora pós-aprovação, que só roda depois que um humano clica em aprovar no Slack.

data "aws_iam_policy_document" "ops_mutate_trust" {
  statement {
    sid     = "AssumeFromPostApprovalExecutorOnly"
    effect  = "Allow"
    actions = ["sts:AssumeRole"]

    principals {
      type        = "AWS"
      identifiers = [var.ops_mutate_assumer_role_arn]
    }

    condition {
      test     = "StringEquals"
      variable = "sts:ExternalId"
      values   = [var.external_id]
    }
  }
}

A permission policy é a metade mais interessante. ssm:StartAutomationExecution suporta restrição em nível de recurso para ARNs específicos de documentos do SSM Automation, então a allowlist de runbooks que essa role pode executar é imposta diretamente pelo IAM, não por lógica de aplicação que poderia ter um bug:

data "aws_iam_policy_document" "ops_mutate_permissions" {
  statement {
    sid       = "StartOnlyAllowlistedRunbooks"
    effect    = "Allow"
    actions   = ["ssm:StartAutomationExecution"]
    resources = var.allowed_ssm_automation_document_arns
  }

  statement {
    sid    = "InspectAndStopOwnExecutions"
    effect = "Allow"
    actions = [
      "ssm:GetAutomationExecution",
      "ssm:DescribeAutomationExecutions",
      "ssm:StopAutomationExecution",
    ]
    resources = ["*"]
  }
}

A segunda statement está em Resource = "*" por um motivo específico: GetAutomationExecution e as outras chamadas de inspeção operam sobre um execution ID que não existe até o StartAutomationExecution já ter retornado um, então não há nada a que restringi-las de antemão. A fronteira que importa nessa role é quais documentos ela pode iniciar, não quais das suas próprias execuções já em andamento ela pode consultar. E, diferente da ops-readonly, essa role tem zero attachments de managed policy da AWS. Toda permissão que ela detém está enumerada nesse único documento de policy, então uma mudança do lado da AWS numa managed policy nunca consegue ampliar silenciosamente o que ela pode fazer.

Acesso a modelos do Bedrock é um problema de dias no console

Não existe recurso Terraform para conceder acesso a foundation models do Bedrock, e essa lacuna vale a pena apontar antes que ela custe uma sessão de debugging. Ativar um modelo para uma conta é um passo manual, seja pela página "Model access" do console do Bedrock, seja pela chamada de API avulsa equivalente, que dispara um workflow de aceite de EULA que a AWS processa de forma assíncrona. Não é instantâneo, e não é infraestrutura idempotente que um plan e um apply consigam expressar, então não pode viver no mesmo repositório que as roles IAM acima.

O efeito prático: solicite acesso para todo modelo que esta plataforma vai chamar, em toda região para a qual um inference profile entre regiões possa rotear, antes do primeiro terraform apply, não depois. Um entitlement faltando em uma região de um profile multi-região falha só as invocações roteadas para lá, o que faz a falha parecer intermitente, um agente instável, um throttle transitório, em vez do que ela realmente é: uma região entre várias ainda esperando por um clique no console que ninguém deu. Trate isso como tarefa do dia um, não como tarefa durante os testes, porque o próprio workflow de aprovação está fora do controle do Terraform, e deste projeto.

Sob demanda, provisionado ou entre regiões: a regra de decisão é "sob demanda até medir"

Existem três formas de chamar um modelo do Bedrock, e escolher entre elas antes de haver qualquer tráfego para medir é um chute disfarçado de decisão de arquitetura.

ModoComo é cobradoO que compra
On-demandPor token de entrada/saída, na tarifa padrão publicadaZero compromisso, escala a zero, o único default sensato antes de haver uma baseline de tráfego
Provisioned ThroughputPor hora, em Model Units; a tarifa depende do modelo, da quantidade de MUs e da duração do compromisso (sem compromisso, 1 mês, 6 meses, compromissos mais longos custam menos por hora)Throughput garantido e dedicado; paga pela capacidade independentemente de uso
Cross-region inference profileMesma tarifa por token da região de origem, sem sobretaxa de roteamentoRoteamento automático entre as regiões de uma geografia para folga em picos e confiabilidade, somente on-demand

Cross-region inference profiles são a decisão fácil: não custam nada a mais do que on-demand e somam capacidade de pico mais um caminho de failover se uma região sofrer throttle, então esta plataforma os usa em tudo desde o dia um. Provisioned Throughput é a decisão mais difícil, e a regra que este projeto usa é sob demanda até medir: uma Model Unit compra um teto fixo de tokens de entrada e saída por minuto que é cobrado por hora, quer a plataforma envie uma única requisição naquela hora ou não, então se comprometer com isso antes de conhecer a distribuição real de taxa de requisições entre quatro agentes é se comprometer com um número que ninguém consegue defender ainda. A Parte 7 percorre a matemática real de tokens e o ponto em que a tarifa horária do provisioned throughput supera a tarifa por token do on-demand; até essa matemática existir, chutar um nível de compromisso é só mover o risco de "o modelo está lento" para "a conta está errada".

O que o Terraform de fato cria aqui não é o cross-region profile em si, essa parte é gerenciada pela AWS e existe no momento em que uma região passa a suportá-lo, mas um application inference profile por agente que envolve o profile definido pelo sistema:

resource "aws_bedrock_inference_profile" "agent" {
  for_each = var.agents

  name        = "${var.platform_name}-${each.key}"
  description = "Application inference profile for the ${each.key} agent, tagged for per-agent cost allocation."

  model_source {
    copy_from = each.value.cross_region_profile_arn
  }

  tags = merge(var.tags, {
    Agent = each.key
  })
}

A tag Agent é todo o motivo de envolver o profile do sistema em vez de chamá-lo diretamente: sem ela, o gasto de Bedrock de cada agente cai numa única linha indiferenciada, e o requisito de alocação de custo por agente da arquitetura da Parte 1 fica sem nada para se apoiar. Com ela, a própria configuração de Budgets e Cost Anomaly Detection da plataforma, que existe para vigiar os agentes que vigiam tudo o mais, consegue de fato atribuir o gasto ao agente que o gerou.

O primeiro terraform apply

Rodar terraform -chdir=terraform/00-foundation init e depois plan resulta num plano pequeno em número de linhas, duas roles IAM, duas inline policies, dois attachments de managed policy, e quantos application inference profiles houver agentes, mas é o único apply em toda esta série que precisa estar certo antes que qualquer coisa a jusante possa ser confiável. O root module versionado instancia spoke_roles uma vez contra o provider padrão, então este exemplo aplica numa única conta para continuar executável com terraform validate e plan. Um deployment real passa, em vez disso, um provider alias com escopo de spoke para esse módulo; sem isso, as duas roles caem lado a lado numa única conta e a fronteira entre contas que esta parte deveria construir não existe. Toda parte posterior assume que ops-readonly e ops-mutate já existem exatamente com este formato: erre a lista de principals da trust policy aqui, e a chamada AssumeRole de uma Lambda de tool falha na Parte 4 de um jeito que parece uma misconfiguração do Gateway, não um erro de digitação da Parte 2 três semanas atrás.

Um detalhe de implementação que custou alguns minutos na primeira vez: aws_iam_role.max_session_duration tem um piso rígido de 3600 segundos. O instinto para ops-mutate, dado quão estreito já é o raio de impacto dela, é pedir a sessão mais curta possível, mas o IAM não permite um teto em nível de role abaixo de uma hora. O tempo de vida real da credencial que o executor pós-aprovação solicita em runtime, via sua própria chamada STS, pode e deve continuar sendo bem mais curto do que esse teto; a configuração da role só limita o máximo, ela não define o default.

Higiene de state e backend

Nada nesta parte toca num backend remoto de propósito, porque o ponto que esta seção quer fazer é mais importante do que o backend específico escolhido: o state precisa viver em algum lugar com locking e criptografia antes que uma segunda pessoa, ou uma segunda execução de CI, jamais aplique contra a mesma conta, e precisa viver fora do alcance de qualquer agente ou Lambda de tool. Um bucket S3 com versionamento e uma tabela de lock no DynamoDB, ou o Terraform Cloud, ambos funcionam; o que importa é que ops-readonly e ops-mutate não têm permissão para ler ou escrever esse state, e que os humanos rodando terraform apply contra este repositório sigam um caminho diferente, auditado, da plataforma que o repositório constrói. O drift de console é a outra metade dessa disciplina: mudanças de produção feitas clicando por aí dessincronizam o arquivo de state da realidade de formas que só aparecem depois como um plan confuso, não como um erro imediato, o que é exatamente o argumento para tratar IaC como o único caminho até produção, coberto em mais detalhes em IaC-First: Why We Never Touch the AWS Console in Production no ercan.cloud.

Modos de falha para observar especificamente desta parte

Três coisas que vale a pena saber antes que apareçam como um sintoma confuso duas partes à frente. Um entitlement de modelo faltando numa região de um cross-region profile falha só as requisições roteadas para aquela região, então se apresenta como instabilidade intermitente, não como um erro limpo, até alguém pensar em checar a página de acesso no console em vez do código. Um erro de digitação em ops_readonly_assumer_role_arns ou ops_mutate_assumer_role_arn faz o AssumeRole falhar com um access-denied que não dá nenhuma pista sobre qual lado, trust policy ou chamador, está errado, então mantenha esses ARNs de role de Lambda como outputs do Terraform de onde quer que sejam criados e referencie-os, nunca redigite um ARN à mão. E uma lista allowed_ssm_automation_document_arns vazia é Terraform válido, a role aplica limpo, assume limpo, e depois nunca consegue de fato iniciar nada; um bloco de validation em tempo de plan pega esse caso específico, mas só porque alguém pensou em adicioná-lo, não porque o Terraform o pega sozinho.

Leia isso a seguir

O módulo terraform/00-foundation/ completo, incluindo as partes cortadas dos trechos acima, vive no repositório complementar em github.com/flightlesstux/agents-on-call. Para o lado de banco de dados e infraestrutura de rodar plataformas como esta em escala, as notas de campo estão em ercan.cloud, e o hub fica em ercanermis.com.