feat: region safety guard, rollback button, SSH key support, split-panel layout

- Block terraform plan if model omits variable "region" (prevents wrong-region provisioning)
- Rollback button on apply failure with ROLLBACK confirmation modal
- SSH public key field in OCI configs, auto-injected into terraform.tfvars
- Split-panel layout: terminal right (40%), files/plan/resources below chat (60%)
- reasoning_effort uppercase fix for OCI SDK
- Plan button persists after destroy status
This commit is contained in:
nogueiraguh
2026-03-12 00:26:13 -03:00
parent 58d430c904
commit e4ba2cfaab
3 changed files with 182 additions and 49 deletions

View File

@@ -380,6 +380,11 @@ def init_db():
c.execute(f"ALTER TABLE terraform_workspaces ADD COLUMN {col}")
except sqlite3.OperationalError:
pass
for col in ["ssh_public_key TEXT"]:
try:
c.execute(f"ALTER TABLE oci_configs ADD COLUMN {col}")
except sqlite3.OperationalError:
pass
# Migrate legacy table_name from adb_vector_configs into adb_vector_tables
try:
for cfg_row in c.execute("SELECT id, table_name FROM adb_vector_configs WHERE table_name IS NOT NULL AND table_name != ''").fetchall():
@@ -641,6 +646,7 @@ async def save_oci(
user_ocid: str = Form(...), fingerprint: str = Form(...),
region: str = Form(...), compartment_id: str = Form(""),
key_passphrase: str = Form(""),
ssh_public_key: str = Form(""),
private_key: UploadFile = File(...), public_key: Optional[UploadFile] = File(None),
u = Depends(require("admin","user"))
):
@@ -664,8 +670,8 @@ async def save_oci(
cfg_file.write_text(cfg_content)
cfg_file.chmod(0o600)
with db() as c:
c.execute("INSERT INTO oci_configs (id,user_id,tenancy_name,tenancy_ocid,user_ocid,fingerprint,region,key_file_path,compartment_id) VALUES (?,?,?,?,?,?,?,?,?)",
(cid, u["id"], tenancy_name, _enc(tenancy_ocid), _enc(user_ocid), _enc(fingerprint), region, str(kp), _enc(compartment_id) if compartment_id else None))
c.execute("INSERT INTO oci_configs (id,user_id,tenancy_name,tenancy_ocid,user_ocid,fingerprint,region,key_file_path,compartment_id,ssh_public_key) VALUES (?,?,?,?,?,?,?,?,?,?)",
(cid, u["id"], tenancy_name, _enc(tenancy_ocid), _enc(user_ocid), _enc(fingerprint), region, str(kp), _enc(compartment_id) if compartment_id else None, ssh_public_key.strip() if ssh_public_key.strip() else None))
_audit(u["id"], u["username"], "save_oci_config", cid, f"tenancy={tenancy_name}")
_config_log("oci", cid, tenancy_name, "success", "save", f"Credencial salva: {tenancy_name} ({region})", u["id"], u["username"])
return {"id": cid, "tenancy_name": tenancy_name, "region": region}
@@ -673,7 +679,7 @@ async def save_oci(
@app.get("/api/oci/configs")
async def list_oci(u=Depends(current_user)):
with db() as c:
cols = "id,user_id,tenancy_name,tenancy_ocid,user_ocid,fingerprint,region,compartment_id,created_at"
cols = "id,user_id,tenancy_name,tenancy_ocid,user_ocid,fingerprint,region,compartment_id,ssh_public_key,created_at"
if u["role"]=="admin": rows=c.execute(f"SELECT {cols} FROM oci_configs").fetchall()
else: rows=c.execute(f"SELECT {cols} FROM oci_configs WHERE user_id=?",(u["id"],)).fetchall()
result = []
@@ -694,6 +700,10 @@ async def list_oci(u=Depends(current_user)):
d["compartment_id"] = _mask(_dec(d["compartment_id"]))
except Exception:
d["compartment_id"] = _mask(d["compartment_id"])
if d.get("ssh_public_key"):
# Show just type + first/last few chars for identification
parts = d["ssh_public_key"].split()
d["ssh_public_key_preview"] = f"{parts[0]} ...{parts[1][-12:]}" if len(parts) >= 2 else "ssh-rsa ..."
result.append(d)
return result
@@ -715,6 +725,7 @@ async def update_oci(
user_ocid: str = Form(""), fingerprint: str = Form(""),
region: str = Form(...), compartment_id: str = Form(""),
key_passphrase: str = Form(""),
ssh_public_key: str = Form(""),
private_key: Optional[UploadFile] = File(None), public_key: Optional[UploadFile] = File(None),
u = Depends(require("admin","user"))
):
@@ -727,6 +738,11 @@ async def update_oci(
user_ocid = user_ocid or _safe_dec(existing["user_ocid"])
fingerprint = fingerprint or _safe_dec(existing["fingerprint"])
compartment_id = compartment_id or _safe_dec(existing["compartment_id"]) or ""
try:
existing_ssh = existing["ssh_public_key"] or ""
except (IndexError, KeyError):
existing_ssh = ""
ssh_public_key = ssh_public_key.strip() if ssh_public_key.strip() else existing_ssh
cdir = OCI_DIR / cid; cdir.mkdir(parents=True, exist_ok=True)
kp = cdir / "oci_api_key.pem"
if private_key and private_key.filename:
@@ -744,8 +760,8 @@ async def update_oci(
cfg_content += f"pass_phrase={key_passphrase}\n"
cfg_file.write_text(cfg_content); cfg_file.chmod(0o600)
with db() as c:
c.execute("UPDATE oci_configs SET tenancy_name=?,tenancy_ocid=?,user_ocid=?,fingerprint=?,region=?,compartment_id=? WHERE id=?",
(tenancy_name, _enc(tenancy_ocid), _enc(user_ocid), _enc(fingerprint), region, _enc(compartment_id) if compartment_id else None, cid))
c.execute("UPDATE oci_configs SET tenancy_name=?,tenancy_ocid=?,user_ocid=?,fingerprint=?,region=?,compartment_id=?,ssh_public_key=? WHERE id=?",
(tenancy_name, _enc(tenancy_ocid), _enc(user_ocid), _enc(fingerprint), region, _enc(compartment_id) if compartment_id else None, ssh_public_key or None, cid))
_audit(u["id"], u["username"], "update_oci_config", cid, f"tenancy={tenancy_name}")
_config_log("oci", cid, tenancy_name, "success", "save", f"Credencial atualizada: {tenancy_name} ({region})", u["id"], u["username"])
return {"id": cid, "tenancy_name": tenancy_name, "region": region}
@@ -1903,7 +1919,7 @@ def _call_genai(gc: dict, message: str, history: list = None, tools: list = None
chat_request.max_completion_tokens = clamped_tokens
re = gc.get("reasoning_effort")
if re and hasattr(chat_request, "reasoning_effort"):
chat_request.reasoning_effort = re
chat_request.reasoning_effort = re.upper() if isinstance(re, str) else re
# Explicitly unset unsupported params
chat_request.temperature = None
chat_request.top_p = None
@@ -1940,6 +1956,16 @@ def _call_genai(gc: dict, message: str, history: list = None, tools: list = None
chat_request.frequency_penalty = None
chat_request.presence_penalty = None
# Log applied parameters
params_log = f"provider={provider}, reasoning={is_reasoning}"
params_log += f", max_tokens={getattr(chat_request, 'max_completion_tokens', None) or getattr(chat_request, 'max_tokens', None)}"
params_log += f", temp={getattr(chat_request, 'temperature', None)}, top_p={getattr(chat_request, 'top_p', None)}"
params_log += f", top_k={getattr(chat_request, 'top_k', None)}"
params_log += f", freq_pen={getattr(chat_request, 'frequency_penalty', None)}, pres_pen={getattr(chat_request, 'presence_penalty', None)}"
if is_reasoning:
params_log += f", reasoning_effort={getattr(chat_request, 'reasoning_effort', None)}"
log.info(f"GenAI params: {params_log}")
messages = []
if system_prompt:
sys_content = oci.generative_ai_inference.models.TextContent()
@@ -2181,7 +2207,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.
- Se houver "ARQUIVOS TERRAFORM ATUAIS NO WORKSPACE", gere **SOMENTE os arquivos que precisam de correção**. Arquivos sem erro NÃO devem ser regenerados. Mantenha os mesmos nomes de arquivo. Se o erro está em `networking.tf`, gere APENAS `// filename: networking.tf` com o conteúdo corrigido. NUNCA regenere `variables.tf`, `outputs.tf` ou outros arquivos que não têm erro, a menos que o erro exija alteração neles (ex: variável faltando).
- 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:
@@ -2197,6 +2223,13 @@ Se pedirem AWS, Azure, GCP ou outro provider, recuse educadamente.
- Nunca invente recursos inexistentes.
- Use sempre HCL multi-line, nunca blocos inline com múltiplos argumentos.
### Correção de erros (IMPORTANTE)
- Quando o usuário envia um erro de plan/apply com arquivos existentes, gere SOMENTE o(s) arquivo(s) que corrigem o erro.
- Exemplo: se o erro é em `service_gateways.tf`, retorne APENAS `// filename: service_gateways.tf` com o conteúdo corrigido.
- Se a correção exige criar/alterar uma variável, inclua também `// filename: variables.tf` apenas com as variáveis novas/alteradas + todas existentes.
- NUNCA regenere todos os arquivos do zero. Isso descarta o trabalho anterior e cria loops infinitos de correção.
- Preserve os nomes de arquivo exatos. Não renomeie `drg_attachments_and_routes.tf` para `drg.tf`.
### Regras críticas
- `oci_core_drg_route_distribution_statement` deve usar `match_criteria`.
- 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).
@@ -4021,11 +4054,13 @@ async def _chat_background(mid: str, sid: str, msg: ChatMsg, user: dict, genai_c
base_prompt = cfg_dict.get("system_prompt", "")
cfg_dict["system_prompt"] = f"{base_prompt}\n\n{config_hint}" if base_prompt else config_hint
# ── Terraform agent: boost max_tokens to model limit ──
# ── Terraform agent: boost max_tokens to model limit + force reasoning_effort=high ──
if agent_type == "terraform":
tf_model_info = GENAI_MODELS.get(cfg_dict.get("model_id", ""), {})
tf_model_max = tf_model_info.get("max_tokens", 32768)
cfg_dict["max_tokens"] = tf_model_max
if tf_model_info.get("reasoning") and not cfg_dict.get("reasoning_effort"):
cfg_dict["reasoning_effort"] = "HIGH"
# ── Inject existing OCI resources for terraform agent ──
if agent_type == "terraform" and active_oci_id:
@@ -5129,11 +5164,22 @@ async def _terraform_exec(wid: str, action: str, user: dict):
if passphrase:
cred_block += f'\n private_key_password = "{passphrase}"'
# Primary region: use var.region if declared in .tf files, otherwise hardcode from OCI config
# Primary region: MUST use var.region — model is required to declare it
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}"'
if not has_region_var:
error_msg = (
f'ERRO: O código Terraform não declarou variable "region". '
f'Isso é obrigatório para garantir que os recursos sejam provisionados na região correta. '
f'Adicione no variables.tf: variable "region" {{ type = string; default = "{primary_region}" }}'
)
log.warning(f"Workspace {wid}: missing variable 'region' — blocking plan")
with db() as c:
c.execute("UPDATE terraform_workspaces SET status='failed', error=?, updated_at=datetime('now') WHERE id=?",
(error_msg, wid))
return
region_value = 'var.region'
provider_tf = f'''terraform {{
required_providers {{
@@ -5165,8 +5211,13 @@ provider "oci" {{
# 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()
oci_row = c.execute("SELECT compartment_id, region, ssh_public_key FROM oci_configs WHERE id=?", (ws["oci_config_id"],)).fetchone()
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 "")
try:
ssh_pub_key = oci_row["ssh_public_key"] if oci_row else ""
except (IndexError, KeyError):
ssh_pub_key = ""
ssh_pub_key = ssh_pub_key or ""
# Scan all declared variables in .tf files
declared_vars = set()
@@ -5185,6 +5236,8 @@ provider "oci" {{
"user_ocid": oci_cfg.get("user", ""),
"fingerprint": oci_cfg.get("fingerprint", ""),
"private_key_path": oci_cfg.get("key_file", ""),
"ssh_public_key": ssh_pub_key,
"ssh_authorized_keys": ssh_pub_key,
}
# NOTE: "region" is intentionally NOT included — Terraform uses the default from variable declarations
for vname, vval in oci_var_map.items():