Ce fichier crée l'app Gemini Enterprise, son assistant protégé par Model Armor, et applique par l'API REST les réglages que le provider ne sait pas encore gérer. Bases Terraform : 00-bases-terraform.md.
1. À quoi sert ce fichier
C'est le cœur visible du projet : l'application que les collaborateurs ouvrent dans leur navigateur. Le fichier crée l'app (le « moteur » de recherche et de chat), lui rattache les sources de données des connecteurs, puis crée son assistant (le chat IA) en le branchant sur les filtres Model Armor. Enfin, il applique trois réglages absents du provider Terraform (durée de conservation des conversations, observabilité, Agent Gateway) par un appel direct à l'API Google.
2. Vue d'ensemble
| Bloc (adresse) | Type de bloc | Rôle en une phrase |
|---|---|---|
google_discovery_engine_search_engine.app | resource | L'app Gemini Enterprise elle-même, avec ses sources et ses fonctionnalités. |
google_discovery_engine_assistant.default | resource | L'assistant (chat) de l'app : langue, consignes, filtres Model Armor, phrases interdites, recherche web. |
local.engine_settings | locals | Le contenu JSON des réglages non gérés par le provider. |
terraform_data.engine_settings | resource | Envoie ces réglages à l'API REST avec curl, à chaque changement. |
3. Le code expliqué, bloc par bloc
3.1 L'app : google_discovery_engine_search_engine.app
Vocabulaire. Dans l'API Google, Gemini Enterprise s'appelle encore « Discovery Engine » (ancien nom :
Agentspace). Une app y est un engine (moteur). Les sources de données y sont des data stores (magasins de
données). Les apps sont rangées dans une collection ; la collection par défaut s'appelle default_collection.
resource "google_discovery_engine_search_engine" "app" {
location = var.ge_location
collection_id = "default_collection"
engine_id = var.app_id
display_name = var.app_display_name
industry_vertical = "GENERIC"
app_type = "APP_TYPE_INTRANET" # = une app Gemini Enterprise| Ligne | Ce qu'elle fait | D'où vient la valeur / pourquoi |
|---|---|---|
location = var.ge_location | Multi-région de l'app. | Variable, défaut "eu" : données en Union européenne (README §9.1). Immuable : la changer recrée l'app. |
collection_id = "default_collection" | Collection où ranger l'app. | Valeur fixe de Google. |
engine_id = var.app_id | Identifiant technique de l'app (apparaît dans les URL). | Variable, défaut "assistant-ia". |
display_name = var.app_display_name | Nom affiché aux utilisateurs. | Variable, défaut "Assistant IA". |
industry_vertical = "GENERIC" | Secteur métier de l'app. | GENERIC = usage général (pas « santé » ni « média »). |
app_type = "APP_TYPE_INTRANET" | Type d'app. | C'est cette valeur qui fait de l'engine une app Gemini Enterprise (et pas un simple moteur de recherche de site web). |
data_store_ids = concat(local.connector_data_store_ids, var.extra_data_store_ids)
kms_key_name = var.enable_cmek ? google_kms_crypto_key.ge[0].id : null
features = var.features-
data_store_ids: la liste des sources rattachées à l'app. On la construit avecconcat, qui colle deux listes bout à bout (00-bases §12.4) :local.connector_data_store_ids: les data stores créés par les connecteurs, calculés dans40_connectors.tf(voir 07-connectors.md) ;var.extra_data_store_ids: des data stores créés à la main dans la console (défaut[]).
Exemple concret : si les connecteurs produisent
["sharepoint_file"]et queextra_data_store_idsvaut["intranet-rh"], alorsconcat(...)donne["sharepoint_file", "intranet-rh"]. Avec leterraform.tfvars.example,extra_data_store_idsn'est pas renseigné, donc vaut[].Comme cette ligne utilise un attribut des connecteurs, Terraform crée les connecteurs avant l'app (dépendance implicite, 00-bases §9).
-
kms_key_name: la clé de chiffrement CMEK (Customer-Managed Encryption Key : une clé que Acme possède dans Cloud KMS, au lieu d'une clé gérée par Google). C'est une condition ternaire (00-bases §12.1) :- si
var.enable_cmekvauttrue(défaut), on prendgoogle_kms_crypto_key.ge[0].id(le[0]car la clé est créée aveccount, voir 05-data-protection.md) ; - sinon
null, ce qui veut dire « ne pas renseigner cet argument » (Google chiffre alors avec ses propres clés).
- si
-
features: une map « nom de fonctionnalité → état ». La valeur vient devar.features(variables.tf). Par défaut :"disable-agent-sharing" = "FEATURE_STATE_ON" # pas de partage d'agents maison "skill-sharing-without-admin-approval" = "FEATURE_STATE_OFF" # partage de skills = validation admin "session-sharing" = "FEATURE_STATE_OFF" # pas de partage de conversations "feedback" = "FEATURE_STATE_ON" # retours utilisateursPourquoi (README §9.3) : un agent ou une conversation partagés peuvent diffuser des consignes inadaptées ou des informations que le destinataire n'a pas le droit de voir. On rouvre le partage quand la gouvernance est définie.
common_config {
company_name = var.company_name
}common_configest un sous-bloc (00-bases §5).company_namedonne le nom de l'entreprise à l'IA, pour contextualiser ses réponses. Variable, défaut"Acme".
search_engine_config {
search_tier = "SEARCH_TIER_ENTERPRISE"
search_add_ons = ["SEARCH_ADD_ON_LLM"]
required_subscription_tier = "SUBSCRIPTION_TIER_ENTERPRISE"
}search_tier = "SEARCH_TIER_ENTERPRISE": niveau « entreprise » de la recherche (fonctions avancées).search_add_ons = ["SEARCH_ADD_ON_LLM"]: active l'option « LLM » (grand modèle de langage), c'est-à-dire les réponses générées par l'IA et pas seulement une liste de résultats. C'est une liste, même avec un seul élément.required_subscription_tier = "SUBSCRIPTION_TIER_ENTERPRISE": l'édition de licence Gemini Enterprise exigée des utilisateurs (ici, « Enterprise »). Elle doit correspondre aux licences achetées.
depends_on = [google_discovery_engine_cmek_config.ge]
}depends_on(00-bases §9) : force Terraform à créer l'enregistrement CMEK (google_discovery_engine_cmek_config.ge, dans20_data_protection.tf) avant l'app. Rien dans les arguments ne le laisse deviner, mais Google l'exige : une app créée avant l'enregistrement de la clé reste chiffrée par Google, définitivement.- On peut écrire
depends_onvers une ressource qui acount = 0(CMEK désactivé) : la liste est alors simplement vide, sans erreur.
3.2 L'assistant : google_discovery_engine_assistant.default
L'assistant est la partie « chat » de l'app : il reçoit les questions, interroge les sources et rédige les
réponses. Chaque app en a un, nommé default_assistant.
resource "google_discovery_engine_assistant" "default" {
location = var.ge_location
collection_id = "default_collection"
engine_id = google_discovery_engine_search_engine.app.engine_id
assistant_id = "default_assistant"
display_name = "${var.app_display_name} - assistant"
web_grounding_type = var.web_groundinglocationetcollection_id: les mêmes que l'app (l'assistant vit dans l'app).engine_id = google_discovery_engine_search_engine.app.engine_id: une référence à l'app (00-bases §7). Résultat :"assistant-ia". Comme on utilise un attribut de l'app, Terraform crée l'app d'abord (pas besoin dedepends_on).assistant_id = "default_assistant": identifiant fixe attendu par Gemini Enterprise.display_name = "${var.app_display_name} - assistant": interpolation (${...}insère une valeur dans un texte). Avec la valeur par défaut :"Assistant IA - assistant".web_grounding_type = var.web_grounding: la recherche web (« grounding » = appuyer les réponses sur des sources). Défaut"WEB_GROUNDING_TYPE_DISABLED": pas de recherche web publique. Pourquoi (README §9.2) : des fragments de questions confidentielles partiraient vers un moteur public, et la recherche Google est incompatible avec la résidence UE et CMEK. Si le métier en a besoin :WEB_GROUNDING_TYPE_ENTERPRISE_WEB_SEARCH.
generation_config {
default_language = "fr"
system_instruction {
additional_system_instruction = "Réponds à partir des données de l'entreprise et cite tes sources. Ne demande jamais de mot de passe, de numéro de sécurité sociale ou de coordonnées bancaires."
}
}generation_config: comment l'IA rédige.default_language = "fr": langue par défaut des réponses.system_instruction→additional_system_instruction: une consigne système, ajoutée à celles de Google, que l'IA lit avant chaque question. Ici : citer ses sources et ne jamais réclamer de données sensibles. C'est une défense en complément de Model Armor, pas un remplacement (une consigne peut être contournée, un filtre non).
customer_policy {
model_armor_config {
user_prompt_template = google_model_armor_template.shield["ge-prompt-shield"].id
response_template = google_model_armor_template.shield["ge-response-shield"].id
failure_mode = "FAIL_CLOSED" # Model Armor indisponible => refus, jamais de réponse non filtrée
}customer_policy: la politique de sécurité propre à l'entreprise.model_armor_configbranche les templates Model Armor (Model Armor = le service Google qui filtre les questions et réponses : injection de prompt, données sensibles, liens malveillants…) créés dans30_ai_guardrails.tf(voir 06-ai-guardrails.md).user_prompt_template: le template appliqué aux questions. La notationshield["ge-prompt-shield"]vient dufor_eachde la ressourceshield(00-bases §8.2).response_template: le template appliqué aux réponses.failure_mode = "FAIL_CLOSED": si Model Armor ne répond pas (panne, droit manquant), l'assistant refuse au lieu de répondre sans filtre. L'autre valeur (FAIL_OPEN) privilégierait la disponibilité. Choix assumé (README §8.5 et §15) : la sécurité passe avant la disponibilité.
dynamic "banned_phrases" {
for_each = var.banned_phrases
content {
phrase = banned_phrases.value
match_type = "WORD_BOUNDARY_STRING_MATCH"
ignore_diacritics = true
}
}
}dynamic "banned_phrases"génère un sous-blocbanned_phrasespar élément de la listevar.banned_phrases(00-bases §10). Danscontent, l'élément en cours s'appellebanned_phrases.value(même nom que le bloc).phrase: l'expression que l'assistant ne doit jamais produire (par exemple un nom de code de projet secret).match_type = "WORD_BOUNDARY_STRING_MATCH": on cherche l'expression comme mot entier. la phrase « Atlas » bloquera « projet Atlas » mais pas « Atlassian ».ignore_diacritics = true: les accents sont ignorés. « élève » et « eleve » sont traités pareil.
Exemple concret. Avec banned_phrases = ["nom de code projet confidentiel"], Terraform génère :
banned_phrases {
phrase = "nom de code projet confidentiel"
match_type = "WORD_BOUNDARY_STRING_MATCH"
ignore_diacritics = true
}Avec le terraform.tfvars.example, la ligne banned_phrases est en commentaire : la liste vaut [] (défaut), donc
aucun bloc n'est généré. C'est tout l'intérêt de dynamic : un bloc écrit à la main ne pourrait pas
« disparaître ».
depends_on = [google_project_iam_member.ge_uses_armor]
}- Force la création du droit « Gemini Enterprise peut appeler Model Armor » (
roles/modelarmor.user, dans30_ai_guardrails.tf) avant l'assistant. Sans ce droit, et avecFAIL_CLOSED, l'assistant refuserait tout.
3.3 Les réglages calculés : locals { engine_settings }
Le problème. Trois réglages de l'app existent dans l'API Google, mais pas encore dans le provider Terraform
(version ~> 8.4) :
- la durée de conservation des conversations (README §6.3 : minimisation RGPD, 30 jours au lieu de 60) ;
- l'observabilité (traces techniques, sans le contenu des questions, README §9.4) ;
- le rattachement à l'Agent Gateway (option, voir 09-advanced.md).
On prépare donc leur contenu JSON dans une valeur locale (00-bases §6.4), puis on l'envoie à l'API (§3.4).
locals {
engine_settings = merge(
{
sessionConfig = { sessionTtl = { days = var.session_ttl_days } }
observabilityConfig = { observabilityEnabled = var.enable_observability, sensitiveLoggingEnabled = var.enable_observability && var.log_prompts_responses }
},
var.enable_agent_gateway ? {
agentGatewaySetting = { defaultEgressAgentGateway = { name = "projects/${local.project_number}/locations/${var.region}/agentGateways/${google_network_services_agent_gateway.egress[0].name}" } }
} : {},
)
}merge(map1, map2)fusionne deux maps en une seule (00-bases §12.4).- Première map (toujours présente) :
sessionConfig = { sessionTtl = { days = var.session_ttl_days } }: conserver les conversationssession_ttl_daysjours (défaut30).observabilityConfig = { observabilityEnabled = var.enable_observability, sensitiveLoggingEnabled = var.enable_observability && var.log_prompts_responses }: traces et journaux d'usage activés (défauttrue). Le texte complet des questions et réponses (sensitiveLoggingEnabled) n'est activé que si les deux variables valenttrue:&&est le « ET » logique, car la journalisation sensible n'a pas de sens sans l'observabilité. Par défautlog_prompts_responses = false. Où vont ces journaux : 13-logging.md.- Les noms en camelCase (
sessionConfig,sessionTtl…) sont ceux de l'API REST Google (ressourceEngine), pas des noms Terraform. C'est pour ça qu'ils ne suivent pas le stylesnake_casehabituel.
- Deuxième map (conditionnelle) :
var.enable_agent_gateway ? { agentGatewaySetting = … } : {}.- Si l'Agent Gateway est activé : une map avec la clé
agentGatewaySetting, qui donne le nom complet de la passerelle. Ce nom est construit par interpolation à partir du numéro de projet (local.project_number, dans00_foundation.tf), de la région et du nom de la passerelle (egress[0], car créée aveccount). - Sinon :
{}, une map vide. Fusionner une map vide n'ajoute rien. - C'est la technique classique pour ajouter une clé sous condition. Terraform n'évalue la référence
egress[0]que dans la branche choisie : pas d'erreur quand la passerelle n'existe pas.
- Si l'Agent Gateway est activé : une map avec la clé
Exemple concret avec le terraform.tfvars.example (donc les défauts session_ttl_days = 30,
enable_observability = true, enable_agent_gateway = false) :
local.engine_settings = {
observabilityConfig = { observabilityEnabled = true, sensitiveLoggingEnabled = false }
sessionConfig = { sessionTtl = { days = 30 } }
}Avec enable_agent_gateway = true, une troisième clé s'ajoute :
agentGatewaySetting = { defaultEgressAgentGateway = { name = "projects/123456789012/locations/europe-west1/agentGateways/assistant-ia-egress" } }
(le numéro de projet est un exemple).
3.4 L'appel à l'API : terraform_data.engine_settings
Principe général : 00-bases §14.
resource "terraform_data" "engine_settings" {
triggers_replace = [jsonencode(local.engine_settings), google_discovery_engine_search_engine.app.id]terraform_data: une ressource « vide », fournie par Terraform lui-même (pas par Google). Elle ne crée rien chez Google ; elle sert de support à une commande.triggers_replace: une liste de valeurs surveillées. Si l'une change, Terraform recrée la ressource, et la commande est donc relancée. Ici on surveille :jsonencode(local.engine_settings): le texte JSON des réglages.jsonencodetransforme une valeur Terraform en texte JSON. Si vous passezsession_ttl_daysde 30 à 15, le texte change, donc l'appel est rejoué ;google_discovery_engine_search_engine.app.id: l'identifiant de l'app. Si l'app est recréée, ses réglages sont perdus : il faut les réappliquer.
provisioner "local-exec" {
command = <<-EOT
curl -fsS -X PATCH \
-H "Authorization: Bearer $(gcloud auth print-access-token)" \
-H "Content-Type: application/json" -H "X-Goog-User-Project: ${var.project_id}" \
"https://${local.de_host}/v1/${google_discovery_engine_search_engine.app.name}?updateMask=${join(",", keys(local.engine_settings))}" \
-d '${jsonencode(local.engine_settings)}' >/dev/null && echo "engine settings applied"
EOT
}
}provisioner "local-exec": exécute une commande sur la machine qui lanceterraform apply(votre poste ou la CI), une seule fois, à la création de la ressource.<<-EOT … EOT: un heredoc, c'est-à-dire un texte sur plusieurs lignes (00-bases §12.3). Le-après<<retire l'indentation commune, pour que le code reste lisible.- Les
\en fin de ligne sont pour le shell : « la commande continue à la ligne suivante ».
Ligne par ligne :
| Morceau | Signification |
|---|---|
curl -fsS -X PATCH | curl envoie une requête HTTP. -X PATCH = « modifier une partie » de la ressource. -f : échouer si Google renvoie une erreur (donc apply échoue aussi). -s : silencieux. -S : mais afficher les erreurs. |
-H "Authorization: Bearer $(gcloud auth print-access-token)" | S'authentifier. $(...) est une syntaxe du shell (pas de Terraform) : elle exécute gcloud auth print-access-token et insère le jeton obtenu. Terraform ne touche qu'à ${...}, il laisse $(...) intact. |
-H "Content-Type: application/json" | Le corps de la requête est du JSON. |
-H "X-Goog-User-Project: ${var.project_id}" | Le projet à qui facturer l'appel et décompter les quotas. Requis quand on appelle ces API avec un compte utilisateur. |
"https://${local.de_host}/v1/${…app.name}?updateMask=…" | L'URL. local.de_host vaut "eu-discoveryengine.googleapis.com" (point d'accès régional, dans 00_foundation.tf). app.name est le nom complet de l'app chez Google. |
?updateMask=${join(",", keys(local.engine_settings))} | Le masque de mise à jour : la liste des champs à modifier. Sans lui, un PATCH pourrait remettre à zéro les autres champs de l'app. keys(map) donne la liste des clés ; join(",", liste) les colle avec des virgules. |
-d '${jsonencode(local.engine_settings)}' | Le corps de la requête : les réglages en JSON, entre apostrophes pour le shell. |
>/dev/null | Jeter la réponse de Google (longue et inutile ici). |
&& echo "engine settings applied" | Si curl a réussi, afficher une confirmation dans la sortie de apply. |
Exemple concret avec session_ttl_days = 30, enable_observability = true, enable_agent_gateway = false,
project_id = "acme-gemini-enterprise-prd", app_id = "assistant-ia" :
keys(local.engine_settings)→["observabilityConfig", "sessionConfig"](Terraform trie les clés par ordre alphabétique) ;join(",", …)→"observabilityConfig,sessionConfig";jsonencode(…)→{"observabilityConfig":{"observabilityEnabled":true,"sensitiveLoggingEnabled":false},"sessionConfig":{"sessionTtl":{"days":30}}}.
La commande réellement exécutée ressemble donc à ceci (le numéro de projet 123456789012 est un exemple ; le
format exact de app.name, numéro ou ID de projet, est à vérifier dans terraform state show) :
curl -fsS -X PATCH \
-H "Authorization: Bearer ya29.xxxxx" \
-H "Content-Type: application/json" -H "X-Goog-User-Project: acme-gemini-enterprise-prd" \
"https://eu-discoveryengine.googleapis.com/v1/projects/123456789012/locations/eu/collections/default_collection/engines/assistant-ia?updateMask=observabilityConfig,sessionConfig" \
-d '{"observabilityConfig":{"observabilityEnabled":true,"sensitiveLoggingEnabled":false},"sessionConfig":{"sessionTtl":{"days":30}}}' >/dev/null && echo "engine settings applied"Avec enable_agent_gateway = true, le masque devient agentGatewaySetting,observabilityConfig,sessionConfig et le
JSON contient en plus la clé agentGatewaySetting.
Pourquoi ce contournement, et ses limites. C'est la seule façon d'appliquer ces réglages depuis le code tant que le provider ne les gère pas. Mais Terraform ne sait pas lire leur valeur réelle :
- si quelqu'un change la durée de conservation dans la console,
terraform planne le verra pas (pas de détection de dérive) ; - supprimer ce bloc ne remet pas les réglages à leur valeur Google : il ne se passe rien chez Google ;
- la commande exige
curletgcloud(connecté) sur la machine qui lanceapply.
Dès que le provider ajoutera ces arguments à google_discovery_engine_search_engine, il faudra les y déplacer et
supprimer ce bloc.
4. Ce que Terraform crée concrètement
Avec le terraform.tfvars.example, 3 ressources :
- une app Gemini Enterprise
assistant-ia, en UE, chiffrée par la clé CMEK, avec les data stores des connecteurs et les 4 réglages de fonctionnalités ; - un assistant
default_assistant, en français, branché sur les deux templates Model Armor enFAIL_CLOSED, sans recherche web et sans phrase interdite (liste vide) ; - une ressource
terraform_data, qui ne crée rien chez Google mais lance une fois l'appelcurl: conservation 30 jours, observabilité sans contenu.
5. Dépendances avec les autres fichiers
| Ce fichier utilise… | …défini dans | Fiche |
|---|---|---|
var.ge_location, var.app_id, var.features, var.web_grounding, var.banned_phrases, var.session_ttl_days… | variables.tf | 02-variables.md |
local.project_number, local.de_host | 00_foundation.tf | 03-foundation.md |
google_kms_crypto_key.ge, google_discovery_engine_cmek_config.ge | 20_data_protection.tf | 05-data-protection.md |
google_model_armor_template.shield, google_project_iam_member.ge_uses_armor | 30_ai_guardrails.tf | 06-ai-guardrails.md |
local.connector_data_store_ids | 40_connectors.tf | 07-connectors.md |
google_network_services_agent_gateway.egress | 60_advanced.tf | 09-advanced.md |
Dans l'autre sens : 70_detection.tf surveille les modifications de l'assistant
(10-detection.md) et outputs.tf affiche data_store_ids
(11-outputs.md).
6. Pour reproduire / adapter
Besoin type : appliquer un réglage d'API que le provider Terraform ne supporte pas encore.
# 1. Le corps de la requête, avec les noms de champs de l'API REST (camelCase).
locals {
mes_reglages = merge(
{ champToujoursPresent = { sousChamp = var.ma_valeur } }, # toujours envoyé
var.mon_option ? { champOptionnel = { actif = true } } : {}, # ajouté seulement si l'option est vraie
)
}
# 2. Une ressource "support" qui relance la commande quand les réglages changent.
resource "terraform_data" "mes_reglages" {
triggers_replace = [
jsonencode(local.mes_reglages), # rejouer si le contenu change
google_xxx.ma_ressource.id, # rejouer si la ressource cible est recréée
]
provisioner "local-exec" {
command = <<-EOT
curl -fsS -X PATCH \
-H "Authorization: Bearer $(gcloud auth print-access-token)" \
-H "Content-Type: application/json" -H "X-Goog-User-Project: ${var.project_id}" \
"https://<service>.googleapis.com/v1/${google_xxx.ma_ressource.name}?updateMask=${join(",", keys(local.mes_reglages))}" \
-d '${jsonencode(local.mes_reglages)}' >/dev/null && echo "réglages appliqués"
EOT
}
}- Vérifier d'abord que l'argument n'existe vraiment pas dans le provider : page de la ressource, par exemple https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/discovery_engine_search_engine (et https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/discovery_engine_assistant). Si l'argument existe, utilisez-le : c'est toujours mieux.
- Trouver dans la référence REST de l'API Google le nom exact des champs (camelCase) et le verbe (
PATCHupdateMaskpour une mise à jour partielle).
- Tester l'appel
curlà la main une fois, dans un terminal, avant de le mettre dans Terraform. - Mettre le corps dans un
locals, la commande dans unterraform_dataavectriggers_replace. terraform plandoit montrerterraform_data.mes_reglagesà créer ; aprèsapply, vérifier le réglage dans la console ou avec uncurlenGET.- Ajouter un commentaire « à supprimer quand le provider supportera X ».
7. Pièges et bonnes pratiques
- Modifier
data_store_idspeut recréer l'app (bug connu du provider, README §16). Ajouter un connecteur ajoute un data store, donc change cette liste. Lisez leplan: si l'app est en-/+(détruite puis recréée), arrêtez. Idéalement, déclarez les connecteurs avant le lancement aux utilisateurs. locationetengine_idsont immuables : les changer recrée l'app (conversations et réglages perdus).local-execexigegcloudetcurlsur la machine qui lanceapply, et ungcloud auth loginvalide. Le jeton vient du compte gcloud, qui peut être différent du compte utilisé par Terraform (ADC,gcloud auth application-default login) : en CI, les deux doivent être configurés.- Pas de détection de dérive sur les réglages envoyés par
curl: une modification dans la console reste invisible pour Terraform. Pour forcer une réapplication :terraform apply -replace=terraform_data.engine_settings. - Si
curléchoue, l'applyéchoue (grâce à-f) : lisez le message d'erreur HTTP (droit manquant, champ inconnu…). Si l'API renomme un champ, l'appel cassera : c'est voulu, mieux vaut une erreur qu'un réglage ignoré. - Apostrophes : le JSON est entouré de
'…'pour le shell. Une valeur contenant une apostrophe casserait la commande. Ici les valeurs sont des nombres et des booléens, donc sans risque. FAIL_CLOSED+ droit IAM manquant = tout est bloqué. Juste aprèsapply, attendez 1 à 2 minutes (propagation IAM) avant de conclure à une panne.banned_phrasesn'est pas un secret : la liste est en clair dans le code et dans l'état Terraform. N'y mettez pas d'information plus sensible que le nom de code lui-même.- Toute modification de l'assistant déclenche l'alerte « garde-fou modifié » (10-detection.md) : c'est normal et voulu.