feat: Terraform resource reference from provider schema, provider alias fixes, and timestamps
- Add gen_tf_reference.py to generate OCI Terraform provider resource catalog (~937 resources) from `terraform providers schema -json` - Auto-inject categorized resource reference into Terraform Agent system prompt to prevent invalid resource type names - Build-time reference generation in Dockerfile, refreshable via POST /api/terraform/refresh-reference - Provider alias detection with brace-matching parser for multi-region deployments - Clean old .tf files before writing new ones on Re-plan - Message timestamps in terraform chat and history - Partial DOM rendering to eliminate screen flickering - Layout overflow fix (no scroll beyond viewport)
This commit is contained in:
@@ -19,10 +19,14 @@ RUN pip install --no-cache-dir -r requirements.txt && \
|
||||
COPY app.py .
|
||||
COPY cis_reports.py .
|
||||
COPY mcp_cis_server.py .
|
||||
COPY gen_tf_reference.py .
|
||||
|
||||
# Data volume
|
||||
RUN mkdir -p /data
|
||||
|
||||
# Generate OCI Terraform resource reference at build time
|
||||
RUN python gen_tf_reference.py || echo "WARN: tf reference generation skipped (terraform init may need network)"
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "8"]
|
||||
|
||||
197
backend/app.py
197
backend/app.py
@@ -1955,6 +1955,14 @@ TF_DEFAULT_SYSTEM_PROMPT = """Você é um agente especializado EXCLUSIVAMENTE em
|
||||
- Cada bloco `hcl` vira um arquivo separado no sistema.
|
||||
- Se o usuário NÃO especificar, gere tudo em um único bloco.
|
||||
|
||||
### REGRA CRÍTICA: Unicidade de Recursos
|
||||
- **NUNCA** declare o mesmo recurso (mesmo tipo + mesmo nome) em mais de um arquivo.
|
||||
- Cada `resource "tipo" "nome"` deve existir em EXATAMENTE UM arquivo.
|
||||
- Se um recurso precisa de atributos definidos em outro arquivo (ex: route_table_id, security_list_ids), defina TODOS os atributos na declaração ÚNICA do recurso, referenciando recursos de outros arquivos.
|
||||
- Exemplo ERRADO: declarar `resource "oci_core_subnet" "app"` em `networking.tf` e redeclarar em `routing.tf`.
|
||||
- Exemplo CORRETO: declarar a subnet UMA VEZ em `networking.tf` com `route_table_id = oci_core_route_table.app_rt.id` referenciando a route table de `routing.tf`.
|
||||
- Antes de gerar o código, planeje quais recursos vão em cada arquivo para evitar duplicações.
|
||||
|
||||
### Contexto OCI
|
||||
- Use as informações de tenancy, região e compartment fornecidas no contexto.
|
||||
- Use `var.compartment_id` como default para compartment_id nos recursos.
|
||||
@@ -2000,8 +2008,68 @@ TF_DEFAULT_SYSTEM_PROMPT = """Você é um agente especializado EXCLUSIVAMENTE em
|
||||
- Se o pedido for ambíguo, faça perguntas de esclarecimento.
|
||||
- NÃO sugira rodar comandos terraform — o sistema cuida disso.
|
||||
- Inclua outputs úteis (IPs, OCIDs dos recursos criados).
|
||||
- SEMPRE declare `variable "region"` no variables.tf (o sistema injeta via terraform.tfvars).
|
||||
|
||||
### Nomes CORRETOS de recursos OCI (ERROS COMUNS)
|
||||
Abaixo estão os nomes corretos de resource types do provider oracle/oci. O modelo DEVE usar exatamente estes nomes:
|
||||
|
||||
- **Network Firewall**:
|
||||
- `oci_network_firewall_network_firewall` (NÃO `oci_network_firewall`)
|
||||
- `oci_network_firewall_network_firewall_policy` (NÃO `oci_network_firewall_policy`)
|
||||
- **DRG Attachment Management**:
|
||||
- `oci_core_drg_attachment_management` (NÃO `oci_core_vcn_drg_attachment_management`)
|
||||
- `oci_core_drg_attachment` — para criar attachments normais
|
||||
- **DRG Route Distribution Statement** (`oci_core_drg_route_distribution_statement`):
|
||||
- NÃO use `attachment_id` diretamente. Use o bloco `match_criteria`:
|
||||
```hcl
|
||||
match_criteria {
|
||||
match_type = "DRG_ATTACHMENT_ID"
|
||||
drg_attachment_id = oci_core_drg_attachment.example.id
|
||||
}
|
||||
```
|
||||
- **Remote Peering Connection**: `oci_core_remote_peering_connection`
|
||||
- **DRG Route Table**: `oci_core_drg_route_table`
|
||||
- **DRG Route Distribution**: `oci_core_drg_route_distribution`
|
||||
- **DRG Route Table Route Rule**: `oci_core_drg_route_table_route_rule`
|
||||
"""
|
||||
|
||||
# ── Terraform OCI Resource Reference (generated at build time, updatable at runtime) ──
|
||||
_TF_RESOURCE_REF_PATH = "/data/oci_tf_resource_reference.txt"
|
||||
_tf_resource_ref_cache: dict = {"content": None, "mtime": 0}
|
||||
|
||||
def _load_tf_resource_reference() -> str:
|
||||
"""Load the OCI Terraform resource reference file, cached by mtime."""
|
||||
try:
|
||||
p = Path(_TF_RESOURCE_REF_PATH)
|
||||
if not p.exists():
|
||||
return ""
|
||||
mtime = p.stat().st_mtime
|
||||
if _tf_resource_ref_cache["content"] and _tf_resource_ref_cache["mtime"] == mtime:
|
||||
return _tf_resource_ref_cache["content"]
|
||||
content = p.read_text()
|
||||
_tf_resource_ref_cache["content"] = content
|
||||
_tf_resource_ref_cache["mtime"] = mtime
|
||||
log.info(f"Loaded TF resource reference: {len(content)} chars, {content.count(chr(10))} lines")
|
||||
return content
|
||||
except Exception as e:
|
||||
log.warning(f"Failed to load TF resource reference: {e}")
|
||||
return ""
|
||||
|
||||
def _regenerate_tf_reference() -> dict:
|
||||
"""Regenerate the OCI Terraform resource reference file."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["python", "/app/gen_tf_reference.py"],
|
||||
capture_output=True, text=True, timeout=300
|
||||
)
|
||||
_tf_resource_ref_cache["content"] = None # invalidate cache
|
||||
if result.returncode == 0:
|
||||
ref = _load_tf_resource_reference()
|
||||
return {"ok": True, "output": result.stdout.strip(), "lines": ref.count('\n')}
|
||||
return {"ok": False, "error": result.stderr.strip()}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
def _get_adb_connection(cfg: dict):
|
||||
"""Create an oracledb connection from an adb_vector_configs row."""
|
||||
import oracledb
|
||||
@@ -3120,6 +3188,25 @@ async def _chat_background(mid: str, sid: str, msg: ChatMsg, user: dict, genai_c
|
||||
except Exception as e:
|
||||
log.warning(f"Failed to inject terraform resource context: {e}")
|
||||
|
||||
# ── Inject OCI Terraform resource reference for correct resource names ──
|
||||
if agent_type == "terraform":
|
||||
tf_ref = _load_tf_resource_reference()
|
||||
if tf_ref:
|
||||
# Extract only the categorized sections (not the full 900+ resource list)
|
||||
# to keep prompt size manageable
|
||||
sections = []
|
||||
for line in tf_ref.split('\n'):
|
||||
if line.startswith('## All Resource Types'):
|
||||
break
|
||||
sections.append(line)
|
||||
ref_compact = '\n'.join(sections).strip()
|
||||
if ref_compact:
|
||||
cfg_dict["system_prompt"] = cfg_dict.get("system_prompt", "") + \
|
||||
"\n\n### Referência de Recursos OCI Terraform (gerado do provider schema)\n" + \
|
||||
"Use EXATAMENTE estes nomes de resource types. Se o recurso não estiver nesta lista, ele NÃO EXISTE no provider.\n\n" + \
|
||||
ref_compact
|
||||
log.info(f"Terraform: injected resource reference ({len(ref_compact)} chars)")
|
||||
|
||||
mcp_tools = []
|
||||
tool_defs = None
|
||||
if msg.use_tools:
|
||||
@@ -3676,6 +3763,26 @@ async def tf_delete_workspace(wid: str, u=Depends(current_user)):
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.post("/api/terraform/refresh-reference")
|
||||
async def tf_refresh_reference(u=Depends(current_user)):
|
||||
"""Regenerate the OCI Terraform resource reference from provider schema."""
|
||||
loop = asyncio.get_event_loop()
|
||||
result = await loop.run_in_executor(_chat_executor, _regenerate_tf_reference)
|
||||
return result
|
||||
|
||||
|
||||
@app.get("/api/terraform/reference-status")
|
||||
async def tf_reference_status(u=Depends(current_user)):
|
||||
"""Check if OCI Terraform resource reference is available."""
|
||||
p = Path(_TF_RESOURCE_REF_PATH)
|
||||
if p.exists():
|
||||
content = _load_tf_resource_reference()
|
||||
lines = content.count('\n') if content else 0
|
||||
mtime = datetime.fromtimestamp(p.stat().st_mtime).isoformat()
|
||||
return {"available": True, "lines": lines, "updated_at": mtime}
|
||||
return {"available": False}
|
||||
|
||||
|
||||
def _write_tf_files(wdir: Path, tf_code: str):
|
||||
"""Parse tf_code for '// filename: xxx.tf' markers and write separate files."""
|
||||
import re as _re
|
||||
@@ -3709,11 +3816,81 @@ async def _terraform_exec(wid: str, action: str, user: dict):
|
||||
wdir = TERRAFORM_DIR / wid
|
||||
wdir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Clean old .tf files (keep .terraform/, state, lock)
|
||||
for old_tf in wdir.glob("*.tf"):
|
||||
old_tf.unlink()
|
||||
|
||||
# Write .tf files — parse // filename: comments to split into separate files
|
||||
_write_tf_files(wdir, ws["tf_code"] or "")
|
||||
|
||||
# Auto-generate provider.tf from OCI config
|
||||
# Detect provider aliases declared by the model in generated files
|
||||
import re as _re2
|
||||
oci_cfg = _get_oci_config(ws["oci_config_id"])
|
||||
alias_blocks = [] # list of (alias_name, region_ref)
|
||||
|
||||
# Scan all .tf files for provider "oci" { alias = "..." ... region = ... }
|
||||
provider_block_re = _re2.compile(r'provider\s+"oci"\s*\{', _re2.MULTILINE)
|
||||
for tf_file in sorted(wdir.glob("*.tf")):
|
||||
if tf_file.name == "provider.tf":
|
||||
continue
|
||||
content = tf_file.read_text()
|
||||
# Find each provider "oci" block and extract alias + region
|
||||
blocks_to_remove = []
|
||||
for m in provider_block_re.finditer(content):
|
||||
start = m.start()
|
||||
# Find matching closing brace
|
||||
depth = 0
|
||||
end = start
|
||||
for ci in range(m.end() - 1, len(content)):
|
||||
if content[ci] == '{':
|
||||
depth += 1
|
||||
elif content[ci] == '}':
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
end = ci + 1
|
||||
break
|
||||
block = content[start:end]
|
||||
alias_m = _re2.search(r'alias\s*=\s*"([^"]+)"', block)
|
||||
region_m = _re2.search(r'region\s*=\s*(.+)', block)
|
||||
if alias_m:
|
||||
alias_name = alias_m.group(1)
|
||||
region_ref = region_m.group(1).strip().rstrip("}").strip() if region_m else '"unknown"'
|
||||
alias_blocks.append((alias_name, region_ref))
|
||||
blocks_to_remove.append((start, end))
|
||||
# Remove model-generated provider blocks (we centralize in provider.tf)
|
||||
if blocks_to_remove:
|
||||
new_content = content
|
||||
for s, e in reversed(blocks_to_remove):
|
||||
new_content = new_content[:s] + new_content[e:]
|
||||
# Also remove leading comments right before removed blocks
|
||||
new_content = _re2.sub(r'\n(//[^\n]*\n){1,5}\s*\n', '\n\n', new_content)
|
||||
new_content = new_content.strip()
|
||||
if new_content:
|
||||
tf_file.write_text(new_content + "\n")
|
||||
else:
|
||||
tf_file.unlink() # Remove empty file
|
||||
|
||||
# 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":
|
||||
continue
|
||||
content = tf_file.read_text()
|
||||
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"'))
|
||||
|
||||
cred_block = f''' tenancy_ocid = "{oci_cfg.get('tenancy', '')}"
|
||||
user_ocid = "{oci_cfg.get('user', '')}"
|
||||
fingerprint = "{oci_cfg.get('fingerprint', '')}"
|
||||
private_key_path = "{oci_cfg.get('key_file', '')}"'''
|
||||
|
||||
# Use region from OCI config row (may differ from oci sdk 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", "")
|
||||
|
||||
provider_tf = f'''terraform {{
|
||||
required_providers {{
|
||||
oci = {{
|
||||
@@ -3723,11 +3900,21 @@ async def _terraform_exec(wid: str, action: str, user: dict):
|
||||
}}
|
||||
|
||||
provider "oci" {{
|
||||
tenancy_ocid = "{oci_cfg.get('tenancy', '')}"
|
||||
user_ocid = "{oci_cfg.get('user', '')}"
|
||||
fingerprint = "{oci_cfg.get('fingerprint', '')}"
|
||||
private_key_path = "{oci_cfg.get('key_file', '')}"
|
||||
region = "{oci_cfg.get('region', '')}"
|
||||
{cred_block}
|
||||
region = "{primary_region}"
|
||||
}}
|
||||
'''
|
||||
# Add alias providers with same credentials
|
||||
seen_aliases = set()
|
||||
for alias_name, region_ref in alias_blocks:
|
||||
if alias_name in seen_aliases:
|
||||
continue
|
||||
seen_aliases.add(alias_name)
|
||||
provider_tf += f'''
|
||||
provider "oci" {{
|
||||
alias = "{alias_name}"
|
||||
{cred_block}
|
||||
region = {region_ref}
|
||||
}}
|
||||
'''
|
||||
(wdir / "provider.tf").write_text(provider_tf)
|
||||
|
||||
70
backend/gen_tf_reference.py
Normal file
70
backend/gen_tf_reference.py
Normal file
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate OCI Terraform provider resource reference from provider schema."""
|
||||
import json, sys, subprocess, os
|
||||
|
||||
def main():
|
||||
schema_dir = "/tmp/tf_schema"
|
||||
os.makedirs(schema_dir, exist_ok=True)
|
||||
|
||||
# Write minimal tf config
|
||||
with open(f"{schema_dir}/main.tf", "w") as f:
|
||||
f.write('terraform {\n required_providers {\n oci = {\n source = "oracle/oci"\n }\n }\n}\n')
|
||||
|
||||
# Init
|
||||
subprocess.run(["terraform", f"-chdir={schema_dir}", "init", "-input=false", "-no-color"],
|
||||
capture_output=True, timeout=120)
|
||||
|
||||
# Get schema
|
||||
result = subprocess.run(["terraform", f"-chdir={schema_dir}", "providers", "schema", "-json"],
|
||||
capture_output=True, text=True, timeout=60)
|
||||
data = json.loads(result.stdout)
|
||||
|
||||
oci = data["provider_schemas"].get("registry.terraform.io/oracle/oci", {})
|
||||
res = oci.get("resource_schemas", {})
|
||||
ds = oci.get("data_source_schemas", {})
|
||||
|
||||
important_prefixes = [
|
||||
"oci_core_", "oci_network_firewall_", "oci_network_load_balancer_",
|
||||
"oci_load_balancer_", "oci_database_", "oci_identity_",
|
||||
"oci_objectstorage_", "oci_containerengine_", "oci_functions_",
|
||||
"oci_dns_", "oci_file_storage_", "oci_kms_", "oci_vault_",
|
||||
"oci_bastion_", "oci_waf_", "oci_certificates_",
|
||||
]
|
||||
|
||||
lines = []
|
||||
lines.append("# OCI Terraform Provider — Resource Reference")
|
||||
lines.append(f"# Total: {len(res)} resources, {len(ds)} data sources")
|
||||
lines.append("")
|
||||
|
||||
for prefix in important_prefixes:
|
||||
cat_resources = {k: v for k, v in sorted(res.items()) if k.startswith(prefix)}
|
||||
if not cat_resources:
|
||||
continue
|
||||
cat_name = prefix.rstrip("_").replace("oci_", "").replace("_", " ").title()
|
||||
lines.append(f"## {cat_name}")
|
||||
for rname, rschema in cat_resources.items():
|
||||
attrs = rschema.get("block", {}).get("attributes", {})
|
||||
required = [a for a, v in attrs.items() if v.get("required")]
|
||||
block_types = list(rschema.get("block", {}).get("block_types", {}).keys())
|
||||
parts = [f"- **{rname}**"]
|
||||
if required:
|
||||
parts.append(f" required: {', '.join(required)}")
|
||||
if block_types:
|
||||
parts.append(f" blocks: {', '.join(block_types)}")
|
||||
lines.append("".join(parts))
|
||||
lines.append("")
|
||||
|
||||
# Also generate a flat list of ALL resource names for quick lookup
|
||||
lines.append("## All Resource Types (full list)")
|
||||
for rname in sorted(res.keys()):
|
||||
lines.append(f"- {rname}")
|
||||
|
||||
output = "/data/oci_tf_resource_reference.txt"
|
||||
with open(output, "w") as f:
|
||||
f.write("\n".join(lines))
|
||||
|
||||
print(f"Generated {len(lines)} lines -> {output}")
|
||||
print(f"Resources: {len(res)}, Data Sources: {len(ds)}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user