Files
A-Team-Security-Infra-Agent…/backend/routes/oci_config.py
nogueiraguh 1135e9d6a9 refactor: decompose monolith — app.py 10,600→143 lines, 30+ modules
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.
2026-04-06 15:20:10 -03:00

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]}