fix(CI3): filtrer get_build_status på CLOUD_BUILD_TRIGGER_ID

This commit is contained in:
chrischristiansen-glitch 2026-06-10 04:07:36 +02:00
parent 791a8e2b42
commit e4c79c999d

View File

@ -1,13 +1,9 @@
# agents/mcp_server/server.py # agents/mcp_server/server.py
# OPAX-MCP — Vauco sin egen MCP-server / CI-kanal mot GCP # 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 os
import json
import logging import logging
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from typing import Any
from fastapi import FastAPI, HTTPException, Depends, Header from fastapi import FastAPI, HTTPException, Depends, Header
from fastapi.responses import JSONResponse 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") REGION = os.environ.get("REGION", "us-central1")
STATIC_BUCKET = os.environ.get("STATIC_BUCKET", "opax-vauco-static") STATIC_BUCKET = os.environ.get("STATIC_BUCKET", "opax-vauco-static")
CLOUD_RUN_SERVICE = os.environ.get("CLOUD_RUN_SERVICE", "osvauco-agent") 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="")): 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: if MCP_SECRET and x_mcp_key != MCP_SECRET:
raise HTTPException(status_code=401, detail="Ugyldig MCP-nøkkel") raise HTTPException(status_code=401, detail="Ugyldig MCP-nøkkel")
return True return True
# ── Models ──────────────────────────────────────────────────────────────────────────
class PushStaticRequest(BaseModel): class PushStaticRequest(BaseModel):
file_path: str # Lokal sti relativt til repo-rot, f.eks. "static/jason.html" file_path: str
content: str # Fil-innhold (tekst/HTML) content: str
content_type: str = "text/html; charset=utf-8" content_type: str = "text/html; charset=utf-8"
cache_control: str = "public, max-age=300" cache_control: str = "public, max-age=300"
class DeployServiceRequest(BaseModel): class DeployServiceRequest(BaseModel):
branch: str = "main" # Branch å bygge fra branch: str = "main"
trigger_id: str = "" # Cloud Build trigger ID (tom = hent fra env) trigger_id: str = ""
substitutions: dict = {} substitutions: dict = {}
class ToolCallRequest(BaseModel): class ToolCallRequest(BaseModel):
"""MCP-standard tool call envelope."""
tool: str tool: str
params: dict = {} params: dict = {}
# ── Lifespan ──────────────────────────────────────────────────────────────────────────────
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): 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 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") @app.get("/health")
async def health(): async def health():
return {"status": "ok", "service": "opax-mcp", "project": PROJECT_ID} return {"status": "ok", "service": "opax-mcp", "project": PROJECT_ID}
# ── MCP Tool Registry ──────────────────────────────────────────────────────────────────
@app.get("/mcp/tools") @app.get("/mcp/tools")
async def list_tools(_: bool = Depends(verify_token)): async def list_tools(_: bool = Depends(verify_token)):
"""MCP tool manifest — returnerer alle tilgjengelige verktøy med schema."""
return { return {
"tools": [ "tools": [
{ {"name": "push_static", "description": "Last opp statisk fil til GCS (opax.vauco.no)", "required": ["file_path", "content"]},
"name": "push_static", {"name": "get_build_status", "description": "Hent status på siste Cloud Build for osvauco-agent-main-trigger", "required": []},
"description": "Last opp en statisk fil (HTML/CSS/JS) direkte til GCS-bucketen som serverer opax.vauco.no. Erstatter manuell gsutil-upload.", {"name": "deploy_service", "description": "Trigger ny Cloud Build deploy (HITL)", "required": []},
"parameters": { {"name": "get_logs", "description": "Hent Cloud Run-logger for osvauco-agent", "required": []},
"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": [],
},
] ]
} }
# ── Tool: push_static ─────────────────────────────────────────────────────────────────────
@app.post("/tools/push_static") @app.post("/tools/push_static")
async def push_static(req: PushStaticRequest, _: bool = Depends(verify_token)): 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: try:
from google.cloud import storage from google.cloud import storage
client = storage.Client(project=PROJECT_ID) client = storage.Client(project=PROJECT_ID)
bucket = client.bucket(STATIC_BUCKET) bucket = client.bucket(STATIC_BUCKET)
blob = bucket.blob(req.file_path) blob = bucket.blob(req.file_path)
blob.cache_control = req.cache_control blob.cache_control = req.cache_control
blob.upload_from_string( blob.upload_from_string(req.content.encode("utf-8"), content_type=req.content_type)
req.content.encode("utf-8"), logger.info(f"[push_static] {req.file_path} ({len(req.content)} bytes)")
content_type=req.content_type, return {"ok": True, "file_path": req.file_path, "bucket": STATIC_BUCKET,
)
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")), "bytes": len(req.content.encode("utf-8")),
"url": public_url, "url": f"https://storage.googleapis.com/{STATIC_BUCKET}/{req.file_path}"}
}
except Exception as e: 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)) raise HTTPException(status_code=500, detail=str(e))
# ── Tool: get_build_status ─────────────────────────────────────────────────────────────────────
@app.get("/tools/get_build_status") @app.get("/tools/get_build_status")
async def get_build_status(_: bool = Depends(verify_token)): 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 trigger_id for å unngå global/andre builds.
""" """
try: try:
from google.cloud.devtools import cloudbuild_v1 from google.cloud.devtools import cloudbuild_v1
from google.api_core.client_options import ClientOptions client = cloudbuild_v1.CloudBuildClient()
client = cloudbuild_v1.CloudBuildClient(
client_options=ClientOptions(
api_endpoint=f"{REGION}-cloudbuild.googleapis.com"
)
)
request = cloudbuild_v1.ListBuildsRequest( request = cloudbuild_v1.ListBuildsRequest(
project_id=PROJECT_ID, project_id=PROJECT_ID,
filter='trigger_id!=""', filter=f'trigger_id="{BUILD_TRIGGER_ID}"',
page_size=5, page_size=1,
) )
builds = list(client.list_builds(request=request)) builds = list(client.list_builds(request=request))
if not builds: 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] b = builds[0]
status_map = {1:"queued",2:"working",3:"success",4:"failure", status_map = {1:"queued",2:"working",3:"success",4:"failure",
5:"internal_error",6:"timeout",7:"cancelled"} 5:"internal_error",6:"timeout",7:"cancelled"}
status_str = status_map.get(int(b.status), "unknown") status_str = status_map.get(int(b.status), "unknown")
import time import time
duration_s = None 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) duration_s = int(b.finish_time.seconds - b.start_time.seconds)
elif b.start_time: elif b.start_time:
duration_s = int(time.time() - b.start_time.seconds) 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", ""), "commit": (b.substitutions or {}).get("SHORT_SHA", ""),
"duration_s": duration_s, "duration_s": duration_s,
"log_url": b.log_url or "", "log_url": b.log_url or "",
"region": REGION, "trigger_id": BUILD_TRIGGER_ID,
} }
except Exception as e: 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)} return {"status": "error", "message": str(e)}
# ── Tool: deploy_service ──────────────────────────────────────────────────────────────────────
@app.post("/tools/deploy_service") @app.post("/tools/deploy_service")
async def deploy_service(req: DeployServiceRequest, _: bool = Depends(verify_token)): 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: try:
from google.cloud.devtools import cloudbuild_v1 from google.cloud.devtools import cloudbuild_v1
from google.api_core.client_options import ClientOptions client = cloudbuild_v1.CloudBuildClient()
client = cloudbuild_v1.CloudBuildClient( trigger_id = req.trigger_id or BUILD_TRIGGER_ID
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"
)
subs = {"BRANCH_NAME": req.branch, **req.substitutions} subs = {"BRANCH_NAME": req.branch, **req.substitutions}
response = client.run_build_trigger( response = client.run_build_trigger(
project_id=PROJECT_ID, project_id=PROJECT_ID,
trigger_id=trigger_id, trigger_id=trigger_id,
source=cloudbuild_v1.RepoSource( source=cloudbuild_v1.RepoSource(branch_name=req.branch, substitutions=subs),
branch_name=req.branch,
substitutions=subs,
),
) )
build_id = response.metadata.build.id if hasattr(response, 'metadata') else "ukjent" 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}") logger.info(f"[deploy_service] build {build_id} branch {req.branch}")
return { return {"ok": True, "build_id": build_id, "branch": req.branch, "trigger_id": trigger_id}
"ok": True,
"build_id": build_id,
"branch": req.branch,
"trigger_id": trigger_id,
"message": "Build triggered. Kall GET /tools/get_build_status for status.",
}
except HTTPException: except HTTPException:
raise raise
except Exception as e: 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)) raise HTTPException(status_code=500, detail=str(e))
# ── Tool: get_logs ───────────────────────────────────────────────────────────────────────────────
@app.get("/tools/get_logs") @app.get("/tools/get_logs")
async def get_logs( async def get_logs(lines: int = 50, severity: str = "DEFAULT", _: bool = Depends(verify_token)):
lines: int = 50,
severity: str = "DEFAULT",
_: bool = Depends(verify_token)
):
"""
Hent siste Cloud Run-logger for osvauco-agent.
Returnerer logglinjer som liste.
"""
try: try:
from google.cloud import logging as gcloud_logging from google.cloud import logging as gcloud_logging
client = gcloud_logging.Client(project=PROJECT_ID) client = gcloud_logging.Client(project=PROJECT_ID)
filter_str = ( filter_str = (f'resource.type="cloud_run_revision" '
f'resource.type="cloud_run_revision" ' f'resource.labels.service_name="{CLOUD_RUN_SERVICE}"')
f'resource.labels.service_name="{CLOUD_RUN_SERVICE}"'
)
if severity and severity != "DEFAULT": if severity and severity != "DEFAULT":
filter_str += f' severity>={severity}' filter_str += f' severity>={severity}'
entries = list(client.list_entries( entries = list(client.list_entries(
filter_=filter_str, filter_=filter_str, order_by=gcloud_logging.DESCENDING, page_size=lines))
order_by=gcloud_logging.DESCENDING,
page_size=lines,
))
log_lines = [] log_lines = []
for entry in entries: for entry in entries:
payload = entry.payload payload = entry.payload
if isinstance(payload, dict): text = payload.get("message", str(payload)) if isinstance(payload, dict) else str(payload)
text = payload.get("message", str(payload)) log_lines.append({"timestamp": str(entry.timestamp), "severity": str(entry.severity), "text": text[:400]})
else:
text = 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} return {"service": CLOUD_RUN_SERVICE, "count": len(log_lines), "logs": log_lines}
except Exception as e: 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)) raise HTTPException(status_code=500, detail=str(e))
# ── MCP unified tool call endpoint ────────────────────────────────────────────────────────────
@app.post("/mcp/call") @app.post("/mcp/call")
async def mcp_call(req: ToolCallRequest, _: bool = Depends(verify_token)): async def mcp_call(req: ToolCallRequest, _: bool = Depends(verify_token)):
tool = req.tool if req.tool == "push_static":
params = req.params return await push_static(PushStaticRequest(**req.params), True)
elif req.tool == "get_build_status":
if tool == "push_static":
r = PushStaticRequest(**params)
return await push_static(r, True)
elif tool == "get_build_status":
return await get_build_status(True) return await get_build_status(True)
elif tool == "deploy_service": elif req.tool == "deploy_service":
r = DeployServiceRequest(**params) return await deploy_service(DeployServiceRequest(**req.params), True)
return await deploy_service(r, True) elif req.tool == "get_logs":
elif tool == "get_logs": return await get_logs(lines=req.params.get("lines", 50), severity=req.params.get("severity", "DEFAULT"), _=True)
return await get_logs(
lines=params.get("lines", 50),
severity=params.get("severity", "DEFAULT"),
_=True
)
else: 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}")