Prima che uno qualsiasi dei quattro agenti della Parte 1 possa guardare un log, quotare un workload o proporre una fix, questa piattaforma ha bisogno di un confine di account e due ruoli IAM che rendano il "sola lettura di default" una proprietà imposta da AWS, non una promessa fatta in un system prompt. Quel confine, la richiesta di accesso ai modelli che deve avvenire giorni prima che chiunque pensi di fare una demo, e la scelta tra inferenza on-demand, provisioned throughput e inference profile cross-region sono ciò che viene costruito in questa parte, interamente in Terraform, prima che esista una sola riga di codice agente.

La Parte 1 ha piantato la scena: una SaaS di media grandezza con 30 account AWS e 40 chiamate a settimana, che sceglie di costruire una piattaforma ops multi-agente su Bedrock AgentCore e Strands invece di comprarne una, con il sola-lettura imposto da IAM come vincolo di progettazione non negoziabile. Questa parte è dove quel vincolo smette di essere un diagramma e diventa HCL. Il codice di accompagnamento vive su github.com/flightlesstux/agents-on-call, in terraform/00-foundation/, e ogni snippet qui sotto è estratto da quella directory, non semplificato per il post.

Perché un account ops-tooling dedicato

Il layout degli account della Parte 1 mette l'intera piattaforma di agenti in un unico account, separato dai circa 30 account workload su cui opera:

AccountRuolo
managementOrg root, SCP, nessun workload
securityCloudTrail org, GuardDuty e Security Hub delegated admin
ops-toolingL'intera piattaforma di agenti: Runtime, Gateway, Memory, la state machine Step Functions di approvazione
workload × ~30Spoke. Espongono esattamente due ruoli verso ops-tooling

Un unico account dedicato, invece di distribuire l'infrastruttura degli agenti in ogni account workload, compra tre cose che un account condiviso non può dare. Primo, il raggio d'impatto: una Lambda mal configurata o una credenziale trapelata in ops-tooling non può toccare direttamente nulla in uno spoke, perché raggiungere uno spoke richiede sempre un sts:AssumeRole esplicito attraverso un confine di account, non un permesso implicito nello stesso account. Secondo, un unico posto da auditare: CloudTrail nell'account security vede ogni chiamata assume-role cross-account da un'unica sorgente, invece di dover correlare l'attività degli agenti sparsa su 30 trail. Terzo, raggio d'impatto indipendente per la piattaforma stessa: se ops-tooling ha un incident, tutto il resto degrada allo status quo, esattamente la proprietà additiva-non-percorso-critico che la Parte 1 ha posto come requisito.

I due ruoli che ogni spoke espone

Ogni account workload espone esattamente due ruoli verso ops-tooling, e nient'altro. Entrambi vivono in un modulo Terraform riutilizzabile, modules/spoke-roles/, pensato per essere applicato una volta per ogni account spoke: un provider alias per account funziona per una manciata di spoke, e a circa 30 account questo gira invece da una pipeline per-account, per esempio un workspace Terraform Cloud per account o una customization di Landing Zone Account Factory. Il root module di questo repo lo istanzia una volta contro un singolo provider così l'esempio resta eseguibile con terraform validate.

ops-readonly: costruito da policy gestite, limitato da un deny esplicito

I tool di lettura (cloudwatch-read, logs-read, cost-read) assumono tutti questo unico ruolo. La sua trust policy nomina esattamente gli ARN dei ruoli di esecuzione Lambda autorizzati ad assumerlo, più un external ID condiviso come secondo fattore contro il problema del 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
}

I permessi arrivano da due policy gestite AWS: CloudWatchReadOnlyAccess copre sia le letture delle metriche CloudWatch sia quelle di CloudWatch Logs in un solo attachment, motivo per cui cloudwatch-read e logs-read possono condividere un unico ruolo IAM invece di averne bisogno di due, e AWSBillingReadOnlyAccess copre le chiamate Cost Explorer di cui cost-read ha bisogno. Sopra entrambe, uno statement di deny esplicito blocca una lista fissa di azioni mutanti (iam:*, ssm:StartAutomationExecution, ec2:TerminateInstances, s3:PutObject*, e circa una dozzina di altre) a prescindere da cosa concedano le policy gestite collegate. Quest'ultima parte non è decorazione. Le policy gestite AWS ricevono nuove azioni nel tempo man mano che i servizi aggiungono funzionalità, e un deny esplicito è l'unica cosa che una valutazione IAM non può sovrascrivere con un allow, indipendentemente da quale policy, o quale versione futura di una policy, lo abbia prodotto.

ops-mutate: un solo chiamante, una sola allowlist, nessuna policy gestita

La trust policy è ancora più ristretta: esattamente un principal, la Lambda executor post-approvazione che gira solo dopo che un umano clicca approve su 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]
    }
  }
}

La permission policy è la metà più interessante. ssm:StartAutomationExecution supporta la restrizione a livello di risorsa su specifici ARN di documenti SSM Automation, quindi l'allowlist dei runbook che questo ruolo può eseguire è imposta direttamente da IAM, non da logica applicativa che potrebbe avere un 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 = ["*"]
  }
}

Il secondo statement è su Resource = "*" per una ragione precisa: GetAutomationExecution e le altre chiamate di ispezione operano su un execution ID che non esiste finché StartAutomationExecution non ne ha già restituito uno, quindi non c'è nulla su cui delimitarle in anticipo. Il confine significativo su questo ruolo è quali documenti può avviare, non quali delle proprie esecuzioni già in corso può controllare. E a differenza di ops-readonly, questo ruolo ha zero attachment di policy gestite AWS. Ogni permesso che detiene è enumerato in quell'unico documento di policy, così una modifica lato AWS a una policy gestita non può mai ampliare silenziosamente ciò che può fare.

L'accesso ai modelli Bedrock è un problema da giorni di console

Non esiste una risorsa Terraform per concedere l'accesso ai modelli foundation di Bedrock, e questa lacuna vale la pena segnalarla prima che costi una sessione di debugging. Attivare un modello per un account è un passaggio manuale, o la pagina "Model access" della console Bedrock o l'equivalente chiamata API una tantum, che innesca un flusso di accettazione EULA che AWS elabora in modo asincrono. Non è istantaneo, e non è infrastruttura idempotente che un plan e apply possano esprimere, quindi non può vivere nello stesso repo dei ruoli IAM sopra.

L'effetto pratico: richiedere l'accesso per ogni modello che questa piattaforma chiamerà, in ogni regione verso cui un inference profile cross-region può instradare, prima del primo terraform apply, non dopo. Un entitlement mancante in una regione di un profilo multi-regione fa fallire solo le invocazioni instradate lì, il che fa sembrare il fallimento intermittente, un agente ballerino, un throttle transitorio, invece di ciò che realmente è: una regione su diverse ancora in attesa di un click in console che nessuno ha ancora fatto. Va budgettato come un compito del giorno uno, non un compito da fase di test, perché il flusso di approvazione stesso è fuori dal controllo di Terraform, e di questo progetto.

On-demand, provisioned o cross-region: la regola decisionale è "on-demand finché non si misura"

Esistono tre modi per chiamare un modello Bedrock, e scegliere tra loro prima che ci sia traffico da misurare è un'ipotesi travestita da decisione architetturale.

ModalitàCome viene fatturataCosa compra
On-demandPer token in input/output, tariffa standard pubblicataZero commitment, scala a zero, l'unico default sensato prima di avere una baseline di traffico
Provisioned ThroughputOraria, per Model Unit; la tariffa dipende dal modello, dal numero di MU e dalla durata del commitment (senza commitment, 1 mese, 6 mesi, i commitment più lunghi costano meno all'ora)Throughput garantito e dedicato; paga la capacità sia che venga usata sia che non venga usata
Inference profile cross-regionStessa tariffa per token della regione sorgente, nessun sovrapprezzo di routingRouting automatico tra le regioni di una geografia per margine di burst e affidabilità, solo on-demand

Gli inference profile cross-region sono la scelta facile: non costano nulla in più rispetto all'on-demand e aggiungono capacità di burst più un percorso di failover se una regione fa throttling, quindi questa piattaforma li usa ovunque fin dal primo giorno. Provisioned Throughput è la scelta più difficile, e la regola che questo progetto usa è on-demand finché non si misura: una Model Unit compra un tetto fisso di token al minuto in input e output che viene fatturato su base oraria che la piattaforma mandi o meno una sola richiesta in una data ora, quindi impegnarsi su di esso prima di conoscere la reale distribuzione del tasso di richieste tra quattro agenti significa impegnarsi su un numero che nessuno può ancora difendere. La Parte 7 affronta la matematica reale dei token e il punto in cui la tariffa oraria del provisioned throughput batte la tariffa per token dell'on-demand; finché quella matematica non esiste, indovinare un livello di commitment sposta solo il rischio da "il modello è lento" a "il conto è sbagliato".

Quello che Terraform crea davvero qui non è il profilo cross-region in sé, quella parte è gestita da AWS ed esiste nel momento in cui una regione la supporta, ma un application inference profile per agente che avvolge il profilo definito dal 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
  })
}

Il tag Agent è tutto il senso di avvolgere il profilo di sistema invece di chiamarlo direttamente: senza di esso, la spesa Bedrock di ogni agente finisce in un'unica riga indifferenziata, e il requisito di allocazione dei costi per agente dell'architettura della Parte 1 non ha niente su cui agganciarsi. Con esso, il setup Budgets e Cost Anomaly Detection della piattaforma stessa, pensato per sorvegliare gli agenti che sorvegliano tutto il resto, può davvero attribuire la spesa all'agente che l'ha generata.

Il primo terraform apply

Eseguire terraform -chdir=terraform/00-foundation init e poi plan è un piano piccolo per numero di righe, due ruoli IAM, due policy inline, due attachment di policy gestite, e tanti application inference profile quanti sono gli agenti, ma è l'unico apply in tutta questa serie che deve essere corretto prima che qualsiasi cosa a valle possa essere considerata affidabile. Il root module committato istanzia spoke_roles una volta contro il provider di default, così questo esempio si applica in un singolo account per restare eseguibile con terraform validate e plan. Un deployment reale passa invece a quel modulo un provider alias delimitato allo spoke; senza di esso, i due ruoli atterrano fianco a fianco in un unico account e il confine cross-account che questa parte dovrebbe costruire non esiste. Ogni parte successiva assume che ops-readonly e ops-mutate esistano già esattamente con questa forma: sbagliare qui la lista dei principal della trust policy fa fallire la chiamata AssumeRole di una Lambda tool nella Parte 4 in un modo che sembra una misconfigurazione del Gateway, non un typo della Parte 2 fatto tre settimane prima.

Un dettaglio implementativo che è costato qualche minuto la prima volta: aws_iam_role.max_session_duration ha un floor rigido di 3600 secondi. L'istinto per ops-mutate, dato quanto è già ristretto il suo raggio d'impatto, è chiedere la sessione più corta possibile, ma IAM non permette un tetto a livello di ruolo sotto un'ora. La vita effettiva delle credenziali che l'executor post-approvazione richiede a runtime, tramite la propria chiamata STS, può e dovrebbe comunque essere molto più corta di quel tetto; l'impostazione del ruolo delimita solo il massimo, non imposta il default.

Igiene di state e backend

Nulla in questa parte tocca deliberatamente un backend remoto, perché il punto che questa sezione vuole fare è più importante del backend specifico scelto: lo state deve vivere da qualche parte con locking e cifratura prima che una seconda persona, o una seconda run CI, applichi mai contro lo stesso account, e deve vivere fuori dalla portata di qualsiasi agente o Lambda tool. Un bucket S3 con versioning e una tabella di lock DynamoDB, oppure Terraform Cloud, funzionano entrambi; ciò che conta è che ops-readonly e ops-mutate non abbiano alcun permesso di leggere o scrivere quello state, e che gli umani che eseguono terraform apply contro questo repo seguano un percorso diverso e auditato rispetto alla piattaforma che il repo costruisce. Il console drift è l'altra metà di questa disciplina: modifiche in produzione fatte cliccando in giro desincronizzano il file di state dalla realtà in modi che emergono più tardi come un plan confuso, non come un errore immediato, il che è esattamente l'argomento per trattare l'IaC come unico percorso verso la produzione, approfondito in IaC-First: Why We Never Touch the AWS Console in Production su ercan.cloud.

Modalità di fallimento da tenere d'occhio, specifiche di questa parte

Tre cose che vale la pena sapere prima che si presentino come un sintomo confuso due parti da adesso. Un entitlement di modello mancante in una regione di un profilo cross-region fa fallire solo le richieste instradate verso quella regione, quindi si presenta come flakiness intermittente, non come un errore pulito, finché qualcuno non pensa di controllare la pagina di accesso in console invece del codice. Un typo in ops_readonly_assumer_role_arns o ops_mutate_assumer_role_arn fa fallire AssumeRole con un access-denied che non dà alcun indizio su quale lato, trust policy o chiamante, sia sbagliato, quindi tieni quegli ARN dei ruoli Lambda come output Terraform da dovunque vengano creati e referenziali, senza mai riscrivere un ARN a mano. E una lista allowed_ssm_automation_document_arns vuota è Terraform valido, il ruolo si applica pulito, assume pulito, e poi non può mai effettivamente avviare nulla; un blocco di validazione a plan-time cattura questo caso specifico, ma solo perché qualcuno ha pensato di aggiungerlo, non perché Terraform lo catturi da solo.

Leggi questo dopo

Il modulo completo terraform/00-foundation/, incluse le parti tagliate dagli snippet sopra, vive nel repository di accompagnamento su github.com/flightlesstux/agents-on-call. Per il lato database e infrastruttura di come far girare piattaforme come questa su scala, gli appunti tecnici sono su ercan.cloud, e l'hub è su ercanermis.com.