feat(terminal+vm): POST /terminal/exec whitelist + GET /vm/ssh-key
This commit is contained in:
parent
beb466eab0
commit
c7e7b28b68
171
main.py
171
main.py
|
|
@ -15,6 +15,8 @@ OQ-29: GET /notify/channels — list konfigurerte delivery-kanaler (env-var-base
|
|||
OQ-31: GET /opax/build-status — ekte Cloud Build-status via REST API. ✅
|
||||
CG4-credits: GET /billing/credits — kreditt-saldo, burn rate og tom-dato.
|
||||
Krever GOOGLE_CREDIT_TOTAL_USD i Cloud Run env for runway-beregning.
|
||||
TERMINAL: POST /terminal/exec — whitelisted kommandoer: health, billing, build, logs, help. ✅
|
||||
VM: GET /vm/ssh-key — henter public key fra Compute Engine metadata. ✅
|
||||
"""
|
||||
|
||||
import os
|
||||
|
|
@ -194,32 +196,156 @@ async def build_status():
|
|||
return JSONResponse({"status": "error", "message": str(e)}, status_code=200)
|
||||
|
||||
|
||||
# ── TERMINAL: POST /terminal/exec ─────────────────────────────────────────────
|
||||
TERMINAL_WHITELIST = {"health", "billing", "build", "logs", "help"}
|
||||
|
||||
class TerminalExecRequest(BaseModel):
|
||||
cmd: str
|
||||
|
||||
@app.post("/terminal/exec")
|
||||
async def terminal_exec(req: TerminalExecRequest):
|
||||
"""
|
||||
Whitelisted terminal-kommandoer:
|
||||
health → GET /health internt
|
||||
billing → GET /billing/summary
|
||||
build → GET /opax/build-status
|
||||
logs → siste 20 linjer fra Cloud Logging
|
||||
help → returner kommandoliste
|
||||
"""
|
||||
cmd = req.cmd.strip().lower().split()[0] if req.cmd.strip() else ""
|
||||
if cmd not in TERMINAL_WHITELIST:
|
||||
return JSONResponse(
|
||||
{"output": f"Ukjent kommando: '{req.cmd}'. Lov: {', '.join(sorted(TERMINAL_WHITELIST))}", "exit_code": 1}
|
||||
)
|
||||
|
||||
try:
|
||||
if cmd == "help":
|
||||
lines = [
|
||||
"Tilgjengelige kommandoer:",
|
||||
" health — sjekk /health",
|
||||
" billing — hent billing summary",
|
||||
" build — hent siste build-status",
|
||||
" logs — siste 20 linjer fra Cloud Logging",
|
||||
" help — vis denne listen",
|
||||
]
|
||||
return {"output": "\n".join(lines), "exit_code": 0}
|
||||
|
||||
if cmd == "health":
|
||||
return {"output": json.dumps({"status": "ok"}, ensure_ascii=False), "exit_code": 0}
|
||||
|
||||
if cmd == "billing":
|
||||
try:
|
||||
summary = BillingAgent().get_summary()
|
||||
return {"output": json.dumps(summary, ensure_ascii=False, indent=2, default=str), "exit_code": 0}
|
||||
except Exception as e:
|
||||
return {"output": f"billing feilet: {e}", "exit_code": 1}
|
||||
|
||||
if cmd == "build":
|
||||
try:
|
||||
creds, project = google.auth.default()
|
||||
creds.refresh(google.auth.transport.requests.Request())
|
||||
url = f"https://cloudbuild.googleapis.com/v1/projects/{project}/builds?pageSize=5"
|
||||
r = httpx.get(url, headers={"Authorization": f"Bearer {creds.token}"}, timeout=10)
|
||||
builds = r.json().get("builds", [])
|
||||
result = [{"id": b["id"], "status": b["status"],
|
||||
"branch": b.get("substitutions", {}).get("BRANCH_NAME", ""),
|
||||
"createTime": b["createTime"]} for b in builds]
|
||||
return {"output": json.dumps(result, ensure_ascii=False, indent=2, default=str), "exit_code": 0}
|
||||
except Exception as e:
|
||||
return {"output": f"build feilet: {e}", "exit_code": 1}
|
||||
|
||||
if cmd == "logs":
|
||||
try:
|
||||
creds, project = google.auth.default()
|
||||
creds.refresh(google.auth.transport.requests.Request())
|
||||
log_filter = (
|
||||
'resource.type="cloud_run_revision" '
|
||||
'resource.labels.service_name="osvauco-agent" '
|
||||
'severity>=DEFAULT'
|
||||
)
|
||||
body = {
|
||||
"resourceNames": [f"projects/{project}"],
|
||||
"filter": log_filter,
|
||||
"orderBy": "timestamp desc",
|
||||
"pageSize": 20,
|
||||
}
|
||||
r = httpx.post(
|
||||
"https://logging.googleapis.com/v2/entries:list",
|
||||
headers={"Authorization": f"Bearer {creds.token}"},
|
||||
json=body,
|
||||
timeout=10,
|
||||
)
|
||||
entries = r.json().get("entries", [])
|
||||
if not entries:
|
||||
return {"output": "(ingen logglinjer funnet)", "exit_code": 0}
|
||||
lines = []
|
||||
for e in reversed(entries):
|
||||
ts = e.get("timestamp", "")[:19].replace("T", " ")
|
||||
msg = e.get("textPayload") or json.dumps(e.get("jsonPayload", {}), ensure_ascii=False)
|
||||
sev = e.get("severity", "")
|
||||
lines.append(f"[{ts}] {sev:<8} {msg}")
|
||||
return {"output": "\n".join(lines), "exit_code": 0}
|
||||
except Exception as e:
|
||||
return {"output": f"logs feilet: {e}", "exit_code": 1}
|
||||
|
||||
except Exception as e:
|
||||
return {"output": f"Intern feil: {e}", "exit_code": 1}
|
||||
|
||||
return {"output": "", "exit_code": 0}
|
||||
|
||||
|
||||
# ── VM: GET /vm/ssh-key ────────────────────────────────────────────────────────
|
||||
@app.get("/vm/ssh-key")
|
||||
async def vm_ssh_key(instance: str = "osvauco-dev-vm"):
|
||||
"""
|
||||
Henter public SSH-nøkkel fra Compute Engine instance metadata.
|
||||
Bruker google.auth ADC — krever compute.instances.get på service account.
|
||||
"""
|
||||
try:
|
||||
creds, project = google.auth.default()
|
||||
creds.refresh(google.auth.transport.requests.Request())
|
||||
zone = "us-central1-b"
|
||||
url = (
|
||||
f"https://compute.googleapis.com/compute/v1/projects/{project}"
|
||||
f"/zones/{zone}/instances/{instance}"
|
||||
)
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
r = await client.get(url, headers={"Authorization": f"Bearer {creds.token}"})
|
||||
if r.status_code == 404:
|
||||
raise HTTPException(status_code=404, detail=f"Instans ikke funnet: {instance}")
|
||||
if r.status_code != 200:
|
||||
raise HTTPException(status_code=r.status_code, detail=f"Compute API feil: {r.text[:200]}")
|
||||
|
||||
data = r.json()
|
||||
# SSH-nøkler ligger i metadata items med key "ssh-keys"
|
||||
metadata_items = data.get("metadata", {}).get("items", [])
|
||||
ssh_keys_raw = next(
|
||||
(item["value"] for item in metadata_items if item["key"] == "ssh-keys"),
|
||||
None
|
||||
)
|
||||
if not ssh_keys_raw:
|
||||
return {"public_key": None, "instance": instance, "detail": "Ingen SSH-nøkkel funnet i metadata"}
|
||||
|
||||
# Format: "user:ssh-rsa AAAA..." — returner kun public key-delen
|
||||
public_key = ssh_keys_raw.strip()
|
||||
if ":" in public_key:
|
||||
public_key = public_key.split(":", 1)[1].strip()
|
||||
|
||||
return {"public_key": public_key, "instance": instance}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"vm/ssh-key feilet: {e}")
|
||||
|
||||
|
||||
# ── OQ-29: GET /notify/channels — konfigurerte delivery-kanaler ───────────────
|
||||
@app.get("/notify/channels")
|
||||
async def get_notify_channels():
|
||||
"""
|
||||
OQ-29 ✅ — Returnerer hvilke varslings-kanaler som er konfigurert
|
||||
basert på env-vars i Cloud Run. IAP-beskyttet (ingen exempt).
|
||||
|
||||
Test: curl http://localhost:8080/notify/channels | python3 -m json.tool
|
||||
"""
|
||||
return {
|
||||
"channels": [
|
||||
{
|
||||
"type": "email",
|
||||
"configured": bool(os.getenv("SENDGRID_API_KEY")),
|
||||
"target": os.getenv("NOTIFY_EMAIL_TO", None),
|
||||
},
|
||||
{
|
||||
"type": "sms",
|
||||
"configured": bool(os.getenv("TWILIO_ACCOUNT_SID")),
|
||||
"target": os.getenv("TWILIO_FROM_NUMBER", None),
|
||||
},
|
||||
{
|
||||
"type": "webhook",
|
||||
"configured": bool(os.getenv("NOTIFY_WEBHOOK_URL")),
|
||||
"url": os.getenv("NOTIFY_WEBHOOK_URL", None),
|
||||
},
|
||||
{"type": "email", "configured": bool(os.getenv("SENDGRID_API_KEY")), "target": os.getenv("NOTIFY_EMAIL_TO", None)},
|
||||
{"type": "sms", "configured": bool(os.getenv("TWILIO_ACCOUNT_SID")), "target": os.getenv("TWILIO_FROM_NUMBER", None)},
|
||||
{"type": "webhook", "configured": bool(os.getenv("NOTIFY_WEBHOOK_URL")), "url": os.getenv("NOTIFY_WEBHOOK_URL", None)},
|
||||
]
|
||||
}
|
||||
|
||||
|
|
@ -630,9 +756,6 @@ app.add_api_route("/billing/anomalies", endpoint=require_auth(authenticated_bill
|
|||
|
||||
# ── CG4-credits: GET /billing/credits ────────────────────────────────────────
|
||||
async def authenticated_billing_credits(request: Request, days: int = 90):
|
||||
"""
|
||||
CG4-credits — Kreditt-saldo, burn rate og estimert tom-dato.
|
||||
"""
|
||||
try:
|
||||
return JSONResponse(BillingAgent().get_credits_status(days=days))
|
||||
except Exception as exc:
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user