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:
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user