fix(CI3): filtrer get_build_status på CLOUD_BUILD_TRIGGER_ID
This commit is contained in:
parent
791a8e2b42
commit
e4c79c999d
|
|
@ -1,13 +1,9 @@
|
|||
# agents/mcp_server/server.py
|
||||
# OPAX-MCP — Vauco sin egen MCP-server / CI-kanal mot GCP
|
||||
# CI1a: push_static, get_build_status, deploy_service
|
||||
# Deploy: Cloud Run `opax-mcp` (separat fra osvauco-agent)
|
||||
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Depends, Header
|
||||
from fastapi.responses import JSONResponse
|
||||
|
|
@ -20,163 +16,99 @@ PROJECT_ID = os.environ.get("GOOGLE_CLOUD_PROJECT", "propane-will-491900
|
|||
REGION = os.environ.get("REGION", "us-central1")
|
||||
STATIC_BUCKET = os.environ.get("STATIC_BUCKET", "opax-vauco-static")
|
||||
CLOUD_RUN_SERVICE = os.environ.get("CLOUD_RUN_SERVICE", "osvauco-agent")
|
||||
MCP_SECRET = os.environ.get("MCP_SECRET", "") # Secret Manager: mcp-server-key
|
||||
MCP_SECRET = os.environ.get("MCP_SECRET", "")
|
||||
BUILD_TRIGGER_ID = os.environ.get("CLOUD_BUILD_TRIGGER_ID", "38423976-91ff-4ff4-859e-1f262344c609")
|
||||
|
||||
|
||||
# ── Auth ───────────────────────────────────────────────────────────────────────────
|
||||
def verify_token(x_mcp_key: str = Header(default="")):
|
||||
"""Enkel API-nøkkel auth. Byttes ut med IAP når tjenesten er på Cloud Run bak LB."""
|
||||
if MCP_SECRET and x_mcp_key != MCP_SECRET:
|
||||
raise HTTPException(status_code=401, detail="Ugyldig MCP-nøkkel")
|
||||
return True
|
||||
|
||||
|
||||
# ── Models ──────────────────────────────────────────────────────────────────────────
|
||||
class PushStaticRequest(BaseModel):
|
||||
file_path: str # Lokal sti relativt til repo-rot, f.eks. "static/jason.html"
|
||||
content: str # Fil-innhold (tekst/HTML)
|
||||
file_path: str
|
||||
content: str
|
||||
content_type: str = "text/html; charset=utf-8"
|
||||
cache_control: str = "public, max-age=300"
|
||||
|
||||
class DeployServiceRequest(BaseModel):
|
||||
branch: str = "main" # Branch å bygge fra
|
||||
trigger_id: str = "" # Cloud Build trigger ID (tom = hent fra env)
|
||||
branch: str = "main"
|
||||
trigger_id: str = ""
|
||||
substitutions: dict = {}
|
||||
|
||||
class ToolCallRequest(BaseModel):
|
||||
"""MCP-standard tool call envelope."""
|
||||
tool: str
|
||||
params: dict = {}
|
||||
|
||||
|
||||
# ── Lifespan ──────────────────────────────────────────────────────────────────────────────
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
logger.info(f"OPAX-MCP starting | project={PROJECT_ID} | bucket={STATIC_BUCKET}")
|
||||
logger.info(f"OPAX-MCP starting | project={PROJECT_ID} | trigger={BUILD_TRIGGER_ID}")
|
||||
yield
|
||||
logger.info("OPAX-MCP shutting down")
|
||||
|
||||
app = FastAPI(title="OPAX-MCP", version="1.0.0", lifespan=lifespan)
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="OPAX-MCP",
|
||||
description="Vauco sin egen MCP-server — CI/CD-kanal mot GCP",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
|
||||
# ── Health ─────────────────────────────────────────────────────────────────────────────
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"status": "ok", "service": "opax-mcp", "project": PROJECT_ID}
|
||||
|
||||
|
||||
# ── MCP Tool Registry ──────────────────────────────────────────────────────────────────
|
||||
@app.get("/mcp/tools")
|
||||
async def list_tools(_: bool = Depends(verify_token)):
|
||||
"""MCP tool manifest — returnerer alle tilgjengelige verktøy med schema."""
|
||||
return {
|
||||
"tools": [
|
||||
{
|
||||
"name": "push_static",
|
||||
"description": "Last opp en statisk fil (HTML/CSS/JS) direkte til GCS-bucketen som serverer opax.vauco.no. Erstatter manuell gsutil-upload.",
|
||||
"parameters": {
|
||||
"file_path": {"type": "string", "description": "Relativ sti, f.eks. static/jason.html"},
|
||||
"content": {"type": "string", "description": "Fil-innhold som tekst"},
|
||||
"content_type": {"type": "string", "default": "text/html; charset=utf-8"},
|
||||
"cache_control": {"type": "string", "default": "public, max-age=300"},
|
||||
},
|
||||
"required": ["file_path", "content"],
|
||||
},
|
||||
{
|
||||
"name": "get_build_status",
|
||||
"description": "Hent status på siste Cloud Build-kjøring for prosjektet. Returnerer status, commit, varighet og logg-URL.",
|
||||
"parameters": {},
|
||||
"required": [],
|
||||
},
|
||||
{
|
||||
"name": "deploy_service",
|
||||
"description": "Trigger Cloud Build for å bygge og deploye osvauco-agent til Cloud Run. Kan spesifisere branch og substitutions.",
|
||||
"parameters": {
|
||||
"branch": {"type": "string", "default": "main"},
|
||||
"trigger_id": {"type": "string", "description": "Cloud Build trigger ID (valgfri — bruker env-default hvis tom)"},
|
||||
"substitutions": {"type": "object", "default": {}},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
{
|
||||
"name": "get_logs",
|
||||
"description": "Hent siste Cloud Run-logger for osvauco-agent.",
|
||||
"parameters": {
|
||||
"lines": {"type": "integer", "default": 50, "description": "Antall logglinjer å returnere"},
|
||||
"severity": {"type": "string", "default": "DEFAULT", "description": "ERROR | WARNING | INFO | DEFAULT"},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
{"name": "push_static", "description": "Last opp statisk fil til GCS (opax.vauco.no)", "required": ["file_path", "content"]},
|
||||
{"name": "get_build_status", "description": "Hent status på siste Cloud Build for osvauco-agent-main-trigger", "required": []},
|
||||
{"name": "deploy_service", "description": "Trigger ny Cloud Build deploy (HITL)", "required": []},
|
||||
{"name": "get_logs", "description": "Hent Cloud Run-logger for osvauco-agent", "required": []},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
# ── Tool: push_static ─────────────────────────────────────────────────────────────────────
|
||||
@app.post("/tools/push_static")
|
||||
async def push_static(req: PushStaticRequest, _: bool = Depends(verify_token)):
|
||||
"""
|
||||
Last opp statisk fil til GCS.
|
||||
Fil-sti mappes direkte: static/jason.html → gs://{STATIC_BUCKET}/static/jason.html
|
||||
"""
|
||||
try:
|
||||
from google.cloud import storage
|
||||
client = storage.Client(project=PROJECT_ID)
|
||||
bucket = client.bucket(STATIC_BUCKET)
|
||||
blob = bucket.blob(req.file_path)
|
||||
blob.cache_control = req.cache_control
|
||||
blob.upload_from_string(
|
||||
req.content.encode("utf-8"),
|
||||
content_type=req.content_type,
|
||||
)
|
||||
public_url = f"https://storage.googleapis.com/{STATIC_BUCKET}/{req.file_path}"
|
||||
logger.info(f"[push_static] Uploaded {req.file_path} ({len(req.content)} bytes)")
|
||||
return {
|
||||
"ok": True,
|
||||
"file_path": req.file_path,
|
||||
"bucket": STATIC_BUCKET,
|
||||
"bytes": len(req.content.encode("utf-8")),
|
||||
"url": public_url,
|
||||
}
|
||||
blob.upload_from_string(req.content.encode("utf-8"), content_type=req.content_type)
|
||||
logger.info(f"[push_static] {req.file_path} ({len(req.content)} bytes)")
|
||||
return {"ok": True, "file_path": req.file_path, "bucket": STATIC_BUCKET,
|
||||
"bytes": len(req.content.encode("utf-8")),
|
||||
"url": f"https://storage.googleapis.com/{STATIC_BUCKET}/{req.file_path}"}
|
||||
except Exception as e:
|
||||
logger.error(f"[push_static] Failed: {e}", exc_info=True)
|
||||
logger.error(f"[push_static] {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ── Tool: get_build_status ─────────────────────────────────────────────────────────────────────
|
||||
@app.get("/tools/get_build_status")
|
||||
async def get_build_status(_: bool = Depends(verify_token)):
|
||||
"""
|
||||
Hent siste Cloud Build-kjøring fra us-central1.
|
||||
Henter siste build fra osvauco-agent-main-trigger (us-central1).
|
||||
Filtrerer på trigger_id for å unngå global/andre builds.
|
||||
"""
|
||||
try:
|
||||
from google.cloud.devtools import cloudbuild_v1
|
||||
from google.api_core.client_options import ClientOptions
|
||||
client = cloudbuild_v1.CloudBuildClient(
|
||||
client_options=ClientOptions(
|
||||
api_endpoint=f"{REGION}-cloudbuild.googleapis.com"
|
||||
)
|
||||
)
|
||||
client = cloudbuild_v1.CloudBuildClient()
|
||||
request = cloudbuild_v1.ListBuildsRequest(
|
||||
project_id=PROJECT_ID,
|
||||
filter='trigger_id!=""',
|
||||
page_size=5,
|
||||
filter=f'trigger_id="{BUILD_TRIGGER_ID}"',
|
||||
page_size=1,
|
||||
)
|
||||
builds = list(client.list_builds(request=request))
|
||||
if not builds:
|
||||
return {"status": "unknown", "message": "Ingen builds funnet"}
|
||||
return {"status": "unknown", "message": f"Ingen builds for trigger {BUILD_TRIGGER_ID}"}
|
||||
b = builds[0]
|
||||
status_map = {1:"queued",2:"working",3:"success",4:"failure",
|
||||
5:"internal_error",6:"timeout",7:"cancelled"}
|
||||
status_str = status_map.get(int(b.status), "unknown")
|
||||
import time
|
||||
duration_s = None
|
||||
if b.start_time and b.finish_time:
|
||||
if b.finish_time and b.start_time:
|
||||
duration_s = int(b.finish_time.seconds - b.start_time.seconds)
|
||||
elif b.start_time:
|
||||
duration_s = int(time.time() - b.start_time.seconds)
|
||||
|
|
@ -187,123 +119,66 @@ async def get_build_status(_: bool = Depends(verify_token)):
|
|||
"commit": (b.substitutions or {}).get("SHORT_SHA", ""),
|
||||
"duration_s": duration_s,
|
||||
"log_url": b.log_url or "",
|
||||
"region": REGION,
|
||||
"trigger_id": BUILD_TRIGGER_ID,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"[get_build_status] Failed: {e}", exc_info=True)
|
||||
logger.error(f"[get_build_status] {e}", exc_info=True)
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
|
||||
# ── Tool: deploy_service ──────────────────────────────────────────────────────────────────────
|
||||
@app.post("/tools/deploy_service")
|
||||
async def deploy_service(req: DeployServiceRequest, _: bool = Depends(verify_token)):
|
||||
"""
|
||||
Trigger Cloud Build for å bygge + deploye osvauco-agent.
|
||||
HITL-gate: logger kallet og returnerer build-ID for oppfølging.
|
||||
"""
|
||||
try:
|
||||
from google.cloud.devtools import cloudbuild_v1
|
||||
from google.api_core.client_options import ClientOptions
|
||||
client = cloudbuild_v1.CloudBuildClient(
|
||||
client_options=ClientOptions(
|
||||
api_endpoint=f"{REGION}-cloudbuild.googleapis.com"
|
||||
)
|
||||
)
|
||||
|
||||
trigger_id = req.trigger_id or os.environ.get("CLOUD_BUILD_TRIGGER_ID", "")
|
||||
if not trigger_id:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="trigger_id må settes i request eller CLOUD_BUILD_TRIGGER_ID env"
|
||||
)
|
||||
|
||||
client = cloudbuild_v1.CloudBuildClient()
|
||||
trigger_id = req.trigger_id or BUILD_TRIGGER_ID
|
||||
subs = {"BRANCH_NAME": req.branch, **req.substitutions}
|
||||
response = client.run_build_trigger(
|
||||
project_id=PROJECT_ID,
|
||||
trigger_id=trigger_id,
|
||||
source=cloudbuild_v1.RepoSource(
|
||||
branch_name=req.branch,
|
||||
substitutions=subs,
|
||||
),
|
||||
source=cloudbuild_v1.RepoSource(branch_name=req.branch, substitutions=subs),
|
||||
)
|
||||
build_id = response.metadata.build.id if hasattr(response, 'metadata') else "ukjent"
|
||||
logger.info(f"[deploy_service] Triggered build {build_id} from branch {req.branch}")
|
||||
return {
|
||||
"ok": True,
|
||||
"build_id": build_id,
|
||||
"branch": req.branch,
|
||||
"trigger_id": trigger_id,
|
||||
"message": "Build triggered. Kall GET /tools/get_build_status for status.",
|
||||
}
|
||||
logger.info(f"[deploy_service] build {build_id} branch {req.branch}")
|
||||
return {"ok": True, "build_id": build_id, "branch": req.branch, "trigger_id": trigger_id}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"[deploy_service] Failed: {e}", exc_info=True)
|
||||
logger.error(f"[deploy_service] {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ── Tool: get_logs ───────────────────────────────────────────────────────────────────────────────
|
||||
@app.get("/tools/get_logs")
|
||||
async def get_logs(
|
||||
lines: int = 50,
|
||||
severity: str = "DEFAULT",
|
||||
_: bool = Depends(verify_token)
|
||||
):
|
||||
"""
|
||||
Hent siste Cloud Run-logger for osvauco-agent.
|
||||
Returnerer logglinjer som liste.
|
||||
"""
|
||||
async def get_logs(lines: int = 50, severity: str = "DEFAULT", _: bool = Depends(verify_token)):
|
||||
try:
|
||||
from google.cloud import logging as gcloud_logging
|
||||
client = gcloud_logging.Client(project=PROJECT_ID)
|
||||
filter_str = (
|
||||
f'resource.type="cloud_run_revision" '
|
||||
f'resource.labels.service_name="{CLOUD_RUN_SERVICE}"'
|
||||
)
|
||||
filter_str = (f'resource.type="cloud_run_revision" '
|
||||
f'resource.labels.service_name="{CLOUD_RUN_SERVICE}"')
|
||||
if severity and severity != "DEFAULT":
|
||||
filter_str += f' severity>={severity}'
|
||||
entries = list(client.list_entries(
|
||||
filter_=filter_str,
|
||||
order_by=gcloud_logging.DESCENDING,
|
||||
page_size=lines,
|
||||
))
|
||||
filter_=filter_str, order_by=gcloud_logging.DESCENDING, page_size=lines))
|
||||
log_lines = []
|
||||
for entry in entries:
|
||||
payload = entry.payload
|
||||
if isinstance(payload, dict):
|
||||
text = payload.get("message", str(payload))
|
||||
else:
|
||||
text = str(payload)
|
||||
log_lines.append({
|
||||
"timestamp": str(entry.timestamp),
|
||||
"severity": str(entry.severity),
|
||||
"text": text[:400],
|
||||
})
|
||||
text = payload.get("message", str(payload)) if isinstance(payload, dict) else str(payload)
|
||||
log_lines.append({"timestamp": str(entry.timestamp), "severity": str(entry.severity), "text": text[:400]})
|
||||
return {"service": CLOUD_RUN_SERVICE, "count": len(log_lines), "logs": log_lines}
|
||||
except Exception as e:
|
||||
logger.error(f"[get_logs] Failed: {e}", exc_info=True)
|
||||
logger.error(f"[get_logs] {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ── MCP unified tool call endpoint ────────────────────────────────────────────────────────────
|
||||
@app.post("/mcp/call")
|
||||
async def mcp_call(req: ToolCallRequest, _: bool = Depends(verify_token)):
|
||||
tool = req.tool
|
||||
params = req.params
|
||||
|
||||
if tool == "push_static":
|
||||
r = PushStaticRequest(**params)
|
||||
return await push_static(r, True)
|
||||
elif tool == "get_build_status":
|
||||
if req.tool == "push_static":
|
||||
return await push_static(PushStaticRequest(**req.params), True)
|
||||
elif req.tool == "get_build_status":
|
||||
return await get_build_status(True)
|
||||
elif tool == "deploy_service":
|
||||
r = DeployServiceRequest(**params)
|
||||
return await deploy_service(r, True)
|
||||
elif tool == "get_logs":
|
||||
return await get_logs(
|
||||
lines=params.get("lines", 50),
|
||||
severity=params.get("severity", "DEFAULT"),
|
||||
_=True
|
||||
)
|
||||
elif req.tool == "deploy_service":
|
||||
return await deploy_service(DeployServiceRequest(**req.params), True)
|
||||
elif req.tool == "get_logs":
|
||||
return await get_logs(lines=req.params.get("lines", 50), severity=req.params.get("severity", "DEFAULT"), _=True)
|
||||
else:
|
||||
raise HTTPException(status_code=404, detail=f"Ukjent tool: {tool}. Kall GET /mcp/tools for liste.")
|
||||
raise HTTPException(status_code=404, detail=f"Ukjent tool: {req.tool}")
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user