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. ✅
|
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.
|
CG4-credits: GET /billing/credits — kreditt-saldo, burn rate og tom-dato.
|
||||||
Krever GOOGLE_CREDIT_TOTAL_USD i Cloud Run env for runway-beregning.
|
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
|
import os
|
||||||
|
|
@ -194,32 +196,156 @@ async def build_status():
|
||||||
return JSONResponse({"status": "error", "message": str(e)}, status_code=200)
|
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 ───────────────
|
# ── OQ-29: GET /notify/channels — konfigurerte delivery-kanaler ───────────────
|
||||||
@app.get("/notify/channels")
|
@app.get("/notify/channels")
|
||||||
async def 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 {
|
return {
|
||||||
"channels": [
|
"channels": [
|
||||||
{
|
{"type": "email", "configured": bool(os.getenv("SENDGRID_API_KEY")), "target": os.getenv("NOTIFY_EMAIL_TO", None)},
|
||||||
"type": "email",
|
{"type": "sms", "configured": bool(os.getenv("TWILIO_ACCOUNT_SID")), "target": os.getenv("TWILIO_FROM_NUMBER", None)},
|
||||||
"configured": bool(os.getenv("SENDGRID_API_KEY")),
|
{"type": "webhook", "configured": bool(os.getenv("NOTIFY_WEBHOOK_URL")), "url": os.getenv("NOTIFY_WEBHOOK_URL", None)},
|
||||||
"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 ────────────────────────────────────────
|
# ── CG4-credits: GET /billing/credits ────────────────────────────────────────
|
||||||
async def authenticated_billing_credits(request: Request, days: int = 90):
|
async def authenticated_billing_credits(request: Request, days: int = 90):
|
||||||
"""
|
|
||||||
CG4-credits — Kreditt-saldo, burn rate og estimert tom-dato.
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
return JSONResponse(BillingAgent().get_credits_status(days=days))
|
return JSONResponse(BillingAgent().get_credits_status(days=days))
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user