Fase 0 complete: extract all business logic, auth, database, and 176 endpoints from monolithic app.py into dedicated modules. Structure: - app.py: FastAPI hub (CORS, routers, startup/shutdown) - models.py: 13 Pydantic request models - utils.py: shared utilities (embed status, upload validation, process registries) - config.py: constants, env vars, model catalogs - auth/: crypto, jwt_auth, oidc, rate_limit - database/: db(), init_db(), schema DDL - services/: genai, compliance, chat, terraform, embeddings, cis_reports, mcp - routes/: 13 APIRouter modules (auth, users, oci_config, oci_explorer, genai, mcp, adb, embeddings, reports, chat, terraform, settings, cis_engine) Also: README updated with OCIR auth instructions for manual docker run. 86 tests passing.
504 lines
26 KiB
Python
504 lines
26 KiB
Python
"""OCI config routes — CRUD, test, OCI CLI terminal (execute, history, completions)."""
|
|
import asyncio
|
|
import json
|
|
import os
|
|
import re
|
|
import shlex
|
|
import shutil
|
|
import subprocess
|
|
import uuid
|
|
from functools import partial
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
from fastapi import (
|
|
APIRouter, HTTPException, Depends, UploadFile, File, Form, Query,
|
|
)
|
|
|
|
from config import OCI_DIR, _chat_executor, log
|
|
from database import db
|
|
from auth.crypto import _enc, _dec, _mask, _safe_dec
|
|
from auth.jwt_auth import current_user, require, _audit, _config_log, _verify_config_access
|
|
from utils import validate_upload
|
|
|
|
router = APIRouter()
|
|
|
|
# ── OCI Config ────────────────────────────────────────────────────────────────
|
|
@router.post("/api/oci/config")
|
|
async def save_oci(
|
|
tenancy_name: str = Form(...), tenancy_ocid: str = Form(...),
|
|
user_ocid: str = Form(""), fingerprint: str = Form(""),
|
|
region: str = Form(...), compartment_id: str = Form(""),
|
|
key_passphrase: str = Form(""),
|
|
ssh_public_key: str = Form(""),
|
|
auth_type: str = Form("api_key"),
|
|
security_token: str = Form(""),
|
|
private_key: Optional[UploadFile] = File(None), public_key: Optional[UploadFile] = File(None),
|
|
u = Depends(require("admin","user"))
|
|
):
|
|
if auth_type not in ("api_key", "session_token"):
|
|
raise HTTPException(400, "auth_type deve ser 'api_key' ou 'session_token'")
|
|
_PEM_EXTS = {".pem", ".key"}
|
|
if private_key and private_key.filename:
|
|
await validate_upload(private_key, _PEM_EXTS)
|
|
private_key.file.seek(0)
|
|
if public_key and public_key.filename:
|
|
await validate_upload(public_key, _PEM_EXTS)
|
|
public_key.file.seek(0)
|
|
cid = str(uuid.uuid4()); cdir = OCI_DIR / cid; cdir.mkdir(parents=True)
|
|
kp = cdir / "oci_api_key.pem"
|
|
|
|
if auth_type == "session_token":
|
|
# Session token auth: requires tenancy_ocid, region, token, and session key
|
|
if not security_token.strip():
|
|
shutil.rmtree(cdir, ignore_errors=True)
|
|
raise HTTPException(400, "Session Token é obrigatório para autenticação por token.")
|
|
if not private_key or not private_key.filename:
|
|
shutil.rmtree(cdir, ignore_errors=True)
|
|
raise HTTPException(400, "A chave de sessão (.pem) é obrigatória para autenticação por token.")
|
|
key_bytes = await private_key.read()
|
|
kp.write_bytes(key_bytes); kp.chmod(0o600)
|
|
# Save security token file
|
|
token_file = cdir / "token"
|
|
token_file.write_text(security_token.strip())
|
|
token_file.chmod(0o600)
|
|
# Generate config
|
|
cfg_content = (f"[DEFAULT]\ntenancy={tenancy_ocid}\nregion={region}\n"
|
|
f"key_file={kp}\nsecurity_token_file={token_file}\n")
|
|
if user_ocid:
|
|
cfg_content = f"[DEFAULT]\nuser={user_ocid}\n" + cfg_content.replace("[DEFAULT]\n", "")
|
|
cfg_file = cdir / "config"
|
|
cfg_file.write_text(cfg_content); cfg_file.chmod(0o600)
|
|
# For session_token, fingerprint/user may not be provided — use placeholders
|
|
user_ocid = user_ocid or "session_token_user"
|
|
fingerprint = fingerprint or "session_token"
|
|
else:
|
|
# API Key auth (original flow)
|
|
if not user_ocid or not fingerprint:
|
|
shutil.rmtree(cdir, ignore_errors=True)
|
|
raise HTTPException(400, "OCID User e Fingerprint são obrigatórios para API Key.")
|
|
if not private_key or not private_key.filename:
|
|
shutil.rmtree(cdir, ignore_errors=True)
|
|
raise HTTPException(400, "Selecione a chave privada (.pem).")
|
|
key_bytes = await private_key.read()
|
|
kp.write_bytes(key_bytes); kp.chmod(0o600)
|
|
key_text = key_bytes.decode("utf-8", errors="ignore")
|
|
key_is_encrypted = "ENCRYPTED" in key_text or "Proc-Type: 4,ENCRYPTED" in key_text
|
|
if key_is_encrypted and not key_passphrase:
|
|
shutil.rmtree(cdir, ignore_errors=True)
|
|
raise HTTPException(400, "A chave privada está criptografada (ENCRYPTED). Informe a Key Passphrase.")
|
|
if public_key and public_key.filename:
|
|
(cdir / "oci_api_key_public.pem").write_bytes(await public_key.read())
|
|
cfg_file = cdir / "config"
|
|
cfg_content = (f"[DEFAULT]\nuser={user_ocid}\nfingerprint={fingerprint}\n"
|
|
f"tenancy={tenancy_ocid}\nregion={region}\nkey_file={kp}\n")
|
|
if key_passphrase:
|
|
cfg_content += f"pass_phrase={key_passphrase}\n"
|
|
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,ssh_public_key,auth_type) 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, auth_type))
|
|
_audit(u["id"], u["username"], "save_oci_config", cid, f"tenancy={tenancy_name} auth={auth_type}")
|
|
_config_log("oci", cid, tenancy_name, "success", "save", f"Credencial salva: {tenancy_name} ({region}) [{auth_type}]", u["id"], u["username"])
|
|
return {"id": cid, "tenancy_name": tenancy_name, "region": region}
|
|
|
|
@router.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,ssh_public_key,auth_type,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 = []
|
|
for r in rows:
|
|
d = dict(r)
|
|
# Decrypt and mask sensitive fields for display
|
|
try:
|
|
d["tenancy_ocid"] = _mask(_dec(d["tenancy_ocid"]))
|
|
except Exception:
|
|
d["tenancy_ocid"] = _mask(d["tenancy_ocid"])
|
|
try:
|
|
d["user_ocid"] = _mask(_dec(d["user_ocid"]))
|
|
except Exception:
|
|
d["user_ocid"] = _mask(d["user_ocid"])
|
|
d["fingerprint"] = "\u2022" * 20 # fingerprint fully masked
|
|
if d.get("compartment_id"):
|
|
try:
|
|
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
|
|
|
|
@router.delete("/api/oci/configs/{cid}")
|
|
async def del_oci(cid: str, u=Depends(require("admin","user"))):
|
|
with db() as c:
|
|
cfg=c.execute("SELECT * FROM oci_configs WHERE id=?",(cid,)).fetchone()
|
|
if not cfg: raise HTTPException(404)
|
|
if u["role"]!="admin" and cfg["user_id"]!=u["id"]: raise HTTPException(403)
|
|
c.execute("DELETE FROM oci_configs WHERE id=?",(cid,))
|
|
d=OCI_DIR/cid
|
|
if d.exists(): shutil.rmtree(d)
|
|
return {"ok": True}
|
|
|
|
@router.put("/api/oci/configs/{cid}")
|
|
async def update_oci(
|
|
cid: str,
|
|
tenancy_name: str = Form(...), tenancy_ocid: str = Form(""),
|
|
user_ocid: str = Form(""), fingerprint: str = Form(""),
|
|
region: str = Form(...), compartment_id: str = Form(""),
|
|
key_passphrase: str = Form(""),
|
|
ssh_public_key: str = Form(""),
|
|
auth_type: str = Form(""),
|
|
security_token: str = Form(""),
|
|
private_key: Optional[UploadFile] = File(None), public_key: Optional[UploadFile] = File(None),
|
|
u = Depends(require("admin","user"))
|
|
):
|
|
_PEM_EXTS = {".pem", ".key"}
|
|
if private_key and private_key.filename:
|
|
await validate_upload(private_key, _PEM_EXTS)
|
|
private_key.file.seek(0)
|
|
if public_key and public_key.filename:
|
|
await validate_upload(public_key, _PEM_EXTS)
|
|
public_key.file.seek(0)
|
|
with db() as c:
|
|
existing = c.execute("SELECT * FROM oci_configs WHERE id=?", (cid,)).fetchone()
|
|
if not existing: raise HTTPException(404)
|
|
if u["role"] != "admin" and existing["user_id"] != u["id"]: raise HTTPException(403)
|
|
# Keep existing encrypted values if not provided (empty = keep current)
|
|
tenancy_ocid = tenancy_ocid or _safe_dec(existing["tenancy_ocid"])
|
|
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_auth = existing["auth_type"] or "api_key"
|
|
except (IndexError, KeyError):
|
|
existing_auth = "api_key"
|
|
auth_type = auth_type or existing_auth
|
|
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:
|
|
key_bytes = await private_key.read()
|
|
kp.write_bytes(key_bytes); kp.chmod(0o600)
|
|
key_text = key_bytes.decode("utf-8", errors="ignore")
|
|
if auth_type == "api_key" and ("ENCRYPTED" in key_text or "Proc-Type: 4,ENCRYPTED" in key_text) and not key_passphrase:
|
|
raise HTTPException(400, "A chave privada está criptografada (ENCRYPTED). Informe a Key Passphrase.")
|
|
if public_key and public_key.filename:
|
|
(cdir / "oci_api_key_public.pem").write_bytes(await public_key.read())
|
|
# Update session token file if provided
|
|
if security_token.strip():
|
|
token_file = cdir / "token"
|
|
token_file.write_text(security_token.strip()); token_file.chmod(0o600)
|
|
cfg_file = cdir / "config"
|
|
if auth_type == "session_token":
|
|
token_path = cdir / "token"
|
|
cfg_content = f"[DEFAULT]\ntenancy={tenancy_ocid}\nregion={region}\nkey_file={kp}\n"
|
|
if user_ocid and user_ocid != "session_token_user":
|
|
cfg_content = f"[DEFAULT]\nuser={user_ocid}\ntenancy={tenancy_ocid}\nregion={region}\nkey_file={kp}\n"
|
|
cfg_content += f"security_token_file={token_path}\n"
|
|
else:
|
|
cfg_content = (f"[DEFAULT]\nuser={user_ocid}\nfingerprint={fingerprint}\n"
|
|
f"tenancy={tenancy_ocid}\nregion={region}\nkey_file={kp}\n")
|
|
if key_passphrase:
|
|
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=?,ssh_public_key=?,auth_type=? 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, auth_type, cid))
|
|
_audit(u["id"], u["username"], "update_oci_config", cid, f"tenancy={tenancy_name} auth={auth_type}")
|
|
_config_log("oci", cid, tenancy_name, "success", "save", f"Credencial atualizada: {tenancy_name} ({region}) [{auth_type}]", u["id"], u["username"])
|
|
return {"id": cid, "tenancy_name": tenancy_name, "region": region}
|
|
|
|
@router.post("/api/oci/test/{cid}")
|
|
async def test_oci(cid: str, u=Depends(require("admin","user"))):
|
|
_verify_config_access("oci", cid, u)
|
|
cp = OCI_DIR / cid / "config"
|
|
cname = None
|
|
with db() as c:
|
|
row = c.execute("SELECT tenancy_name FROM oci_configs WHERE id=?", (cid,)).fetchone()
|
|
if row: cname = row["tenancy_name"]
|
|
if not cp.exists():
|
|
_config_log("oci", cid, cname, "error", "test", "Config não encontrada", u["id"], u["username"])
|
|
return {"status":"error","message":"Config não encontrada"}
|
|
try:
|
|
loop = asyncio.get_event_loop()
|
|
r = await loop.run_in_executor(None, partial(
|
|
subprocess.run, ["oci","iam","region","list","--config-file",str(cp),"--output","json"],
|
|
capture_output=True, text=True, timeout=30, stdin=subprocess.DEVNULL))
|
|
if r.returncode == 0:
|
|
_config_log("oci", cid, cname, "success", "test", "Conexão OK", u["id"], u["username"])
|
|
return {"status":"success","message":"Conexão OK","data":json.loads(r.stdout)}
|
|
msg = r.stderr[:500]
|
|
if "passphrase" in msg.lower() or "getpass" in msg.lower():
|
|
msg = "A chave privada requer passphrase. Recadastre a credencial informando a Key Passphrase."
|
|
_config_log("oci", cid, cname, "error", "test", msg, u["id"], u["username"])
|
|
return {"status":"error","message":msg}
|
|
except FileNotFoundError:
|
|
_config_log("oci", cid, cname, "error", "test", "OCI CLI não disponível no container.", u["id"], u["username"])
|
|
return {"status":"error","message":"OCI CLI não disponível no container."}
|
|
except subprocess.TimeoutExpired:
|
|
_config_log("oci", cid, cname, "error", "test", "Timeout na conexão", u["id"], u["username"])
|
|
return {"status":"error","message":"Timeout na conexão"}
|
|
except Exception as e:
|
|
_config_log("oci", cid, cname, "error", "test", str(e)[:500], u["id"], u["username"])
|
|
return {"status":"error","message":str(e)[:500]}
|
|
|
|
# ── OCI CLI Terminal ─────────────────────────────────────────────────────────
|
|
# OCID resource type → OCI CLI get command mapping
|
|
_OCID_CMD_MAP = {
|
|
"instance": "oci compute instance get --instance-id",
|
|
"image": "oci compute image get --image-id",
|
|
"vcn": "oci network vcn get --vcn-id",
|
|
"subnet": "oci network subnet get --subnet-id",
|
|
"securitylist": "oci network security-list get --security-list-id",
|
|
"routetable": "oci network route-table get --rt-id",
|
|
"internetgateway": "oci network internet-gateway get --ig-id",
|
|
"natgateway": "oci network nat-gateway get --nat-gateway-id",
|
|
"servicegateway": "oci network service-gateway get --service-gateway-id",
|
|
"drg": "oci network drg get --drg-id",
|
|
"drgattachment": "oci network drg-attachment get --drg-attachment-id",
|
|
"localpeeringgateway": "oci network local-peering-gateway get --local-peering-gateway-id",
|
|
"networksecuritygroup": "oci network nsg get --nsg-id",
|
|
"vnic": "oci network vnic get --vnic-id",
|
|
"privateip": "oci network private-ip get --private-ip-id",
|
|
"publicip": "oci network public-ip get --public-ip-id",
|
|
"dhcpoptions": "oci network dhcp-options get --dhcp-id",
|
|
"cpe": "oci network cpe get --cpe-id",
|
|
"ipsecconnection": "oci network ip-sec-connection get --ipsc-id",
|
|
"volume": "oci bv volume get --volume-id",
|
|
"bootvolume": "oci bv boot-volume get --boot-volume-id",
|
|
"volumebackup": "oci bv backup get --volume-backup-id",
|
|
"bootvolumereplica": "oci bv boot-volume-replica get --boot-volume-replica-id",
|
|
"volumegroup": "oci bv volume-group get --volume-group-id",
|
|
"bucket": "oci os bucket get --bucket-name",
|
|
"compartment": "oci iam compartment get --compartment-id",
|
|
"tenancy": "oci iam tenancy get --tenancy-id",
|
|
"user": "oci iam user get --user-id",
|
|
"group": "oci iam group get --group-id",
|
|
"policy": "oci iam policy get --policy-id",
|
|
"dynamicgroup": "oci iam dynamic-group get --dynamic-group-id",
|
|
"identityprovider": "oci iam identity-provider get --identity-provider-id",
|
|
"autonomousdatabase": "oci db autonomous-database get --autonomous-database-id",
|
|
"dbsystem": "oci db system get --db-system-id",
|
|
"database": "oci db database get --database-id",
|
|
"dbhome": "oci db db-home get --db-home-id",
|
|
"dbnode": "oci db node get --db-node-id",
|
|
"mysqldbsystem": "oci mysql db-system get --db-system-id",
|
|
"loadbalancer": "oci lb load-balancer get --load-balancer-id",
|
|
"networkloadbalancer": "oci nlb network-load-balancer get --network-load-balancer-id",
|
|
"certificate": "oci certs-mgmt certificate get --certificate-id",
|
|
"vault": "oci kms management vault get --vault-id",
|
|
"key": "oci kms management key get --key-id",
|
|
"secret": "oci vault secret get --secret-id",
|
|
"fnapp": "oci fn application get --application-id",
|
|
"fnfunc": "oci fn function get --function-id",
|
|
"apigateway": "oci api-gateway gateway get --gateway-id",
|
|
"containerinstance": "oci container-instances container-instance get --container-instance-id",
|
|
"cluster": "oci ce cluster get --cluster-id",
|
|
"nodepool": "oci ce node-pool get --node-pool-id",
|
|
"filesystem": "oci fs file-system get --file-system-id",
|
|
"mounttarget": "oci fs mount-target get --mount-target-id",
|
|
"alarm": "oci monitoring alarm get --alarm-id",
|
|
"loggroup": "oci logging log-group get --log-group-id",
|
|
"log": "oci logging log get --log-id",
|
|
"topic": "oci ons topic get --topic-id",
|
|
"subscription": "oci ons subscription get --subscription-id",
|
|
"stream": "oci streaming stream get --stream-id",
|
|
"streampool": "oci streaming stream-pool get --stream-pool-id",
|
|
"dnszone": "oci dns zone get --zone-name-or-id",
|
|
"networkfirewall": "oci network-firewall network-firewall get --network-firewall-id",
|
|
"networkfirewallpolicy": "oci network-firewall network-firewall-policy get --network-firewall-policy-id",
|
|
"waaspolicy": "oci waas waas-policy get --waas-policy-id",
|
|
"instancepool": "oci compute-management instance-pool get --instance-pool-id",
|
|
"instanceconfiguration": "oci compute-management instance-configuration get --instance-configuration-id",
|
|
"capacityreservation": "oci compute capacity-reservation get --capacity-reservation-id",
|
|
"dedicatedvmhost": "oci compute dedicated-vm-host get --dedicated-vm-host-id",
|
|
}
|
|
|
|
def _resolve_ocid_command(ocid: str) -> str | None:
|
|
"""Parse an OCID and return the corresponding OCI CLI get command."""
|
|
parts = ocid.strip().split(".")
|
|
if len(parts) < 4 or parts[0] != "ocid1":
|
|
return None
|
|
resource_type = parts[1].lower()
|
|
# Extract region from OCID if present (4th part, non-empty for regional resources)
|
|
region = parts[3] if len(parts) > 3 and parts[3] and parts[3] != parts[2] else None
|
|
cmd_template = _OCID_CMD_MAP.get(resource_type)
|
|
if not cmd_template:
|
|
return None
|
|
cmd = f"{cmd_template} {ocid}"
|
|
if region:
|
|
cmd += f" --region {region}"
|
|
return cmd
|
|
|
|
_TERMINAL_BLOCKED_PATTERNS = re.compile(
|
|
r'(rm\s|mv\s|cp\s|chmod\s|chown\s|sudo\s|bash\s|sh\s|curl\s|wget\s|python|pip\s|apt\s|yum\s|>\s|>>\s|\|)',
|
|
re.IGNORECASE
|
|
)
|
|
|
|
@router.post("/api/terminal/execute")
|
|
async def terminal_execute(req: dict, u=Depends(current_user)):
|
|
"""Execute an OCI CLI command using the selected OCI config. Only 'oci' commands allowed."""
|
|
oci_config_id = req.get("oci_config_id", "")
|
|
command = (req.get("command", "") or "").strip()
|
|
if not oci_config_id:
|
|
raise HTTPException(400, "oci_config_id obrigatório")
|
|
if not command:
|
|
raise HTTPException(400, "Comando vazio")
|
|
_verify_config_access("oci", oci_config_id, u)
|
|
# Auto-lookup: OCID pasted directly → resolve to oci get command
|
|
resolved_cmd = None
|
|
if command.startswith("ocid1."):
|
|
resolved_cmd = _resolve_ocid_command(command)
|
|
if resolved_cmd:
|
|
command = resolved_cmd
|
|
else:
|
|
raise HTTPException(400, f"Tipo de recurso não reconhecido no OCID: {command.split('.')[1]}")
|
|
# find <query> → search resources by display name using OCI Search service
|
|
elif command.startswith("find "):
|
|
query = command[5:].strip()
|
|
if not query:
|
|
raise HTTPException(400, "Uso: find <nome-do-recurso> | find %partial% | find 10.0.1.5")
|
|
# Detect IP address → search by IP
|
|
if re.match(r'^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$', query):
|
|
search_query = f"query privateip resources where ipAddress = '{query}'"
|
|
elif '%' in query:
|
|
# Partial match with LIKE
|
|
search_query = f"query all resources where displayName =~ '{query}'"
|
|
else:
|
|
search_query = f"query all resources where displayName = '{query}'"
|
|
resolved_cmd = f"oci search resource structured-search --query-text \"{search_query}\" --limit 20"
|
|
command = resolved_cmd
|
|
# Security: only allow 'oci' commands
|
|
if command == "oci":
|
|
command = "oci --help"
|
|
if not command.startswith("oci "):
|
|
raise HTTPException(400, "Apenas comandos 'oci' são permitidos. Ex: oci iam user list")
|
|
if _TERMINAL_BLOCKED_PATTERNS.search(command):
|
|
raise HTTPException(400, "Comando contém operações não permitidas")
|
|
# Build config path
|
|
cfg_path = OCI_DIR / oci_config_id / "config"
|
|
if not cfg_path.exists():
|
|
raise HTTPException(400, "OCI config file não encontrado. Reconfigure as credenciais.")
|
|
# Execute with timeout
|
|
cmd_args = shlex.split(command) + ["--config-file", str(cfg_path)]
|
|
try:
|
|
loop = asyncio.get_event_loop()
|
|
result = await asyncio.wait_for(
|
|
loop.run_in_executor(_chat_executor, lambda: subprocess.run(
|
|
cmd_args, capture_output=True, text=True, timeout=60, cwd="/tmp",
|
|
env={**os.environ, "OCI_CLI_SUPPRESS_FILE_PERMISSIONS_WARNING": "True", "HOME": "/tmp"}
|
|
)), timeout=65)
|
|
output = result.stdout or ""
|
|
error = result.stderr or ""
|
|
exit_code = result.returncode
|
|
# Save to history
|
|
with db() as c:
|
|
c.execute("INSERT INTO terminal_history (id,user_id,oci_config_id,command,exit_code,output,error) VALUES (?,?,?,?,?,?,?)",
|
|
(str(uuid.uuid4()), u["id"], oci_config_id, command, exit_code, output[:10000], error[:2000]))
|
|
return {
|
|
"exit_code": exit_code,
|
|
"output": output[:50000],
|
|
"error": error[:5000] if exit_code != 0 else "",
|
|
"resolved_cmd": resolved_cmd,
|
|
}
|
|
except asyncio.TimeoutError:
|
|
with db() as c:
|
|
c.execute("INSERT INTO terminal_history (id,user_id,oci_config_id,command,exit_code,error) VALUES (?,?,?,?,?,?)",
|
|
(str(uuid.uuid4()), u["id"], oci_config_id, command, -1, "Timeout: 60s"))
|
|
return {"exit_code": -1, "output": "", "error": "Timeout: comando excedeu 60 segundos"}
|
|
except Exception as e:
|
|
return {"exit_code": -1, "output": "", "error": str(e)[:2000]}
|
|
|
|
@router.get("/api/terminal/history")
|
|
async def terminal_history(oci_config_id: str = Query(""), limit: int = Query(50), u=Depends(current_user)):
|
|
"""Get recent terminal commands for the user."""
|
|
with db() as c:
|
|
if oci_config_id:
|
|
rows = c.execute(
|
|
"SELECT * FROM terminal_history WHERE user_id=? AND oci_config_id=? ORDER BY created_at DESC LIMIT ?",
|
|
(u["id"], oci_config_id, limit)).fetchall()
|
|
else:
|
|
rows = c.execute(
|
|
"SELECT * FROM terminal_history WHERE user_id=? ORDER BY created_at DESC LIMIT ?",
|
|
(u["id"], limit)).fetchall()
|
|
return [dict(r) for r in rows]
|
|
|
|
# OCI CLI autocomplete cache
|
|
_oci_completions_cache: dict = {}
|
|
|
|
def _parse_oci_help(args: list[str]) -> list[str]:
|
|
"""Run oci <args> --help and extract available commands/options."""
|
|
try:
|
|
result = subprocess.run(
|
|
["oci"] + args + ["--help"], capture_output=True, text=True, timeout=10,
|
|
env={**os.environ, "OCI_CLI_SUPPRESS_FILE_PERMISSIONS_WARNING": "True", "HOME": "/tmp"})
|
|
text = result.stdout + result.stderr
|
|
commands = []
|
|
in_commands = False
|
|
for line in text.splitlines():
|
|
stripped = line.strip()
|
|
# Detect transition into command/service listing
|
|
if not in_commands:
|
|
if re.match(r'^(Commands|Options|Core|Others|Networking|Database|Identity|Storage|Compute|Security)', stripped):
|
|
in_commands = True
|
|
continue
|
|
if not stripped:
|
|
continue
|
|
# Section headers (single word or capitalized phrase not indented) — skip
|
|
if not line.startswith(" "):
|
|
continue
|
|
# Capture " command-name Description text" pattern
|
|
m = re.match(r'^ ([a-z][a-z0-9\-_]+)\s', line)
|
|
if m and len(m.group(1)) > 1:
|
|
commands.append(m.group(1))
|
|
continue
|
|
# Capture 2-space indent commands (sub-command help)
|
|
m = re.match(r'^ ([a-z][a-z0-9\-_]+)\s', line)
|
|
if m and len(m.group(1)) > 2:
|
|
commands.append(m.group(1))
|
|
continue
|
|
# Capture --options
|
|
if stripped.startswith("--"):
|
|
opt = stripped.split()[0].rstrip(",")
|
|
if opt.startswith("--"): commands.append(opt)
|
|
return sorted(set(commands))
|
|
except Exception:
|
|
return []
|
|
|
|
@router.get("/api/terminal/completions")
|
|
async def terminal_completions(prefix: str = Query(""), u=Depends(current_user)):
|
|
"""Get OCI CLI autocomplete suggestions for a partial command."""
|
|
parts = prefix.strip().split()
|
|
if not parts or parts[0] != "oci":
|
|
return {"suggestions": []}
|
|
# Build cache key from command path (without final partial word)
|
|
# e.g. "oci iam us" → lookup completions for ["oci", "iam"], filter by "us"
|
|
if prefix.endswith(" "):
|
|
cmd_parts = parts[1:]
|
|
filter_prefix = ""
|
|
else:
|
|
cmd_parts = parts[1:-1]
|
|
filter_prefix = parts[-1] if len(parts) > 1 else ""
|
|
cache_key = " ".join(cmd_parts)
|
|
if cache_key not in _oci_completions_cache:
|
|
loop = asyncio.get_event_loop()
|
|
completions = await loop.run_in_executor(_chat_executor, lambda: _parse_oci_help(cmd_parts))
|
|
_oci_completions_cache[cache_key] = completions
|
|
suggestions = _oci_completions_cache[cache_key]
|
|
if filter_prefix:
|
|
suggestions = [s for s in suggestions if s.startswith(filter_prefix)]
|
|
# Commands first, then options
|
|
cmds = [s for s in suggestions if not s.startswith("--")]
|
|
opts = [s for s in suggestions if s.startswith("--")]
|
|
return {"suggestions": (cmds + opts)[:50]}
|