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:
211
backend/app.py
211
backend/app.py
@@ -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 = []
|
||||
|
||||
|
||||
Reference in New Issue
Block a user