feat: chat history, inline file editor, split terminal, OCI resource actions, and layout fixes
- Add ChatGPT-style chat history for Chat Agent and Terraform Agent (sessions, rename, delete) - Add inline .tf file editor with Tab support and auto-sync to workspace code - Add split-panel terminal showing Plan/Apply/Destroy output alongside files/plan/resources - Add OCI resource actions: start/stop Compute instances and Autonomous DBs from Resources panel - Add multi-file Terraform generation via // filename: markers - Fix GenAI empty response handling (GPT-5.2 fallback message) - Fix compartment select visibility, Terraform code block light theme - Fix page layout overflow (no more scroll beyond viewport) - Add DB migration for terraform_workspaces.compartment_id column - Update README with new features, API endpoints, and version notes
This commit is contained in:
260
backend/app.py
260
backend/app.py
@@ -14,7 +14,7 @@ from functools import partial
|
||||
|
||||
from fastapi import (
|
||||
FastAPI, HTTPException, Depends, Request, UploadFile, File, Form,
|
||||
Query, BackgroundTasks
|
||||
Query, Body, BackgroundTasks
|
||||
)
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse, FileResponse
|
||||
@@ -314,6 +314,14 @@ def init_db():
|
||||
message TEXT NOT NULL,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS chat_sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
agent_type TEXT NOT NULL DEFAULT 'chat',
|
||||
title TEXT DEFAULT '',
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS terraform_workspaces (
|
||||
id TEXT PRIMARY KEY, user_id TEXT NOT NULL,
|
||||
session_id TEXT NOT NULL,
|
||||
@@ -352,6 +360,11 @@ def init_db():
|
||||
c.execute(f"ALTER TABLE chat_messages ADD COLUMN {col}")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
for col in ["compartment_id TEXT"]:
|
||||
try:
|
||||
c.execute(f"ALTER TABLE terraform_workspaces 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():
|
||||
@@ -360,6 +373,19 @@ def init_db():
|
||||
(str(uuid.uuid4()), cfg_row["id"], cfg_row["table_name"], "Migrado automaticamente"))
|
||||
except Exception:
|
||||
pass
|
||||
# Backfill chat_sessions from existing chat_messages
|
||||
try:
|
||||
c.execute("""INSERT OR IGNORE INTO chat_sessions (id, user_id, agent_type, title, created_at, updated_at)
|
||||
SELECT cm.session_id, cm.user_id,
|
||||
CASE WHEN tw.id IS NOT NULL THEN 'terraform' ELSE 'chat' END,
|
||||
SUBSTR((SELECT content FROM chat_messages cm2 WHERE cm2.session_id=cm.session_id AND cm2.role='user' ORDER BY cm2.created_at ASC LIMIT 1), 1, 80),
|
||||
MIN(cm.created_at), MAX(cm.created_at)
|
||||
FROM chat_messages cm
|
||||
LEFT JOIN terraform_workspaces tw ON tw.session_id = cm.session_id
|
||||
WHERE cm.session_id NOT IN (SELECT id FROM chat_sessions)
|
||||
GROUP BY cm.session_id""")
|
||||
except Exception:
|
||||
pass
|
||||
# Seed default system prompt if none exists for chat agent
|
||||
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 (?,?,?,?,?)",
|
||||
@@ -1798,11 +1824,61 @@ def _call_genai(gc: dict, message: str, history: list = None, tools: list = None
|
||||
})
|
||||
# Preserve raw tool_calls for building assistant message in next iteration
|
||||
tool_calls_raw = choice.message.tool_calls
|
||||
# Extract text
|
||||
# Extract text from content
|
||||
if hasattr(choice.message, 'content') and choice.message.content:
|
||||
contents = choice.message.content
|
||||
if contents and len(contents) > 0:
|
||||
text = contents[0].text if hasattr(contents[0], 'text') else ""
|
||||
if isinstance(contents, str):
|
||||
text = contents
|
||||
elif isinstance(contents, list):
|
||||
text_parts = []
|
||||
for c in contents:
|
||||
if isinstance(c, str):
|
||||
text_parts.append(c)
|
||||
elif hasattr(c, 'text') and c.text:
|
||||
text_parts.append(c.text)
|
||||
elif isinstance(c, dict) and c.get('text'):
|
||||
text_parts.append(c['text'])
|
||||
text = "\n".join(text_parts)
|
||||
else:
|
||||
text = str(contents)
|
||||
# Fallback: check reasoning_content (some models like GPT-5.2 use this)
|
||||
if not text and hasattr(resp, 'choices') and resp.choices:
|
||||
choice = resp.choices[0]
|
||||
if hasattr(choice, 'message') and choice.message:
|
||||
msg_obj = choice.message
|
||||
if hasattr(msg_obj, 'reasoning_content') and msg_obj.reasoning_content:
|
||||
rc = msg_obj.reasoning_content
|
||||
if isinstance(rc, str):
|
||||
text = rc
|
||||
elif isinstance(rc, list):
|
||||
text_parts = []
|
||||
for c in rc:
|
||||
if isinstance(c, str):
|
||||
text_parts.append(c)
|
||||
elif hasattr(c, 'text') and c.text:
|
||||
text_parts.append(c.text)
|
||||
elif isinstance(c, dict) and c.get('text'):
|
||||
text_parts.append(c['text'])
|
||||
text = "\n".join(text_parts)
|
||||
else:
|
||||
text = str(rc)
|
||||
if text:
|
||||
log.info(f"GenAI: extracted text from reasoning_content ({len(text)} chars)")
|
||||
# Last resort: try choice.text
|
||||
if not text:
|
||||
if hasattr(choice, 'text') and choice.text:
|
||||
text = choice.text
|
||||
if not text and not tool_calls:
|
||||
raw_content = getattr(getattr(resp.choices[0], 'message', None), 'content', None) if hasattr(resp, 'choices') and resp.choices else None
|
||||
# Also dump message attrs for debugging
|
||||
msg_attrs = []
|
||||
if hasattr(resp, 'choices') and resp.choices and hasattr(resp.choices[0], 'message') and resp.choices[0].message:
|
||||
msg_attrs = [a for a in dir(resp.choices[0].message) if not a.startswith('_')]
|
||||
reasoning = getattr(resp.choices[0].message, 'reasoning_content', None) if hasattr(resp, 'choices') and resp.choices and hasattr(resp.choices[0], 'message') else None
|
||||
refusal = getattr(resp.choices[0].message, 'refusal', None) if hasattr(resp, 'choices') and resp.choices and hasattr(resp.choices[0], 'message') else None
|
||||
finish_reason = getattr(resp.choices[0], 'finish_reason', None) if hasattr(resp, 'choices') and resp.choices else None
|
||||
choice_attrs = [a for a in dir(resp.choices[0]) if not a.startswith('_')] if hasattr(resp, 'choices') and resp.choices else []
|
||||
log.warning(f"GenAI returned empty response. model={gc.get('model_id')}, finish_reason={finish_reason}, content_repr: {repr(raw_content)[:500]}, reasoning: {repr(reasoning)[:500] if reasoning else None}, refusal: {repr(refusal)[:200] if refusal else None}, choice_attrs: {choice_attrs}")
|
||||
return (text or "", tool_calls, tool_calls_raw)
|
||||
|
||||
# ── RAG Helpers ───────────────────────────────────────────────────────────────
|
||||
@@ -1859,12 +1935,30 @@ TF_DEFAULT_SYSTEM_PROMPT = """Você é um agente especializado EXCLUSIVAMENTE em
|
||||
### Seu papel
|
||||
- Gerar código Terraform HCL production-ready usando o provider OCI (oracle/oci).
|
||||
- Sempre usar a sintaxe mais recente do OCI Terraform provider.
|
||||
- NÃO incluir bloco provider/required_providers — ele é gerado automaticamente pelo sistema.
|
||||
|
||||
### Multi-Região
|
||||
- Quando o usuário solicitar recursos em MÚLTIPLAS regiões OCI, use **provider aliases**.
|
||||
- NÃO inclua o bloco `terraform { required_providers }` nem o `provider "oci"` principal — estes são gerados automaticamente pelo sistema.
|
||||
- Inclua APENAS os providers com alias para regiões adicionais:
|
||||
```hcl
|
||||
provider "oci" {
|
||||
alias = "secondary"
|
||||
region = var.region_secondary
|
||||
}
|
||||
```
|
||||
- Referencie o provider alias nos recursos: `provider = oci.secondary`
|
||||
- NUNCA diga que não pode criar recursos em outra região. Use provider aliases para qualquer região OCI.
|
||||
|
||||
### Múltiplos Arquivos
|
||||
- Quando o usuário pedir "um arquivo por recurso" ou "arquivos separados", gere MÚLTIPLOS blocos `hcl` separados.
|
||||
- Nomeie cada bloco com um comentário na primeira linha: `// filename: vcn_hub.tf`, `// filename: firewall.tf`, etc.
|
||||
- Cada bloco `hcl` vira um arquivo separado no sistema.
|
||||
- Se o usuário NÃO especificar, gere tudo em um único bloco.
|
||||
|
||||
### 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.
|
||||
- Use `var.region` para região.
|
||||
- Use `var.region` para região primária.
|
||||
|
||||
### Recursos existentes
|
||||
- Se o contexto incluir uma seção "RECURSOS OCI EXISTENTES NO COMPARTMENT", analise-a ANTES de gerar código.
|
||||
@@ -1878,7 +1972,7 @@ TF_DEFAULT_SYSTEM_PROMPT = """Você é um agente especializado EXCLUSIVAMENTE em
|
||||
// código aqui
|
||||
```
|
||||
|
||||
2. Após CADA bloco de código, forneça uma seção **Resource Plan** neste formato exato:
|
||||
2. Após TODOS os blocos de código, forneça uma seção **Resource Plan** neste formato exato:
|
||||
```plan
|
||||
+ oci_core_vcn.main (Virtual Cloud Network)
|
||||
+ oci_core_subnet.public (Subnet Pública)
|
||||
@@ -1889,6 +1983,15 @@ TF_DEFAULT_SYSTEM_PROMPT = """Você é um agente especializado EXCLUSIVAMENTE em
|
||||
3. Explique o que cada recurso faz e como se conectam.
|
||||
4. Quando o usuário pedir modificação, mostre o código COMPLETO atualizado (não apenas diffs).
|
||||
|
||||
### Validação
|
||||
- Após gerar o código, faça uma validação automática verificando:
|
||||
- Referências cruzadas entre recursos estão corretas
|
||||
- Todos os CIDRs estão consistentes e não se sobrepõem indevidamente
|
||||
- Security lists, route tables e gateways necessários estão presentes
|
||||
- Dependências entre recursos estão na ordem correta
|
||||
- Não há gaps de segurança óbvios (subnets sem security list, rotas faltando, etc.)
|
||||
- Inclua uma seção "✅ Validação" no final da resposta com os itens verificados.
|
||||
|
||||
### Diretrizes
|
||||
- Use variables para valores configuráveis (CIDRs, display names, shapes, etc.)
|
||||
- Inclua defaults sensatos nas definições de variáveis.
|
||||
@@ -1897,7 +2000,6 @@ 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).
|
||||
- Gere tudo em um único bloco de código (variables + resources + outputs juntos).
|
||||
"""
|
||||
|
||||
def _get_adb_connection(cfg: dict):
|
||||
@@ -2824,10 +2926,17 @@ async def report_dl(rid, fmt: str = Query("json"), u=Depends(current_user)):
|
||||
def _chat_start(msg: ChatMsg, u, attachments: list = None, agent_type: str = "chat"):
|
||||
"""Start a chat: save user msg, resolve config, return (sid, mid, genai_cfg) or immediate response.
|
||||
If genai_cfg is None, returns immediate fallback response in mid field as dict."""
|
||||
is_new = not msg.session_id
|
||||
sid = msg.session_id or str(uuid.uuid4())
|
||||
with db() as c:
|
||||
c.execute("INSERT INTO chat_messages (id,session_id,user_id,role,content,model_id,status) VALUES (?,?,?,?,?,?,?)",
|
||||
(str(uuid.uuid4()), sid, u["id"], "user", msg.message, None, "done"))
|
||||
if is_new:
|
||||
title = (msg.message or "Nova conversa")[:80].strip()
|
||||
c.execute("INSERT OR IGNORE INTO chat_sessions (id,user_id,agent_type,title) VALUES (?,?,?,?)",
|
||||
(sid, u["id"], agent_type, title))
|
||||
else:
|
||||
c.execute("UPDATE chat_sessions SET updated_at=datetime('now') WHERE id=?", (sid,))
|
||||
|
||||
genai_cfg = None
|
||||
if msg.genai_config_id:
|
||||
@@ -2982,6 +3091,10 @@ 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 for complex infra code ──
|
||||
if agent_type == "terraform":
|
||||
cfg_dict["max_tokens"] = max(int(cfg_dict.get("max_tokens", 6000)), 16000)
|
||||
|
||||
# ── Inject existing OCI resources for terraform agent ──
|
||||
if agent_type == "terraform" and active_oci_id:
|
||||
try:
|
||||
@@ -3086,6 +3199,11 @@ async def _chat_background(mid: str, sid: str, msg: ChatMsg, user: dict, genai_c
|
||||
tools_info = '\n\n🔧 **Tools utilizadas:** ' + ', '.join(tr["name"] for tr in all_tool_results)
|
||||
resp += tools_info
|
||||
|
||||
if not resp or not resp.strip():
|
||||
model_id = genai_cfg.get("model_id", "unknown")
|
||||
resp = f"⚠️ O modelo **{model_id}** retornou uma resposta vazia. Isso pode ocorrer com alguns modelos. Tente novamente ou selecione outro modelo (ex: Gemini, Llama)."
|
||||
log.warning(f"Chat {mid}: empty response from model {model_id}, returning fallback message")
|
||||
|
||||
with db() as c:
|
||||
c.execute("UPDATE chat_messages SET content=?, status='done' WHERE id=?", (resp, mid))
|
||||
log.info(f"Chat {mid} completed successfully")
|
||||
@@ -3218,9 +3336,43 @@ def _agent_respond(msg, user):
|
||||
return ("Sou o **OCI CIS AI Agent v" + VERSION + "**. Sem modelo GenAI configurado, uso respostas locais.\n\n"
|
||||
"Para chat com IA:\n1. Configure **OCI Credentials**\n2. Configure **GenAI** com modelo e região\n3. Selecione o modelo no chat\n\nDigite **ajuda** para ver os comandos.")
|
||||
|
||||
@app.get("/api/chat/sessions")
|
||||
async def list_chat_sessions(agent_type: str = "chat", limit: int = 50, u=Depends(current_user)):
|
||||
with db() as c:
|
||||
rows = c.execute(
|
||||
"""SELECT cs.id, cs.title, cs.agent_type, cs.created_at, cs.updated_at,
|
||||
(SELECT COUNT(*) FROM chat_messages cm WHERE cm.session_id=cs.id) as message_count
|
||||
FROM chat_sessions cs WHERE cs.user_id=? AND cs.agent_type=?
|
||||
ORDER BY cs.updated_at DESC LIMIT ?""",
|
||||
(u["id"], agent_type, limit)).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
@app.get("/api/chat/sessions/{sid}/messages")
|
||||
async def get_session_messages(sid: str, u=Depends(current_user)):
|
||||
with db() as c:
|
||||
session = c.execute("SELECT * FROM chat_sessions WHERE id=? AND user_id=?", (sid, u["id"])).fetchone()
|
||||
if not session:
|
||||
raise HTTPException(404, "Session not found")
|
||||
msgs = c.execute(
|
||||
"SELECT id, role, content, model_id, status, created_at FROM chat_messages "
|
||||
"WHERE session_id=? AND status='done' ORDER BY created_at ASC", (sid,)).fetchall()
|
||||
return {"session": dict(session), "messages": [dict(m) for m in msgs]}
|
||||
|
||||
|
||||
@app.put("/api/chat/sessions/{sid}/title")
|
||||
async def rename_session(sid: str, req: dict = Body(...), u=Depends(current_user)):
|
||||
with db() as c:
|
||||
c.execute("UPDATE chat_sessions SET title=? WHERE id=? AND user_id=?",
|
||||
(req.get("title", "")[:120], sid, u["id"]))
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.delete("/api/chat/{sid}")
|
||||
async def clear_chat(sid, u=Depends(current_user)):
|
||||
with db() as c: c.execute("DELETE FROM chat_messages WHERE session_id=? AND user_id=?", (sid, u["id"]))
|
||||
with db() as c:
|
||||
c.execute("DELETE FROM chat_messages WHERE session_id=? AND user_id=?", (sid, u["id"]))
|
||||
c.execute("DELETE FROM chat_sessions WHERE id=? AND user_id=?", (sid, u["id"]))
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@@ -3252,13 +3404,13 @@ def _fetch_compartment_resources(oci_config_id: str, compartment_id: str, region
|
||||
try:
|
||||
compute = oci.core.ComputeClient(config)
|
||||
insts = compute.list_instances(compartment_id).data
|
||||
resources["instances"] = [{"id": i.id, "display_name": i.display_name, "shape": i.shape, "state": i.lifecycle_state} for i in insts if i.lifecycle_state == "RUNNING"]
|
||||
resources["instances"] = [{"id": i.id, "display_name": i.display_name, "shape": i.shape, "state": i.lifecycle_state} for i in insts if i.lifecycle_state in ("RUNNING", "STOPPED")]
|
||||
except Exception as e:
|
||||
log.warning(f"Failed to fetch compute resources: {e}")
|
||||
try:
|
||||
db_client = oci.database.DatabaseClient(config)
|
||||
adbs = db_client.list_autonomous_databases(compartment_id).data
|
||||
resources["autonomous_databases"] = [{"id": a.id, "display_name": a.display_name, "db_name": a.db_name, "state": a.lifecycle_state, "is_free_tier": a.is_free_tier} for a in adbs if a.lifecycle_state == "AVAILABLE"]
|
||||
resources["autonomous_databases"] = [{"id": a.id, "display_name": a.display_name, "db_name": a.db_name, "state": a.lifecycle_state, "is_free_tier": a.is_free_tier} for a in adbs if a.lifecycle_state in ("AVAILABLE", "STOPPED")]
|
||||
except Exception as e:
|
||||
log.warning(f"Failed to fetch database resources: {e}")
|
||||
try:
|
||||
@@ -3308,6 +3460,53 @@ def _build_resource_context(resources: dict) -> str:
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@app.post("/api/oci/instances/{instance_id}/action")
|
||||
async def oci_instance_action(instance_id: str, req: dict, u=Depends(current_user)):
|
||||
action = req.get("action", "").upper()
|
||||
oci_config_id = req.get("oci_config_id", "")
|
||||
region = req.get("region")
|
||||
if action not in ("START", "STOP"):
|
||||
raise HTTPException(400, "Ação inválida. Use START ou STOP.")
|
||||
if not oci_config_id:
|
||||
raise HTTPException(400, "oci_config_id obrigatório")
|
||||
try:
|
||||
import oci
|
||||
config = _get_oci_config(oci_config_id)
|
||||
if region:
|
||||
config["region"] = region
|
||||
compute = oci.core.ComputeClient(config)
|
||||
compute.instance_action(instance_id, action)
|
||||
_audit(u["id"], u["username"], f"instance_{action.lower()}", instance_id)
|
||||
return {"ok": True, "action": action, "instance_id": instance_id}
|
||||
except Exception as e:
|
||||
raise HTTPException(500, str(e)[:500])
|
||||
|
||||
|
||||
@app.post("/api/oci/autonomous-databases/{adb_id}/action")
|
||||
async def oci_adb_action(adb_id: str, req: dict, u=Depends(current_user)):
|
||||
action = req.get("action", "").lower()
|
||||
oci_config_id = req.get("oci_config_id", "")
|
||||
region = req.get("region")
|
||||
if action not in ("start", "stop"):
|
||||
raise HTTPException(400, "Ação inválida. Use start ou stop.")
|
||||
if not oci_config_id:
|
||||
raise HTTPException(400, "oci_config_id obrigatório")
|
||||
try:
|
||||
import oci
|
||||
config = _get_oci_config(oci_config_id)
|
||||
if region:
|
||||
config["region"] = region
|
||||
db_client = oci.database.DatabaseClient(config)
|
||||
if action == "start":
|
||||
db_client.start_autonomous_database(adb_id)
|
||||
else:
|
||||
db_client.stop_autonomous_database(adb_id)
|
||||
_audit(u["id"], u["username"], f"adb_{action}", adb_id)
|
||||
return {"ok": True, "action": action, "adb_id": adb_id}
|
||||
except Exception as e:
|
||||
raise HTTPException(500, str(e)[:500])
|
||||
|
||||
|
||||
@app.get("/api/terraform/resources")
|
||||
async def tf_list_resources(oci_config_id: str = Query(...), compartment_id: str = Query(...), region: str = Query(None), u=Depends(current_user)):
|
||||
"""List existing OCI resources in a compartment for terraform context."""
|
||||
@@ -3430,6 +3629,23 @@ async def tf_download(wid: str, u=Depends(current_user)):
|
||||
r = c.execute("SELECT tf_code, name FROM terraform_workspaces WHERE id=? AND user_id=?", (wid, u["id"])).fetchone()
|
||||
if not r or not r["tf_code"]: raise HTTPException(404, "No code found")
|
||||
from starlette.responses import Response
|
||||
import re as _re
|
||||
parts = _re.split(r'^//\s*filename:\s*(\S+)\s*$', r["tf_code"], flags=_re.MULTILINE)
|
||||
if len(parts) >= 3:
|
||||
import io, zipfile
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||
if parts[0].strip():
|
||||
zf.writestr("main.tf", parts[0].strip())
|
||||
for i in range(1, len(parts), 2):
|
||||
fname = parts[i].strip().replace("/", "_").replace("..", "_")
|
||||
content = parts[i + 1].strip() if i + 1 < len(parts) else ""
|
||||
if fname and content:
|
||||
zf.writestr(fname, content)
|
||||
buf.seek(0)
|
||||
ws_name = r["name"] or "terraform"
|
||||
return Response(content=buf.read(), media_type="application/zip",
|
||||
headers={"Content-Disposition": f'attachment; filename="{ws_name}.zip"'})
|
||||
return Response(content=r["tf_code"], media_type="text/plain",
|
||||
headers={"Content-Disposition": f'attachment; filename="main.tf"'})
|
||||
|
||||
@@ -3460,6 +3676,24 @@ async def tf_delete_workspace(wid: str, u=Depends(current_user)):
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
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
|
||||
parts = _re.split(r'^//\s*filename:\s*(\S+)\s*$', tf_code, flags=_re.MULTILINE)
|
||||
if len(parts) >= 3:
|
||||
# parts = [preamble, filename1, content1, filename2, content2, ...]
|
||||
if parts[0].strip():
|
||||
(wdir / "main.tf").write_text(parts[0].strip())
|
||||
for i in range(1, len(parts), 2):
|
||||
fname = parts[i].strip()
|
||||
content = parts[i + 1].strip() if i + 1 < len(parts) else ""
|
||||
if fname and content:
|
||||
safe_name = fname.replace("/", "_").replace("..", "_")
|
||||
(wdir / safe_name).write_text(content)
|
||||
else:
|
||||
(wdir / "main.tf").write_text(tf_code)
|
||||
|
||||
|
||||
async def _terraform_exec(wid: str, action: str, user: dict):
|
||||
"""Background: run terraform init + plan/apply/destroy in workspace dir."""
|
||||
log.info(f"Terraform {action} started: wid={wid}")
|
||||
@@ -3475,8 +3709,8 @@ async def _terraform_exec(wid: str, action: str, user: dict):
|
||||
wdir = TERRAFORM_DIR / wid
|
||||
wdir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Write main.tf
|
||||
(wdir / "main.tf").write_text(ws["tf_code"] or "")
|
||||
# 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
|
||||
oci_cfg = _get_oci_config(ws["oci_config_id"])
|
||||
|
||||
Reference in New Issue
Block a user