feat: Terraform intelligence, anti-flickering, and v2.2 release

- Official resource docs injection from registry.terraform.io (on-demand fetch + SQLite cache)
- Smart tfvars auto-generation from declared variables (tenancy_ocid, user_ocid, etc.)
- Multi-region provider: use var.region instead of hardcoded OCI config region
- RPC anti-cycle rule in system prompt
- System prompt auto-sync to DB on restart
- Fix UI flickering: animations scoped to tab switch only (tab-enter class)
- Terraform polling timeout increased to 60 min
- Terminal output updates without full page re-render
- Fix ${IC.bot} literal rendering in chat empty state
- Chat empty state icon scaled to 48px
- README updated to v2.2 with all changes
This commit is contained in:
nogueiraguh
2026-03-10 14:29:38 -03:00
parent 0877744cd1
commit 851def30b6
3 changed files with 247 additions and 45 deletions

View File

@@ -342,6 +342,13 @@ def init_db():
required_args TEXT DEFAULT '',
blocks TEXT DEFAULT ''
);
CREATE TABLE IF NOT EXISTS tf_resource_docs (
slug TEXT PRIMARY KEY,
subcategory TEXT DEFAULT '',
example_usage TEXT DEFAULT '',
argument_ref TEXT DEFAULT '',
fetched_at TEXT DEFAULT (datetime('now'))
);
""")
c.execute("DELETE FROM config_logs WHERE created_at < datetime('now', '-30 days')")
c.execute("DELETE FROM chat_logs WHERE created_at < datetime('now', '-30 days')")
@@ -397,7 +404,11 @@ def init_db():
if not c.execute("SELECT 1 FROM system_prompts WHERE agent='chat'").fetchone():
c.execute("INSERT INTO system_prompts (id,name,agent,content,is_active) VALUES (?,?,?,?,?)",
(str(uuid.uuid4()), "OCI CIS RAG Agent", "chat", RAG_DEFAULT_SYSTEM_PROMPT, 1))
if not c.execute("SELECT 1 FROM system_prompts WHERE agent='terraform'").fetchone():
tf_row = c.execute("SELECT id FROM system_prompts WHERE agent='terraform' AND is_active=1").fetchone()
if tf_row:
# Always sync DB prompt with code default
c.execute("UPDATE system_prompts SET content=? WHERE id=?", (TF_DEFAULT_SYSTEM_PROMPT, tf_row["id"]))
else:
c.execute("INSERT INTO system_prompts (id,name,agent,content,is_active) VALUES (?,?,?,?,?)",
(str(uuid.uuid4()), "OCI Terraform Agent", "terraform", TF_DEFAULT_SYSTEM_PROMPT, 1))
adm = c.execute("SELECT id FROM users WHERE username='admin'").fetchone()
@@ -1936,7 +1947,7 @@ TF_DEFAULT_SYSTEM_PROMPT = """Você gera somente Terraform HCL para Oracle Cloud
Se pedirem AWS, Azure, GCP ou outro provider, recuse educadamente.
### Regras
- Gere código production-ready, com variáveis, defaults sensatos, tags e outputs úteis.
- Gere código production-ready, com variáveis, defaults sensatos, tags e outputs úteis. Evite ficar solicitando ao usuário para ficar interagindo a todo momento, entregue o que ele precisa e faça o mínimo de interações para entender o que o usuário precisa. Explique de forma clara o que foi gerado e o que foi arrumado para que fosse possível gerar o código do terraform.
- Use sempre a sintaxe mais recente do provider OCI.
- Não inclua `terraform { required_providers }` nem `provider "oci"` principal.
- Em multi-região, use provider aliases para regiões adicionais e `provider = oci.alias` nos recursos.
@@ -1945,7 +1956,7 @@ Se pedirem AWS, Azure, GCP ou outro provider, recuse educadamente.
- Nunca declare o mesmo recurso (tipo + nome) em mais de um arquivo.
- Use `var.compartment_id` nos recursos e declare sempre `variable "region"` em `variables.tf`.
- Se houver "RECURSOS OCI EXISTENTES NO COMPARTMENT", reutilize recursos com data sources sempre que possível.
- Se houver "ARQUIVOS TERRAFORM ATUAIS NO WORKSPACE", gere somente os arquivos corrigidos, com os mesmos nomes, sem renomear recursos.
- Se houver "ARQUIVOS TERRAFORM ATUAIS NO WORKSPACE", gere somente os arquivos corrigidos, com os mesmos nomes.
- Sempre responda com blocos `hcl`, depois **Resource Plan** (`+` criar, `~` reutilizar), uma breve explicação e uma seção `✅ Validação`.
- Valide: referências cruzadas, CIDRs, route tables, security lists, dependências, segurança e variáveis.
- Use apenas resource types válidos do OCI. Exemplos corretos:
@@ -1963,7 +1974,7 @@ Se pedirem AWS, Azure, GCP ou outro provider, recuse educadamente.
### Regras críticas
- `oci_core_drg_route_distribution_statement` deve usar `match_criteria`.
- Remote peering usa `peer_id` e `peer_region_name` no próprio `oci_core_remote_peering_connection`.
- Remote peering: APENAS UM LADO do par define `peer_id` e `peer_region_name`. O outro lado é criado SEM `peer_id`. Nunca gere dois RPCs apontando um para o outro (causa Cycle error). Exemplo: RPC_mad1 (sem peer_id) e RPC_mad3 (com peer_id = RPC_mad1.id, peer_region_name = var.region).
- Associação de route table com subnet deve ser via `route_table_id` na subnet ou `oci_core_route_table_attachment`.
- `oci_network_firewall_network_firewall` exige `network_firewall_policy_id`.
"""
@@ -2092,6 +2103,118 @@ def _regenerate_tf_reference() -> dict:
except Exception as e:
return {"ok": False, "error": str(e)}
# ── Terraform Resource Docs (on-demand from GitHub, cached in SQLite) ──
_TF_DOC_BASE_URL = "https://raw.githubusercontent.com/oracle/terraform-provider-oci/master/website/docs/r/"
def _fetch_tf_resource_doc(slug: str) -> dict | None:
"""Fetch a single resource doc from GitHub, parse Example Usage + Argument Reference, cache in SQLite."""
import re as _re
# Check cache first
with db() as c:
row = c.execute("SELECT example_usage, argument_ref FROM tf_resource_docs WHERE slug=?", (slug,)).fetchone()
if row and (row["example_usage"] or row["argument_ref"]):
return {"slug": slug, "example": row["example_usage"], "args": row["argument_ref"]}
# Fetch from GitHub
try:
import urllib.request
url = f"{_TF_DOC_BASE_URL}{slug}.html.markdown"
req = urllib.request.Request(url, headers={"User-Agent": "oci-cis-agent/2.1"})
with urllib.request.urlopen(req, timeout=15) as resp:
if resp.status != 200:
return None
md = resp.read().decode("utf-8")
# Extract Example Usage section
example = ""
m = _re.search(r'## Example Usage\s*\n(.*?)(?=\n## )', md, _re.DOTALL)
if m:
example = m.group(1).strip()
# Extract Argument Reference section
args = ""
m2 = _re.search(r'## Argument Reference\s*\n(.*?)(?=\n## )', md, _re.DOTALL)
if m2:
args = m2.group(1).strip()
# Cache in SQLite
subcategory = ""
m3 = _re.search(r'subcategory:\s*"([^"]+)"', md)
if m3:
subcategory = m3.group(1)
with db() as c:
c.execute(
"INSERT OR REPLACE INTO tf_resource_docs (slug, subcategory, example_usage, argument_ref) VALUES (?,?,?,?)",
(slug, subcategory, example, args))
log.info(f"Fetched TF doc: {slug} (example={len(example)} chars, args={len(args)} chars)")
return {"slug": slug, "example": example, "args": args}
except Exception as e:
log.warning(f"Failed to fetch TF doc for {slug}: {e}")
return None
def _get_tf_docs_for_resources(resource_types: list[str]) -> str:
"""Given a list of oci_xxx resource types, fetch their docs and build a context string."""
docs_parts = []
for rtype in resource_types[:10]: # limit to 10 to keep context manageable
# Convert oci_core_vcn -> core_vcn
slug = rtype.replace("oci_", "", 1) if rtype.startswith("oci_") else rtype
doc = _fetch_tf_resource_doc(slug)
if doc and (doc["example"] or doc["args"]):
part = f"#### {rtype}\n"
if doc["example"]:
part += f"**Example Usage:**\n{doc['example']}\n\n"
if doc["args"]:
part += f"**Arguments:**\n{doc['args']}\n"
docs_parts.append(part)
if not docs_parts:
return ""
return "### Documentação Oficial dos Recursos (registry.terraform.io)\n" + \
"Use exatamente os argumentos e estrutura mostrados nos exemplos abaixo.\n\n" + \
"\n---\n".join(docs_parts)
def _detect_tf_resource_types(text: str) -> list[str]:
"""Detect OCI resource types mentioned or implied in a user message + conversation."""
import re as _re
# Direct mentions of oci_ resource types
explicit = set(_re.findall(r'\boci_\w+', text))
# Keyword-to-resource mapping for common infra requests
keyword_map = {
'vcn': ['oci_core_vcn', 'oci_core_subnet', 'oci_core_internet_gateway', 'oci_core_nat_gateway',
'oci_core_route_table', 'oci_core_security_list'],
'subnet': ['oci_core_subnet'],
'instance': ['oci_core_instance', 'oci_core_instance_configuration'],
'compute': ['oci_core_instance'],
'load.?balancer': ['oci_load_balancer_load_balancer', 'oci_load_balancer_backend_set',
'oci_load_balancer_listener'],
'nlb': ['oci_network_load_balancer_network_load_balancer', 'oci_network_load_balancer_backend_set',
'oci_network_load_balancer_listener'],
'firewall': ['oci_network_firewall_network_firewall', 'oci_network_firewall_network_firewall_policy'],
'drg': ['oci_core_drg', 'oci_core_drg_attachment', 'oci_core_drg_route_table'],
'peering|rpc': ['oci_core_remote_peering_connection'],
'bucket|object.?storage': ['oci_objectstorage_bucket', 'oci_objectstorage_namespace_metadata'],
'autonomous|adb': ['oci_database_autonomous_database'],
'db.?system': ['oci_database_db_system'],
'mysql': ['oci_mysql_mysql_db_system'],
'oke|kubernetes|cluster': ['oci_containerengine_cluster', 'oci_containerengine_node_pool'],
'dns': ['oci_dns_zone', 'oci_dns_record'],
'vault|kms': ['oci_kms_vault', 'oci_kms_key'],
'waf': ['oci_waf_web_app_firewall', 'oci_waf_web_app_firewall_policy'],
'api.?gateway': ['oci_apigateway_gateway', 'oci_apigateway_deployment'],
'function|serverless': ['oci_functions_application', 'oci_functions_function'],
'nsg': ['oci_core_network_security_group', 'oci_core_network_security_group_security_rule'],
'bastion': ['oci_bastion_bastion', 'oci_bastion_session'],
'file.?storage': ['oci_file_storage_file_system', 'oci_file_storage_mount_target',
'oci_file_storage_export'],
'block.?volume': ['oci_core_volume', 'oci_core_volume_attachment'],
'policy|iam': ['oci_identity_policy', 'oci_identity_compartment', 'oci_identity_group',
'oci_identity_dynamic_group'],
}
text_lower = text.lower()
for pattern, resources in keyword_map.items():
if _re.search(pattern, text_lower):
explicit.update(resources)
return sorted(explicit)
def _get_adb_connection(cfg: dict):
"""Create an oracledb connection from an adb_vector_configs row."""
import oracledb
@@ -3279,6 +3402,26 @@ async def _chat_background(mid: str, sid: str, msg: ChatMsg, user: dict, genai_c
ref_compact
log.info(f"Terraform: injected resource reference ({len(ref_compact)} chars)")
# ── Inject official Terraform resource docs (Example Usage + Arguments) ──
if agent_type == "terraform":
try:
# Detect resource types from user message + recent conversation history
detect_text = msg.message if hasattr(msg, 'message') else ""
if history:
# Include last 3 messages for context
for h in history[-3:]:
detect_text += "\n" + (h.get("content", "") or "")
resource_types = _detect_tf_resource_types(detect_text)
if resource_types:
loop = asyncio.get_event_loop()
docs_ctx = await loop.run_in_executor(
_chat_executor, partial(_get_tf_docs_for_resources, resource_types))
if docs_ctx:
cfg_dict["system_prompt"] = cfg_dict.get("system_prompt", "") + "\n\n" + docs_ctx
log.info(f"Terraform: injected {len(resource_types)} resource docs ({len(docs_ctx)} chars)")
except Exception as e:
log.warning(f"Failed to inject terraform resource docs: {e}")
# Log total prompt sizes for debugging
if agent_type == "terraform":
sp_len = len(cfg_dict.get("system_prompt", ""))
@@ -4160,6 +4303,20 @@ async def _terraform_exec(wid: str, action: str, user: dict):
else:
tf_file.unlink() # Remove empty file
# Scan all .tf files for variable declarations with region-like defaults
# so we can map alias providers to the correct variable reference
var_region_map = {} # variable_name -> default_value
var_re = _re2.compile(r'variable\s+"([^"]*region[^"]*)"\s*\{', _re2.IGNORECASE)
for tf_file in sorted(wdir.glob("*.tf")):
if tf_file.name in ("provider.tf", "terraform.tfvars"):
continue
content = tf_file.read_text()
for vm in var_re.finditer(content):
var_region_map[vm.group(1)] = f'var.{vm.group(1)}'
# Check if variable "region" is declared — use it for primary provider
has_region_var = "region" in var_region_map
# Also scan for provider refs in resource blocks (provider = oci.xxx)
for tf_file in sorted(wdir.glob("*.tf")):
if tf_file.name == "provider.tf":
@@ -4168,7 +4325,14 @@ async def _terraform_exec(wid: str, action: str, user: dict):
for ref_m in _re2.finditer(r'provider\s*=\s*oci\.(\w+)', content):
ref_alias = ref_m.group(1)
if ref_alias not in [a[0] for a in alias_blocks]:
alias_blocks.append((ref_alias, '"unknown"'))
# Try to find a matching region variable for this alias
# e.g. alias "mad_3" might match var.region_secondary
region_ref = 'var.region' # fallback to primary region var
for vname, vref in var_region_map.items():
if vname != "region": # prefer non-primary region vars for aliases
region_ref = vref
break
alias_blocks.append((ref_alias, region_ref))
passphrase = oci_cfg.get('pass_phrase', '')
cred_block = f''' tenancy_ocid = "{oci_cfg.get('tenancy', '')}"
@@ -4178,10 +4342,11 @@ async def _terraform_exec(wid: str, action: str, user: dict):
if passphrase:
cred_block += f'\n private_key_password = "{passphrase}"'
# Use region from OCI config row (may differ from oci sdk config)
# Primary region: use var.region if declared in .tf files, otherwise hardcode from OCI config
with db() as c:
oci_row = c.execute("SELECT region FROM oci_configs WHERE id=?", (ws["oci_config_id"],)).fetchone()
primary_region = oci_row["region"] if oci_row else oci_cfg.get("region", "")
region_value = 'var.region' if has_region_var else f'"{primary_region}"'
provider_tf = f'''terraform {{
required_providers {{
@@ -4193,7 +4358,7 @@ async def _terraform_exec(wid: str, action: str, user: dict):
provider "oci" {{
{cred_block}
region = "{primary_region}"
region = {region_value}
}}
'''
# Add alias providers with same credentials
@@ -4211,14 +4376,36 @@ provider "oci" {{
'''
(wdir / "provider.tf").write_text(provider_tf)
# Auto-generate terraform.tfvars with compartment and region
# Auto-generate terraform.tfvars — scan declared variables and provide values from OCI config
with db() as c:
oci_row = c.execute("SELECT compartment_id, region FROM oci_configs WHERE id=?", (ws["oci_config_id"],)).fetchone()
# Use workspace-selected compartment, fallback to OCI config default
comp_id = ws["compartment_id"] if ws["compartment_id"] else (_safe_dec(oci_row["compartment_id"]) if oci_row and oci_row["compartment_id"] else "")
region = oci_row["region"] if oci_row else oci_cfg.get("region", "")
tfvars = f'compartment_id = "{comp_id}"\nregion = "{region}"\n'
(wdir / "terraform.tfvars").write_text(tfvars)
# Scan all declared variables in .tf files
declared_vars = set()
for tf_file in sorted(wdir.glob("*.tf")):
if tf_file.name in ("provider.tf", "terraform.tfvars"):
continue
content = tf_file.read_text()
for vm in _re2.finditer(r'variable\s+"([^"]+)"', content):
declared_vars.add(vm.group(1))
# Map known variable names to OCI config values
var_values = {"compartment_id": comp_id}
oci_var_map = {
"tenancy_ocid": oci_cfg.get("tenancy", ""),
"tenancy_id": oci_cfg.get("tenancy", ""),
"user_ocid": oci_cfg.get("user", ""),
"fingerprint": oci_cfg.get("fingerprint", ""),
"private_key_path": oci_cfg.get("key_file", ""),
}
# NOTE: "region" is intentionally NOT included — Terraform uses the default from variable declarations
for vname, vval in oci_var_map.items():
if vname in declared_vars and vval:
var_values[vname] = vval
tfvars_lines = [f'{k} = "{v}"' for k, v in var_values.items()]
(wdir / "terraform.tfvars").write_text("\n".join(tfvars_lines) + "\n")
output_lines = []