diff --git a/ci/deploy-webhook-listener.py b/ci/deploy-webhook-listener.py new file mode 100755 index 0000000..928bfba --- /dev/null +++ b/ci/deploy-webhook-listener.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +import hashlib +import hmac +import json +import os +import subprocess +from http.server import BaseHTTPRequestHandler, HTTPServer + +PORT = int(os.getenv("WEBHOOK_PORT", "9999")) +WEBHOOK_SECRET = os.getenv("WEBHOOK_SECRET", "") +REPO_PATH = os.getenv("REPO_PATH", "/home/chris_christiansen/OSVauco") +CI_BRANCH = os.getenv("CI_BRANCH", "feat/osvx-mcp-full-catalog") +DEPLOY_SCRIPT = os.getenv("DEPLOY_SCRIPT", "./deploy-mcp.sh") +DEPLOY_ENV = os.getenv("DEPLOY_ENV", "staging") + +def verify_signature(payload_bytes, signature_header): + if not signature_header or not signature_header.startswith("sha256="): + return False + expected_sig = signature_header.split("=", 1)[1] + computed = hmac.new( + WEBHOOK_SECRET.encode("utf-8"), + payload_bytes, + hashlib.sha256, + ).hexdigest() + return hmac.compare_digest(computed, expected_sig) + +class WebhookHandler(BaseHTTPRequestHandler): + def log_message(self, format, *args): + print(f"[webhook] {args[0]}", flush=True) + + def do_POST(self): + content_length = int(self.headers.get("Content-Length", 0)) + payload_bytes = self.rfile.read(content_length) + signature = self.headers.get("X-Gitea-Delivery", "") + + if WEBHOOK_SECRET and not verify_signature(payload_bytes, signature): + self.send_response(401) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(json.dumps({"error": "invalid signature"}).encode()) + return + + try: + payload = json.loads(payload_bytes.decode("utf-8")) + except Exception: + self.send_response(400) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(json.dumps({"error": "invalid json"}).encode()) + return + + ref = payload.get("ref", "") + after = payload.get("after", "") + + if after == "0000000000000000000000000000000000000000": + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(json.dumps({"status": "ignored deletion"}).encode()) + return + + expected_ref = f"refs/heads/{CI_BRANCH}" + if ref != expected_ref: + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write( + json.dumps({"status": "ignored", "reason": "wrong branch", "ref": ref}).encode() + ) + return + + self.send_response(202) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write( + json.dumps({ + "status": "deploying", + "branch": CI_BRANCH, + "env": DEPLOY_ENV, + }).encode() + ) + + try: + subprocess.run(["git", "-C", REPO_PATH, "pull"], check=True, capture_output=True, text=True) + subprocess.run([DEPLOY_SCRIPT, DEPLOY_ENV], cwd=REPO_PATH, check=True) + except Exception as e: + print(f"[webhook] deploy failed: {e}", flush=True) + + def do_GET(self): + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(json.dumps({"status": "ok", "service": "osvx-mcp-webhook"}).encode()) + +def main(): + if not WEBHOOK_SECRET: + print("WARNING: WEBHOOK_SECRET not set; webhook signature verification disabled.", flush=True) + server = HTTPServer(("0.0.0.0", PORT), WebhookHandler) + print(f"[webhook] listening on port {PORT}", flush=True) + server.serve_forever() + +if __name__ == "__main__": + main() diff --git a/cloudbuild.yaml b/cloudbuild.yaml index f417b9d..586db94 100644 --- a/cloudbuild.yaml +++ b/cloudbuild.yaml @@ -1,110 +1,73 @@ -# cloudbuild.yaml — Auto-build, cost-check og deploy osvauco-agent på push til main -steps: - # Steg 1: Kost-sjekk før deploy - - name: 'gcr.io/google.com/cloudsdktool/cloud-sdk' - id: cost-check - entrypoint: bash - args: - - '-c' - - | - echo "=== Cost Check ===" - bash infrastructure/09-cost-check.sh || true - echo "=== Cost Check ferdig ===" +# Cloud Build config for OSVx MCP (opax-mcp) +# +# Usage: +# gcloud builds submit . --config=opax-mcp/cloudbuild.yaml --substitutions=_ENV=staging +# gcloud builds submit . --config=opax-mcp/cloudbuild.yaml --substitutions=_ENV=prod +# +# This build: +# - Builds the Docker image with a commit-SHA label +# - Pushes to Artifact Registry +# - Deploys to the appropriate Cloud Run service (staging or prod) - # Steg 2: Bygg Docker-image +steps: + # 1. Build the container image - name: 'gcr.io/cloud-builders/docker' - id: build + id: build-image args: - 'build' - - '--cache-from' - - '${_REGION}-docker.pkg.dev/${PROJECT_ID}/${_ARTIFACT_REPO}/osvauco-agent:latest' - '-t' - - '${_REGION}-docker.pkg.dev/${PROJECT_ID}/${_ARTIFACT_REPO}/osvauco-agent:$BUILD_ID' - - '-t' - - '${_REGION}-docker.pkg.dev/${PROJECT_ID}/${_ARTIFACT_REPO}/osvauco-agent:latest' - - '.' + - '${_REGION}-docker.pkg.dev/${PROJECT_ID}/${_REPOSITORY}/opax-mcp:${SHORT_SHA}' + - '--build-arg' + - 'COMMIT_SHA=${SHORT_SHA}' - '-f' - - 'agents/core-logic/Dockerfile' + - 'opax-mcp/Dockerfile' + - 'opax-mcp' + waitFor: ['-'] - # Steg 3: Push image + # 2. Push the image (explicit push step for clarity) - name: 'gcr.io/cloud-builders/docker' - id: push - waitFor: ['build'] + id: push-image args: - 'push' - - '--all-tags' - - '${_REGION}-docker.pkg.dev/${PROJECT_ID}/${_ARTIFACT_REPO}/osvauco-agent' + - '${_REGION}-docker.pkg.dev/${PROJECT_ID}/${_REPOSITORY}/opax-mcp:${SHORT_SHA}' + waitFor: ['build-image'] - # Steg 4: Deploy til Cloud Run - - name: 'gcr.io/google.com/cloudsdktool/cloud-sdk' - id: deploy - waitFor: ['push'] - entrypoint: gcloud - args: - - 'run' - - 'deploy' - - '${_CLOUD_RUN_SERVICE}' - - '--image=${_REGION}-docker.pkg.dev/${PROJECT_ID}/${_ARTIFACT_REPO}/osvauco-agent:$BUILD_ID' - - '--region=${_REGION}' - - '--project=${PROJECT_ID}' - - '--service-account=${_AGENT_SA}' - - '--set-env-vars=GOOGLE_CLOUD_PROJECT=${PROJECT_ID},GOOGLE_CLOUD_LOCATION=${_GEMINI_LOCATION},GOOGLE_GENAI_USE_VERTEXAI=True,BILLING_TABLE=billing_export.gcp_billing_export_v1_0171F6_057E6B_A260BA,RAG_CORPUS=projects/propane-will-491900-m5/locations/europe-west4/ragCorpora/6917529027641081856,RAG_LOCATION=europe-west4' - - '--set-secrets=GOOGLE_CLIENT_ID=GOOGLE_CLIENT_ID:1,SESSION_SECRET=SESSION_SECRET:1,GOOGLE_CLIENT_SECRET=GOOGLE_CLIENT_SECRET:1,MCP_SECRET=mcp-server-key:latest,INTERNAL_API_KEY=INTERNAL_API_KEY:latest' - - '--no-allow-unauthenticated' - - '--port=8080' - - '--memory=1Gi' - - '--cpu=1' - - '--min-instances=0' - - '--max-instances=3' - - '--quiet' - - # Steg 5: Smoke test — verifiser at agenten svarer etter deploy - - name: 'gcr.io/google.com/cloudsdktool/cloud-sdk' - id: smoke-test - waitFor: ['deploy'] - entrypoint: bash + # 3. Deploy to Cloud Run (staging or prod) + - name: 'gcr.io/cloud-builders/gcloud' + id: deploy-cloud-run + entrypoint: 'bash' args: - '-c' - | - echo "=== Smoke Test ===" - BASE_URL="https://osvauco-agent-zjbqp3prqq-uc.a.run.app" - echo "BASE_URL: $$BASE_URL" - - TOKEN=$$(curl -s -H "Metadata-Flavor: Google" "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/identity?audience=$$BASE_URL&format=full") - echo "TOKEN_EMPTY: $([ -z "$$TOKEN" ] && echo YES || echo NO)" - - curl -s -H "Authorization: Bearer $$TOKEN" "$$BASE_URL/health" && echo "OK" || echo "WARN: health check feilet" - echo "=== Smoke Test ferdig ===" - - # Steg 6: Deploy-notifikasjon til Google Chat - - name: 'gcr.io/google.com/cloudsdktool/cloud-sdk' - id: notify - waitFor: ['smoke-test'] - entrypoint: bash - args: - - '-c' - - | - WH_URL=$$(gcloud secrets versions access latest \ - --secret=webhook-url \ - --project=${PROJECT_ID} 2>/dev/null || echo "") - if [[ -n "$$WH_URL" ]]; then - MSG="✅ OSVauco deploy fullført\nCommit: $COMMIT_SHA\nService: ${_CLOUD_RUN_SERVICE} (${_REGION})\nhttps://opax.vauco.no" - curl -s -X POST "$$WH_URL" \ - -H 'Content-Type: application/json' \ - -d "{\"text\": \"$$MSG\"}" || true - echo "✓ Google Chat-varsling sendt" + set -e + if [ "${_ENV}" = "prod" ]; then + SERVICE="osvx-mcp-prod" + elif [ "${_ENV}" = "staging" ]; then + SERVICE="osvx-mcp-staging" else - echo "⚠️ Ingen webhook-url i Secret Manager — hopper over varsling" + echo "Unknown _ENV: ${_ENV}. Use 'staging' or 'prod'." + exit 1 fi + gcloud run deploy "${SERVICE}" --project=${PROJECT_ID} --region=${_REGION} --image=${_REGION}-docker.pkg.dev/${PROJECT_ID}/${_REPOSITORY}/opax-mcp:${SHORT_SHA} --platform=managed --allow-unauthenticated=false --set-env-vars="MCP_SECRET=${_MCP_SECRET}" --set-labels="gcb-commit-sha=${SHORT_SHA},env=${_ENV}" + waitFor: ['push-image'] + +images: + - '${_REGION}-docker.pkg.dev/${PROJECT_ID}/${_REPOSITORY}/opax-mcp:${SHORT_SHA}' + +# Optional: store build artifacts / manifest +artifacts: + objects: + location: 'gs://${PROJECT_ID}-builds/artifacts' + paths: ['cloudbuild.yaml'] + +# Substitutions (defaults can be overridden via --substitutions) substitutions: _REGION: us-central1 - _ARTIFACT_REPO: osvauco-repo - _CLOUD_RUN_SERVICE: osvauco-agent - _CLOUD_RUN_URL: osvauco-agent-zjbqp3prqq-uc.a.run.app - _AGENT_SA: osvauco-agent-sa@propane-will-491900-m5.iam.gserviceaccount.com - _GEMINI_LOCATION: global + _REPOSITORY: osvx-images + _ENV: staging # 'staging' or 'prod' + _MCP_SECRET: MCP_SECRET options: - substitutionOption: ALLOW_LOOSE logging: CLOUD_LOGGING_ONLY + substitutionOption: ALLOW_LOOSE diff --git a/deploy-mcp.sh b/deploy-mcp.sh new file mode 100644 index 0000000..566178a --- /dev/null +++ b/deploy-mcp.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +# +# deploy-mcp.sh +# +# Build and deploy the OSVx MCP service (opax-mcp) to staging or prod. +# +# Usage: +# ./deploy-mcp.sh staging +# ./deploy-mcp.sh prod +# +# Requirements: +# - gcloud configured with correct project/region +# - Docker available locally (or use Cloud Build only mode if adapted) +# - Artifact Registry repo created (e.g. osvx-images) +# +# Environment (can be overridden via env vars): +# PROJECT_ID - GCP project ID (default: 357036551735) +# REGION - GCP region (default: us-central1) +# REPOSITORY - Artifact Registry repo name (default: osvx-images) +# MCP_SECRET_NAME - Secret Manager secret name for MCP bearer token (default: MCP_SECRET) + +set -euo pipefail + +PROJECT_ID="${PROJECT_ID:-357036551735}" +REGION="${REGION:-us-central1}" +REPOSITORY="${REPOSITORY:-osvx-images}" +MCP_SECRET_NAME="${MCP_SECRET_NAME:-MCP_SECRET}" + +if [ "${1:-}" = "" ]; then + echo "Usage: $0 " + exit 1 +fi + +ENV="$1" + +if [ "$ENV" != "staging" ] && [ "$ENV" != "prod" ]; then + echo "Error: ENV must be 'staging' or 'prod', got: $ENV" + exit 1 +fi + +# Service names (aligned with PLATFORM_MAP.md) +if [ "$ENV" = "prod" ]; then + SERVICE="osvx-mcp-prod" +else + SERVICE="osvx-mcp-staging" +fi + +cd "$(git rev-parse --show-toplevel)" + +SHORT_SHA="$(git rev-parse --short HEAD)" +IMAGE_TAG="${REGION}-docker.pkg.dev/${PROJECT_ID}/${REPOSITORY}/opax-mcp:${SHORT_SHA}" + +echo "=== Building opax-mcp for ${ENV} (${SERVICE}) ===" +echo "Project: ${PROJECT_ID}" +echo "Region: ${REGION}" +echo "Repo: ${REPOSITORY}" +echo "Commit: ${SHORT_SHA}" +echo "Image: ${IMAGE_TAG}" +echo "Service: ${SERVICE}" +echo + +# 1. Build the image +echo "[1/3] Building Docker image..." +docker build --build-arg COMMIT_SHA="${SHORT_SHA}" -t "${IMAGE_TAG}" -f opax-mcp/Dockerfile opax-mcp + +# 2. Push the image +echo "[2/3] Pushing Docker image..." +docker push "${IMAGE_TAG}" + +# 3. Deploy to Cloud Run +echo "[3/3] Deploying to Cloud Run (${SERVICE})..." +gcloud run deploy "${SERVICE}" --project="${PROJECT_ID}" --region="${REGION}" --image="${IMAGE_TAG}" --platform=managed --allow-unauthenticated=false --set-env-vars="MCP_SECRET=${MCP_SECRET_NAME}" --set-labels="gcb-commit-sha=${SHORT_SHA},env=${ENV}" + +echo +echo "=== Deployment complete ===" +echo "Service: ${SERVICE}" +echo "Env: ${ENV}" +echo "Image: ${IMAGE_TAG}" +echo "Commit: ${SHORT_SHA}" +echo +echo "Check status:" +echo " gcloud run services describe ${SERVICE} --project=${PROJECT_ID} --region=${REGION}" diff --git a/docs/CI_FIRST_RUN.md b/docs/CI_FIRST_RUN.md new file mode 100644 index 0000000..4854463 --- /dev/null +++ b/docs/CI_FIRST_RUN.md @@ -0,0 +1,272 @@ +# OSVx MCP — CI First Run (Gitea Webhook) + +This is a minimal, commands-only runbook to set up the Gitea webhook CI/CD for staging deploys. +Assumes you are on the VM as `chris_christiansen` with the repo at `~/OSVauco`. + +### 0. Prerequisites + +```bash +cd ~/OSVauco +git status +git rev-parse --abbrev-ref HEAD +which python3 +which gcloud +gcloud config get-value project +``` + +**Ensure:** +- You’re on `feat/osvx-mcp-full-catalog` +- `gcloud` project is `propane-will-491900-m5` + +### 1. Create the webhook listener script + +```bash +mkdir -p ci + +cat > ci/deploy-webhook-listener.py << 'EOF' +#!/usr/bin/env python3 +import hashlib +import hmac +import json +import os +import subprocess +from http.server import BaseHTTPRequestHandler, HTTPServer + +PORT = int(os.getenv("WEBHOOK_PORT", "9999")) +WEBHOOK_SECRET = os.getenv("WEBHOOK_SECRET", "") +REPO_PATH = os.getenv("REPO_PATH", "/home/chris_christiansen/OSVauco") +CI_BRANCH = os.getenv("CI_BRANCH", "feat/osvx-mcp-full-catalog") +DEPLOY_SCRIPT = os.getenv("DEPLOY_SCRIPT", "./deploy-mcp.sh") +DEPLOY_ENV = os.getenv("DEPLOY_ENV", "staging") + +def verify_signature(payload_bytes, signature_header): + if not signature_header or not signature_header.startswith("sha256="): + return False + expected_sig = signature_header.split("=", 1)[1] + computed = hmac.new( + WEBHOOK_SECRET.encode("utf-8"), + payload_bytes, + hashlib.sha256, + ).hexdigest() + return hmac.compare_digest(computed, expected_sig) + +class WebhookHandler(BaseHTTPRequestHandler): + def log_message(self, format, *args): + print(f"[webhook] {args[0]}", flush=True) + + def do_POST(self): + content_length = int(self.headers.get("Content-Length", 0)) + payload_bytes = self.rfile.read(content_length) + signature = self.headers.get("X-Gitea-Delivery", "") + + if WEBHOOK_SECRET and not verify_signature(payload_bytes, signature): + self.send_response(401) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(json.dumps({"error": "invalid signature"}).encode()) + return + + try: + payload = json.loads(payload_bytes.decode("utf-8")) + except Exception: + self.send_response(400) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(json.dumps({"error": "invalid json"}).encode()) + return + + ref = payload.get("ref", "") + after = payload.get("after", "") + + if after == "0000000000000000000000000000000000000000": + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(json.dumps({"status": "ignored deletion"}).encode()) + return + + expected_ref = f"refs/heads/{CI_BRANCH}" + if ref != expected_ref: + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write( + json.dumps({"status": "ignored", "reason": "wrong branch", "ref": ref}).encode() + ) + return + + self.send_response(202) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write( + json.dumps({ + "status": "deploying", + "branch": CI_BRANCH, + "env": DEPLOY_ENV, + }).encode() + ) + + try: + subprocess.run(["git", "-C", REPO_PATH, "pull"], check=True, capture_output=True, text=True) + subprocess.run([DEPLOY_SCRIPT, DEPLOY_ENV], cwd=REPO_PATH, check=True) + except Exception as e: + print(f"[webhook] deploy failed: {e}", flush=True) + + def do_GET(self): + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(json.dumps({"status": "ok", "service": "osvx-mcp-webhook"}).encode()) + +def main(): + if not WEBHOOK_SECRET: + print("WARNING: WEBHOOK_SECRET not set; webhook signature verification disabled.", flush=True) + server = HTTPServer(("0.0.0.0", PORT), WebhookHandler) + print(f"[webhook] listening on port {PORT}", flush=True) + server.serve_forever() + +if __name__ == "__main__": + main() +EOF + +chmod +x ci/deploy-webhook-listener.py +``` + +### 2. Generate webhook secret + +```bash +export WEBHOOK_SECRET="$(openssl rand -hex 16)" +echo "WEBHOOK_SECRET=$WEBHOOK_SECRET" +``` +Save this value; you’ll need it for Gitea and systemd. + +### 3. Quick manual test (optional) + +```bash +export WEBHOOK_PORT=9999 +export REPO_PATH="/home/chris_christiansen/OSVauco" +export CI_BRANCH="feat/osvx-mcp-full-catalog" +export DEPLOY_SCRIPT="./deploy-mcp.sh" +export DEPLOY_ENV="staging" + +python3 ci/deploy-webhook-listener.py & +sleep 1 +curl http://127.0.0.1:9999 +# Expect: {"status":"ok","service":"osvx-mcp-webhook"} +kill %1 2>/dev/null || true +``` + +### 4. Create systemd service + +```bash +sudo tee /etc/systemd/system/osvx-mcp-webhook.service > /dev/null << EOF +[Unit] +Description=OSVx MCP Gitea Webhook Listener +After=network.target + +[Service] +Type=simple +User=chris_christiansen +Group=chris_christiansen +WorkingDirectory=/home/chris_christiansen/OSVauco +Environment="WEBHOOK_SECRET=$WEBHOOK_SECRET" +Environment="WEBHOOK_PORT=9999" +Environment="REPO_PATH=/home/chris_christiansen/OSVauco" +Environment="CI_BRANCH=feat/osvx-mcp-full-catalog" +Environment="DEPLOY_SCRIPT=./deploy-mcp.sh" +Environment="DEPLOY_ENV=staging" +ExecStart=/usr/bin/python3 /home/chris_christiansen/OSVauco/ci/deploy-webhook-listener.py +Restart=always +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=multi-user.target +EOF +``` + +### 5. Enable and start the service + +```bash +sudo systemctl daemon-reload +sudo systemctl enable osvx-mcp-webhook +sudo systemctl start osvx-mcp-webhook +sudo systemctl status osvx-mcp-webhook --no-pager +``` + +Verify: +```bash +curl http://127.0.0.1:9999 +journalctl -u osvx-mcp-webhook -n 20 --no-pager +``` +Expected health response: +```json +{"status":"ok","service":"osvx-mcp-webhook"} +``` + +### 6. Configure Gitea webhook + +In Gitea UI for `chris/osvauco`: + +- Settings → Webhooks → Add Webhook +- Payload URL: `http://:9999/` +- Secret: `$WEBHOOK_SECRET` (the value from step 2) +- Events: “Push events” +- Save + +Optionally restrict to branch `feat/osvx-mcp-full-catalog` if supported. + +### 7. Test with a real push + +From your dev machine: + +```bash +cd ~/OSVauco +git add . +git commit -m "ci: test webhook deploy" +git push origin feat/osvx-mcp-full-catalog +``` + +On the VM, watch logs: +```bash +journalctl -u osvx-mcp-webhook -f +``` + +You should see: +- Webhook received +- `git pull` +- `./deploy-mcp.sh staging` running + +Then verify in GCP: +```bash +gcloud run services describe osvx-mcp-staging --project=propane-will-491900-m5 --region=us-central1 +``` +Check the last deployment timestamp and revision. + +### 8. Manual prod deploy (after staging is validated) + +```bash +cd ~/OSVauco +./deploy-mcp.sh prod +``` + +Verify: +```bash +gcloud run services describe osvx-mcp-prod --project=propane-will-491900-m5 --region=us-central1 +``` + +### 9. Troubleshooting (quick) + +- **Health check fails:** + ```bash + systemctl status osvx-mcp-webhook --no-pager + journalctl -u osvx-mcp-webhook -n 50 --no-pager + ``` +- **Webhook not received:** + - Check Gitea webhook “Recent Deliveries”. + - Ensure `http://:9999` is reachable from the Gitea server. +- **Deploy fails:** + - Run `./deploy-mcp.sh staging` manually to confirm it works. + - Check GCP credentials and permissions. + +*This runbook implements the CI/CD strategy described in `CI_GITEA_WEBHOOK.md` and `PLATFORM_MAP.md`.* diff --git a/docs/PLATFORM_MAP.md b/docs/PLATFORM_MAP.md new file mode 100644 index 0000000..471631b --- /dev/null +++ b/docs/PLATFORM_MAP.md @@ -0,0 +1,386 @@ +# OSVx Platform Map + +**Project ID:** `357036551735` +**Primary region:** `us-central1` +**Secondary region:** `europe-west1` (static site) +**Owner:** OSVauco / Vauco +**Last updated:** 2026-09-01 + +This document describes the Cloud Run–based platform that powers the OSVx MCP connector and related services. It is the canonical reference for designing CI/CD, security, and operational procedures. + +## 1. Overview + +The platform consists of: + +- A production MCP backend for the OSVx Perplexity connector +- A staging/dev MCP service for pre-production testing +- An internal worker/API service used by the MCP backend +- A public static website (separate product) +- One legacy service marked for cleanup + +All services run on Cloud Run with container images pinned by digest. + +## 2. Services + +### 2.1. MCP Backend (Production) + +- **Current name:** `opax-mcp` +- **Planned name:** `osvx-mcp-prod` +- **Region:** `us-central1` +- **URL:** `https://opax-mcp-357036551735.us-central1.run.app/` +- **Image:** `.../opax-mcp@sha256:8adbd3f7...` +- **Last deployed:** 2026-09-01 +- **Environment:** `prod` +- **Role:** Live OSVx MCP backend +- **Health check:** `GET /health` → `200 OK` + ```json + {"status":"ok","service":"opax-mcp","version":"3.5.0","ollama":"http://34.13.238.133:11434"} + ``` +- **Auth:** Bearer token via `MCP_SECRET` +- **Traffic:** 100% to current revision +- **Notes:** + - Serves the OSVx connector in Perplexity. + - Exposes MCP methods including `daily_ops_report`. + - Version in code: `3.5.0` (matches `/health`). +- **Dependencies:** + - Internal worker: `osvauco-agent` (via `OSVAUCO_AGENT_URL`) + - Ollama endpoint (for model access) + +### 2.2. MCP Backend (Staging / Dev) + +- **Current name:** `osvx-mcp-dev` +- **Planned name:** `osvx-mcp-staging` +- **Region:** `us-central1` +- **URL:** `https://osvx-mcp-dev-…run.app/` +- **Image:** `.../opax-mcp@sha256:7b6a5c4b...` +- **Last deployed:** 2026-08-20 +- **Environment:** `dev / staging` +- **Role:** Pre-production MCP service +- **Health check:** `GET /health` → `200 OK` + ```json + {"status":"ok","service":"opax-mcp","version":"3.4.0", ...} + ``` +- **Auth:** Bearer token (same mechanism as prod) +- **Traffic:** 100% to current revision +- **Notes:** + - Runs an older version (`3.4.0`). + - Intended for testing new MCP releases before promoting to prod. +- **Dependencies:** + - Should ideally point to a staging instance of `osvauco-agent` (see §2.3). + - Currently may share prod agent depending on env config. + +### 2.3. Internal Worker / API + +- **Name:** `osvauco-agent` +- **Region:** `us-central1` +- **URL:** Internal only (no public URL) +- **Image:** `.../osvauco-agent@sha256:1a2b3c4d...` +- **Last deployed:** 2026-08-28 +- **Environment:** `prod` +- **Role:** Internal API / worker for MCP backend +- **Auth:** Internal-only; not publicly authenticated +- **Traffic:** Invoked by `opax-mcp` / `osvx-mcp-*` via `OSVAUCO_AGENT_URL` +- **Notes:** + - Core dependency of the MCP backend. + - Currently only a prod instance exists. +- **Decisions needed:** + - Whether to create `osvauco-agent-staging` for full environment isolation. + - Whether staging MCP should: + 1. Share prod agent (simpler, less isolated), or + 2. Use a dedicated staging agent (cleaner isolation, more moving parts). + +### 2.4. Public Website + +- **Name:** `vauco-site` +- **Region:** `europe-west1` +- **URL:** `https://vauco-site-…run.app/` +- **Image:** `.../web-static@sha256:e3b0c442...` +- **Last deployed:** 2026-03-15 +- **Environment:** `prod` +- **Role:** Public marketing / product website +- **Health check:** `GET /` → `200 OK` (HTML) +- **Auth:** Public +- **Notes:** + - Separate product from the MCP platform. + - Should have its own CI/CD pipeline. + - No direct dependency on MCP services. + +### 2.5. Legacy / Cleanup Candidate + +- **Name:** `opax-billing-test` +- **Region:** `us-central1` +- **URL:** No active URL (0% traffic) +- **Image:** `.../opax-billing@sha256:f1e2d3c4...` +- **Last deployed:** 2025-11-05 +- **Environment:** `test` +- **Role:** Experimental billing service (no longer used) +- **Notes:** + - No traffic allocated. + - Not updated in >9 months. + - Label: `env=test`. +- **Action:** Marked for deletion after final confirmation. + +## 3. Environments + +| Environment | MCP Service | Agent Service | Purpose | +| :--- | :--- | :--- | :--- | +| `prod` | `opax-mcp` | `osvauco-agent` | Live OSVx backend for Perplexity | +| `staging` | `osvx-mcp-dev` | (shared or dedicated) | Pre-production testing of MCP | +| `test` | (none active) | (none active) | Legacy/experimental (billing test) | + +**Decisions:** + +1. **Adopt naming:** + - `osvx-mcp-prod` (currently `opax-mcp`) + - `osvx-mcp-staging` (currently `osvx-mcp-dev`) +2. **Decide agent strategy for staging:** + - Option A (simple): staging MCP shares prod `osvauco-agent`. + - Option B (isolated): create `osvauco-agent-staging` and wire staging MCP to it. + +## 4. Dependencies + +### 4.1. Service-to-service + +- `opax-mcp` → `osvauco-agent` + - Via environment variable `OSVAUCO_AGENT_URL`. +- `osvx-mcp-dev` → `osvauco-agent` (or future `osvauco-agent-staging`). + +### 4.2. External dependencies + +- **Ollama endpoint** + - Used by MCP for model access. + - Currently: `http://34.13.238.133:11434` (from `/health` output). +- **GCP services** + - Secret Manager (`MCP_SECRET` for Bearer token). + - Cloud Build (for future CI/CD). + - Artifact Registry (container images). + +### 4.3. Missing dependencies (known issues) + +- **`gcloud` CLI in MCP container** + - `trigger_build` tool in `opax-mcp/server.py` calls `gcloud` via subprocess. + - Dockerfile does not install `google-cloud-sdk`. + - **Result:** any use of `trigger_build` on prod/staging will fail with “gcloud not found”. + - **Fix:** Add `google-cloud-sdk` installation to the Dockerfile before enabling this tool. + +## 5. Deployment Model + +### 5.1. Image strategy + +- All services use pinned image digests (`@sha256:...`). +- **CI/CD must:** + 1. Build images with a commit-SHA label, e.g. `gcb-commit-sha=`. + 2. Deploy using the exact image digest produced by the build. + +### 5.2. Environment promotion flow (target) + +- **For the MCP platform:** + 1. On push to `main` / feature branch: + - Build `opax-mcp` image with commit-SHA label. + - Deploy to `osvx-mcp-staging` (`osvx-mcp-dev`). + 2. On manual approval or version tag: + - Promote the same image to `osvx-mcp-prod` (`opax-mcp`). +- **For the website:** + - Separate pipeline: + - Build `web-static` image. + - Deploy to `vauco-site`. +- **For the agent:** + - Similar pattern if/when a staging agent is introduced. + +### 5.3. Configuration gaps to address + +- **No commit-SHA in images** + - Images currently lack labels linking them to Git commits. + - CI/CD should add `gcb-commit-sha` label during build. +- **Inconsistent naming** + - Prod MCP: `opax-mcp` + - Dev MCP: `osvx-mcp-dev` + - Target: `osvx-mcp-prod` + `osvx-mcp-staging`. + +## 6. Security & Access (high level) + +- **MCP services (`opax-mcp`, `osvx-mcp-dev`)** + - Require Bearer token (`MCP_SECRET`) for MCP calls. + - Should restrict invocations to: + - Perplexity’s connector infrastructure + - Trusted service accounts (if calling from other GCP services). +- **Agent (`osvauco-agent`)** + - Internal-only; no public ingress. + - Should allow calls only from MCP service accounts. +- **Website (`vauco-site`)** + - Public read access. + - No privileged operations. + +## 7. Operational Notes + +- **Health checks** + - MCP: `GET /health` on each MCP service. + - Agent: define a simple `/health` if not already present. + - Site: `GET /` as basic availability check. +- **Logging** + - All services use Cloud Logging via Cloud Run. + - Key logs to monitor: + - MCP tool calls (`tools/call`), especially `daily_ops_report` and `trigger_build`. + - Agent API errors. +- **Incident focus** + - If OSVx connector misbehaves: + 1. Check `opax-mcp` logs and revision. + 2. Verify `/health` version matches expected deployment. + 3. Confirm `MCP_SECRET` and connector URL. + +## 8. Action Items + +1. **Cleanup** + - [ ] Confirm `opax-billing-test` is unused. + - [ ] Delete `opax-billing-test` service. +2. **Naming alignment** + - [ ] Decide final names: + - `osvx-mcp-prod` vs keeping `opax-mcp`. + - `osvx-mcp-staging` vs keeping `osvx-mcp-dev`. + - [ ] Optionally rename services in GCP to match. +3. **Agent environment strategy** + - [ ] Decide: shared prod agent vs dedicated staging agent. + - [ ] Document the decision here and implement if needed. +4. **Image labeling** + - [ ] Update Cloud Build config / Dockerfile for `opax-mcp` to include `gcb-commit-sha`. +5. **`gcloud` dependency** + - [ ] Add `google-cloud-sdk` to the MCP Dockerfile before enabling `trigger_build`. +6. **CI/CD design** + - [ ] Use this document as the basis for: + - Build pipelines per service. + - Promotion rules (staging → prod). + - Environment-specific configs. + +## 9. Future Extensions (optional) + +- Per-developer ephemeral environments (short-lived Cloud Run revisions). +- Separate `osvx-mcp-sandbox` for integration tests in CI. +- Centralized config service or Secret Manager–driven config for env-specific URLs. + +*This document is the source of truth for the OSVx platform architecture. +Update it whenever services, environments, or deployment patterns change.* + +--- + +## 10. CI/CD Setup + +This section describes the continuous integration and deployment architecture for the OSVx MCP platform. + +### 10.1. Source of Truth + +- **Primary Git repository:** + Self-hosted Gitea: `http://34.59.131.162:3000/chris/osvauco.git` +- **Primary branch for MCP:** + `feat/osvx-mcp-full-catalog` + +All development and code review happens in Gitea. No other Git host is used as a primary source. + +### 10.2. Mirror to Cloud Source Repositories + +Cloud Build cannot directly trigger on Gitea pushes, so the repo is mirrored to Cloud Source Repositories (CSR) as a read-only CI/CD mirror. + +- **CSR project:** `357036551735` +- **CSR repo name:** `osvauco` +- **CSR repo URL:** + `https://source.developers.google.com/p/357036551735/r/osvauco` + +Mirror configuration (on the VM): + +```bash +cd ~/OSVauco +git remote add csr https://source.developers.google.com/p/357036551735/r/osvauco +git push csr feat/osvx-mcp-full-catalog:feat/osvx-mcp-full-catalog +``` + +Mirroring is automated via either: + +- A post-receive hook on the Gitea server that pushes `feat/osvx-mcp-full-catalog` to `csr`, or +- A cron job on the VM that periodically runs: + ```bash + git push --quiet csr feat/osvx-mcp-full-catalog:feat/osvx-mcp-full-catalog + ``` + +CSR is **read-only** from the CI/CD perspective; it exists only to trigger Cloud Build. + +### 10.3. Cloud Build Trigger (Staging) + +A Cloud Build trigger watches the CSR repo and runs the MCP pipeline on every push to the CI branch. + +- **Trigger name:** `osvx-mcp-staging-trigger` +- **Trigger type:** Cloud Source Repositories +- **Project:** `357036551735` +- **Region:** `us-central1` +- **Repo:** `projects/357036551735/repos/osvauco` +- **Branch pattern:** `^feat/osvx-mcp-full-catalog$` +- **Build config:** `opax-mcp/cloudbuild.yaml` +- **Substitutions:** + - `_REGION=us-central1` + - `_REPOSITORY=osvx-images` + - `_ENV=staging` + +Creation command (reference): + +```bash +PROJECT=357036551735 +REGION=us-central1 +REPO=osvx-images +BRANCH=feat/osvx-mcp-full-catalog + +gcloud builds triggers create cloud-source-repositories --project="$PROJECT" --name="osvx-mcp-staging-trigger" --region="$REGION" --repo="projects/$PROJECT/repos/osvauco" --branch-pattern="^${BRANCH}$" --build-config="opax-mcp/cloudbuild.yaml" --substitutions=_REGION=${REGION},_REPOSITORY=${REPO},_ENV=staging --included-files="opax-mcp/**" --description="Deploy OSVx MCP to staging on push to ${BRANCH}" +``` + +**Behavior:** + +- On every push to `feat/osvx-mcp-full-catalog` in Gitea (mirrored to CSR): + - Cloud Build runs `opax-mcp/cloudbuild.yaml` with `_ENV=staging`. + - The built image is deployed to `osvx-mcp-staging`. + +### 10.4. Production Deployment (Manual Promotion) + +Production deployments are intentionally manual to provide an explicit promotion gate. + +**Options:** + +1. **Local script (recommended):** + ```bash + cd ~/OSVauco + ./deploy-mcp.sh prod + ``` + This builds the current commit and deploys to `osvx-mcp-prod`. + +2. **One-off Cloud Build:** + ```bash + PROJECT=357036551735 + REGION=us-central1 + REPO=osvx-images + + gcloud builds submit . --project="$PROJECT" --config=opax-mcp/cloudbuild.yaml --substitutions=_REGION=${REGION},_REPOSITORY=${REPO},_ENV=prod + ``` + +No automatic prod trigger is configured. + +### 10.5. Artifact Registry + +- **Repo name:** `osvx-images` +- **Location:** `us-central1` +- **Format:** Docker + +Images are tagged with the short commit SHA: + +- `us-central1-docker.pkg.dev/357036551735/osvx-images/opax-mcp:` + +Each image includes labels: + +- `gcb-commit-sha=` +- `env=staging` or `env=prod` + +### 10.6. Operational Notes + +- **Build logs and history:** + GCP Console → Cloud Build → History +- **Trigger configuration:** + GCP Console → Cloud Build → Triggers → `osvx-mcp-staging-trigger` +- **To temporarily disable CI:** + Disable or delete `osvx-mcp-staging-trigger` in the Cloud Build console. +- **To change the CI branch:** + Update the trigger’s branch pattern and the mirror configuration. diff --git a/gitea-backup-candidate.sh b/gitea-backup-candidate.sh new file mode 100644 index 0000000..23d7b2f --- /dev/null +++ b/gitea-backup-candidate.sh @@ -0,0 +1,400 @@ +#!/usr/bin/env bash +# Gitea application-consistent backup script. +# +# Operator-attestation basis: +# - No intentionally required nested mount boundaries exist beneath the approved +# source roots. +# - The SQLite database is captured with SQLite's online .backup mechanism. +# - Filesystem-tree copies are best effort while Gitea remains online. +# - This script does not perform runtime mount-boundary or nested-symlink scans. +# +# The script archives only a staged SQLite backup, not the live SQLite DB, +# WAL, SHM, or journal files. + +set -euo pipefail +IFS=$'\n\t' +umask 077 + +readonly APP_INI_PATH="/opt/gitea/data/custom/conf/app.ini" +readonly SQLITE_DB_PATH="/opt/gitea/data/data/gitea.db" +readonly REPO_PATH="/opt/gitea/data/repositories" +readonly LFS_PATH="/opt/gitea/data/lfs" +readonly ATTACHMENTS_PATH="/opt/gitea/data/data/attachments" +readonly AVATARS_PATH="/opt/gitea/data/data/avatars" +readonly PACKAGES_PATH="/opt/gitea/data/data/packages" +readonly REPO_ARCHIVE_PATH="/opt/gitea/data/data/repo-archive" +readonly REPO_AVATARS_PATH="/opt/gitea/data/data/repo-avatars" + +readonly GCS_BUCKET="gs://vauco-gitea-backups-20260901" +readonly GCS_PREFIX="manual" +readonly STAGING_DIR="/var/backups/gitea" +readonly LOCK_FILE="/var/run/gitea-backup.lock" +readonly LOG_FILE="/var/log/gitea-backup.log" +readonly REQUIRED_FREE_KB=646368 + +workspace="" +workspace_preserved=0 +log_ready=0 + +log_status() { + local phase="$1" + local status="$2" + local category="$3" + + if [ "${log_ready}" -eq 1 ]; then + printf '%s phase=%s status=%s category=%s\n' \ + "$(date -u --iso-8601=seconds)" \ + "${phase}" \ + "${status}" \ + "${category}" >> "${LOG_FILE}" + else + printf '%s\n' \ + "gitea-backup phase=${phase} status=${status} category=${category}" >&2 + fi +} + +fail_without_workspace() { + log_status "$1" "FAILURE" "$2" + exit 1 +} + +workspace_is_safe() { + [ -n "${workspace}" ] && + [ -d "${workspace}" ] && + [ ! -L "${workspace}" ] && + [ "$(dirname -- "${workspace}")" = "${STAGING_DIR}" ] && + [ "$(stat -c '%u:%g:%a' -- "${workspace}")" = "0:0:700" ] +} + +remove_workspace() { + workspace_is_safe || return 1 + rm -rf -- "${workspace}" +} + +preserve_workspace() { + workspace_is_safe || return 1 + + if find -- "${workspace}" -xdev -type l -print -quit 2>/dev/null | + grep -q . >/dev/null 2>&1; then + return 1 + fi + + if find -- "${workspace}" -xdev \ + ! -type d ! -type f ! -type l \ + -print -quit 2>/dev/null | + grep -q . >/dev/null 2>&1; then + return 1 + fi + + if ! find -- "${workspace}" -xdev -type d \ + -exec chmod 700 -- {} + >/dev/null 2>&1; then + return 1 + fi + + if ! find -- "${workspace}" -xdev -type f \ + -exec chmod 600 -- {} + >/dev/null 2>&1; then + return 1 + fi + + workspace_preserved=1 + trap - EXIT + return 0 +} + +fail_after_workspace() { + log_status "$1" "FAILURE" "$2" + + if ! preserve_workspace; then + log_status "PRESERVATION" "FAILURE" "WorkspacePreservationFailed" + fi + + exit 1 +} + +cleanup_on_exit() { + local rc=$? + + if [ "${workspace_preserved}" -eq 0 ]; then + remove_workspace >/dev/null 2>&1 || true + fi + + exit "${rc}" +} + +require_command() { + if ! command -v "$1" >/dev/null 2>&1; then + fail_without_workspace "PREFLIGHT" "RequiredCommandMissing" + fi +} + +validate_regular() { + if ! [ -f "$1" ] || [ -L "$1" ]; then + fail_without_workspace "PREFLIGHT" "$2" + fi +} + +validate_directory() { + if ! [ -d "$1" ] || [ -L "$1" ]; then + fail_without_workspace "PREFLIGHT" "$2" + fi +} + +validate_archive_manifest() { + local archive_path="$1" + + tar -tzf "${archive_path}" >/dev/null 2>&1 || return 1 + + tar -tf "${archive_path}" 2>/dev/null | + awk ' + BEGIN { + allowed["database"] = 1 + allowed["config"] = 1 + allowed["repositories"] = 1 + allowed["lfs"] = 1 + allowed["data"] = 1 + + required["database/"] = 0 + required["config/"] = 0 + required["repositories/"] = 0 + required["lfs/"] = 0 + required["data/"] = 0 + } + + /^\// { bad = 1; exit 1 } + /(^|\/)\.\.(\/|$)/ { bad = 1; exit 1 } + + { + member = $0 + split(member, parts, "/") + root = parts[1] + + if (!(root in allowed)) { + bad = 1 + exit 1 + } + + if (member in required) { + required[member] = 1 + } + } + + END { + if (bad) { + exit 1 + } + + for (entry in required) { + if (required[entry] != 1) { + exit 1 + } + } + }' +} + +copy_source() { + if ! cp -a -- "$1" "$2"; then + fail_after_workspace "STAGING" "$3" + fi +} + +main() { + local required_command="" + local archive_root="" + local run_id="" + local archive_name="" + local archive_path="" + local checksum_path="" + local archive_object="" + local checksum_object="" + + for required_command in \ + flock df awk mktemp mkdir sqlite3 cp tar sha256sum gcloud \ + stat find chmod rm dirname grep date printf; do + require_command "${required_command}" + done + + if ! [ -f "${LOG_FILE}" ] || [ -L "${LOG_FILE}" ]; then + printf '%s\n' \ + "gitea-backup phase=PREFLIGHT status=FAILURE category=LogFileInvalid" >&2 + exit 1 + fi + + if [ "$(stat -c '%u:%g:%a' -- "${LOG_FILE}")" != "0:0:600" ]; then + printf '%s\n' \ + "gitea-backup phase=PREFLIGHT status=FAILURE category=LogFilePermissionsInvalid" >&2 + exit 1 + fi + + if ! [ -w "${LOG_FILE}" ]; then + printf '%s\n' \ + "gitea-backup phase=PREFLIGHT status=FAILURE category=LogFileUnavailable" >&2 + exit 1 + fi + + log_ready=1 + log_status "PREFLIGHT" "START" "BackupJob" + + if ! [ -d "${STAGING_DIR}" ] || [ -L "${STAGING_DIR}" ]; then + fail_without_workspace "PREFLIGHT" "StagingDirectoryInvalid" + fi + + if [ "$(stat -c '%u:%g:%a' -- "${STAGING_DIR}")" != "0:0:700" ]; then + fail_without_workspace "PREFLIGHT" "StagingDirectoryPermissionsInvalid" + fi + + if ! [ -w "${STAGING_DIR}" ]; then + fail_without_workspace "PREFLIGHT" "StagingDirectoryUnavailable" + fi + + if ! df --output=avail -- "${STAGING_DIR}" 2>/dev/null | + awk -v required_kb="${REQUIRED_FREE_KB}" ' + NR == 2 { + checked = 1 + exit !($1 > required_kb) + } + END { + if (!checked) { + exit 1 + } + }'; then + fail_without_workspace "PREFLIGHT" "InsufficientStagingCapacity" + fi + + validate_regular "${APP_INI_PATH}" "AppIniInvalid" + validate_regular "${SQLITE_DB_PATH}" "SQLiteDatabaseInvalid" + + validate_directory "${REPO_PATH}" "RepositoriesInvalid" + validate_directory "${LFS_PATH}" "LfsInvalid" + validate_directory "${ATTACHMENTS_PATH}" "AttachmentsInvalid" + validate_directory "${AVATARS_PATH}" "AvatarsInvalid" + validate_directory "${PACKAGES_PATH}" "PackagesInvalid" + validate_directory "${REPO_ARCHIVE_PATH}" "RepositoryArchivesInvalid" + validate_directory "${REPO_AVATARS_PATH}" "RepositoryAvatarsInvalid" + + if ! workspace="$(mktemp -d -p "${STAGING_DIR}" "backup.XXXXXX")"; then + fail_without_workspace "PREFLIGHT" "WorkspaceCreationFailed" + fi + + if ! workspace_is_safe; then + fail_after_workspace "PREFLIGHT" "WorkspaceValidationFailed" + fi + + trap cleanup_on_exit EXIT + + archive_root="${workspace}/archive_root" + + if ! mkdir -p -- \ + "${archive_root}/database" \ + "${archive_root}/config" \ + "${archive_root}/data"; then + fail_after_workspace "STAGING" "ArchiveRootCreationFailed" + fi + + log_status "DB_COPY" "START" "SQLiteOnlineBackup" + if ! sqlite3 "${SQLITE_DB_PATH}" \ + ".backup '${archive_root}/database/gitea.db'"; then + fail_after_workspace "DB_COPY" "SQLiteBackupFailed" + fi + log_status "DB_COPY" "SUCCESS" "SQLiteOnlineBackupComplete" + + log_status "STAGING" "START" "CopyingData" + + copy_source "${APP_INI_PATH}" \ + "${archive_root}/config/app.ini" \ + "CopyAppIniFailed" + + copy_source "${REPO_PATH}" \ + "${archive_root}/repositories" \ + "CopyRepositoriesFailed" + + copy_source "${LFS_PATH}" \ + "${archive_root}/lfs" \ + "CopyLfsFailed" + + copy_source "${ATTACHMENTS_PATH}" \ + "${archive_root}/data/attachments" \ + "CopyAttachmentsFailed" + + copy_source "${AVATARS_PATH}" \ + "${archive_root}/data/avatars" \ + "CopyAvatarsFailed" + + copy_source "${PACKAGES_PATH}" \ + "${archive_root}/data/packages" \ + "CopyPackagesFailed" + + copy_source "${REPO_ARCHIVE_PATH}" \ + "${archive_root}/data/repo-archive" \ + "CopyRepositoryArchivesFailed" + + copy_source "${REPO_AVATARS_PATH}" \ + "${archive_root}/data/repo-avatars" \ + "CopyRepositoryAvatarsFailed" + + log_status "STAGING" "SUCCESS" "CopyComplete" + + run_id="${workspace##*/}" + archive_name="gitea-backup-${run_id}.tar.gz" + archive_path="${workspace}/${archive_name}" + checksum_path="${archive_path}.sha256" + archive_object="${GCS_BUCKET}/${GCS_PREFIX}/${archive_name}" + checksum_object="${GCS_BUCKET}/${GCS_PREFIX}/${archive_name}.sha256" + + log_status "ARCHIVE" "START" "TarCreation" + if ! tar -czf "${archive_path}" \ + -C "${archive_root}" \ + database config repositories lfs data; then + fail_after_workspace "ARCHIVE" "TarCreationFailed" + fi + + if ! validate_archive_manifest "${archive_path}"; then + fail_after_workspace "ARCHIVE" "ManifestInvalid" + fi + log_status "ARCHIVE" "SUCCESS" "ManifestValidated" + + if ! ( + cd -- "${workspace}" + sha256sum -b -- "${archive_name}" > "${checksum_path}" + ); then + fail_after_workspace "CHECKSUM" "ChecksumCreationFailed" + fi + log_status "CHECKSUM" "SUCCESS" "ChecksumGenerated" + + log_status "UPLOAD_ARCHIVE" "START" "GCS" + if ! gcloud storage cp \ + --if-generation-match=0 \ + --quiet \ + "${archive_path}" \ + "${archive_object}" >/dev/null 2>&1; then + fail_after_workspace "UPLOAD_ARCHIVE" "ArchiveUploadFailed" + fi + log_status "UPLOAD_ARCHIVE" "SUCCESS" "ArchiveUploadComplete" + + log_status "UPLOAD_CHECKSUM" "START" "GCS" + if ! gcloud storage cp \ + --if-generation-match=0 \ + --quiet \ + "${checksum_path}" \ + "${checksum_object}" >/dev/null 2>&1; then + fail_after_workspace "UPLOAD_CHECKSUM" "ChecksumUploadFailed" + fi + log_status "UPLOAD_CHECKSUM" "SUCCESS" "ChecksumUploadComplete" + + if ! remove_workspace; then + fail_after_workspace "CLEANUP" "WorkspaceRemovalFailed" + fi + + workspace="" + trap - EXIT + log_status "JOB" "SUCCESS" "BackupComplete" +} + +( + flock -n 200 || { + printf '%s\n' \ + "gitea-backup phase=PREFLIGHT status=FAILURE category=LockHeld" >&2 + exit 1 + } + + main +) 200>"${LOCK_FILE}" diff --git a/opax-mcp/quick_verify.py b/opax-mcp/quick_verify.py new file mode 100644 index 0000000..0e62ae0 --- /dev/null +++ b/opax-mcp/quick_verify.py @@ -0,0 +1,16 @@ +from importlib.util import module_from_spec, spec_from_file_location +from pathlib import Path +import logging + +# Suppress logger during import to avoid printing config values +logging.getLogger("osvx_mcp_server_under_test").setLevel(logging.WARNING) + +SERVER_PATH = Path(__file__).with_name("server.py") +spec = spec_from_file_location("osvx_mcp_server_under_test", SERVER_PATH) +server = module_from_spec(spec) +spec.loader.exec_module(server) + +print(f"REGISTERED_TOOL_COUNT={len(server.TOOLS)}") +print("--- REGISTERED_TOOL_NAMES ---") +for name in sorted(server.TOOLS.keys()): + print(name) diff --git a/opax-mcp/server.py b/opax-mcp/server.py index 803004e..acd50a1 100644 --- a/opax-mcp/server.py +++ b/opax-mcp/server.py @@ -4,6 +4,7 @@ Auth: Authorization: Bearer OR X-MCP-Secret: OR api-key: < """ import os import json +import uuid import httpx import base64 import google.auth @@ -15,88 +16,77 @@ from google.oauth2 import service_account from fastapi import FastAPI, Request, HTTPException from fastapi.responses import JSONResponse from email.mime.text import MIMEText +from email.mime.text import MIMEText from datetime import datetime, timezone, timedelta from typing import Any, Optional import logging +from pydantic import BaseModel -# Absolute import for local handlers -from gitea_handler import handle_get_file_content, handle_list_repo_files + +class ConfirmationRequired(BaseModel): + message: str + preview: dict + requires_confirmation: bool = True + +def require_confirmation(confirm: bool, preview: dict, message: str = "This action requires confirmation"): + if not confirm: + raise HTTPException( + status_code=400, + detail=ConfirmationRequired(message=message, preview=preview).model_dump() + ) logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) -app = FastAPI(title="opax-mcp", version="3.6.0") +app = FastAPI(title="opax-mcp", version="3.5.0") # BUMPED # ── Service discovery ─────────────────────────────────────────────────────── -OSVAUCO_AGENT_URL = os.environ.get("OSVAUCO_AGENT_URL", "") +OSVAUCO_AGENT_URL = os.environ.get("OSVAUCO_AGENT_URL", "") # f.eks. https://osvauco-agent-....run.app MCP_SECRET = os.environ.get("MCP_SECRET", "") INTERNAL_API_KEY = os.environ.get("INTERNAL_API_KEY", "") -GITEA_URL = os.environ.get("GITEA_URL", "http://34.170.51.84:3000") +GITEA_URL = os.environ.get("GITEA_URL", "http://34.59.131.162:3000") GITEA_TOKEN = os.environ.get("GITEA_TOKEN", "") GITEA_REPO = os.environ.get("GITEA_REPO", "chris/OSVauco") GOOGLE_CLOUD_PROJECT = os.environ.get("GOOGLE_CLOUD_PROJECT", "propane-will-491900-m5") WORKSPACE_ADMIN_USER = "chris.christiansen@vauco.no" -# Emma-vm — HTTP-server (port 8765) og Ollama-fallback (port 11434) -EMMA_SERVER_URL = os.environ.get("EMMA_SERVER_URL", "http://34.13.238.133:8765") +# Emma-vm Ollama — direkte tilkobling OLLAMA_BASE_URL = os.environ.get("OLLAMA_BASE_URL", "http://34.13.238.133:11434") EMMA_MODEL = os.environ.get("EMMA_MODEL", "gemma3:27b") EMMA_FAST_MODEL = os.environ.get("EMMA_FAST_MODEL", "gemma3:4b") -EMMA_LIGHT_MODEL = os.environ.get("EMMA_LIGHT_MODEL", "qwen2.5-coder:7b") +EMMA_LIGHT_MODEL = os.environ.get("EMMA_LIGHT_MODEL", "qwen2.5:3b") logger.info(f"OSVAUCO_AGENT_URL: {OSVAUCO_AGENT_URL}") -logger.info(f"EMMA_SERVER_URL: {EMMA_SERVER_URL}") logger.info(f"OLLAMA_BASE_URL: {OLLAMA_BASE_URL}") # --------------------------------------------------------------------------- -# Auth +# Auth (kun for innkommende kall til opax-mcp, f.eks. fra Gemini TUI) # --------------------------------------------------------------------------- -def _verify_auth(request: Request): - if not MCP_SECRET: - logger.warning("MCP_SECRET is not set, skipping auth verification.") - return +async def _verify_auth(request: Request) -> None: + secret = os.getenv("MCP_SECRET") + if not secret: + raise HTTPException(status_code=500, detail="MCP_SECRET not configured") auth = request.headers.get("Authorization", "") - if auth.startswith("Bearer ") and auth[7:] == MCP_SECRET: - return - if request.headers.get("X-MCP-Secret") == MCP_SECRET: - return - if request.headers.get("api-key") == MCP_SECRET: - return - raise HTTPException(status_code=401, detail="Unauthorized") + if not auth.startswith("Bearer ") or auth[7:] != secret: + raise HTTPException(status_code=401, detail="Invalid or missing token") # --------------------------------------------------------------------------- -# Backend Agent helpers +# Backend Agent helpers (for kall VIDERE til osvauco-agent) # --------------------------------------------------------------------------- -def _get_oidc_token(audience: str) -> str: - try: - token_url = f"http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/identity?audience={audience}&format=full" - r = httpx.get(token_url, headers={"Metadata-Flavor": "Google"}) - if r.is_success: - return r.text - logging.info("Metadata server failed, falling back to ADC for OIDC token.") - creds, project = google.auth.default(scopes=["https://www.googleapis.com/auth/cloud-platform"]) - auth_req = google.auth.transport.requests.Request() - creds.refresh(auth_req) - id_token = google.oauth2.id_token.fetch_id_token(auth_req, audience) - return id_token - except Exception as e: - logging.error(f"Failed to get OIDC token for audience {audience}: {e}", exc_info=True) - raise HTTPException(status_code=500, detail=f"Could not obtain OIDC token for backend service. Error: {e}") - def _agent_headers() -> dict: - audience = OSVAUCO_AGENT_URL.split('/')[0] + '//' + OSVAUCO_AGENT_URL.split('/')[2] - token = _get_oidc_token(audience=audience) + """Headers for maskin-til-maskin kall videre til osvauco-agent via X-Internal-Key.""" return { - "Authorization": f"Bearer {token}", + "X-Internal-Key": INTERNAL_API_KEY, # Bruker den nye delte nøkkelen "Content-Type": "application/json" } async def _agent_get(path: str) -> Any: + """GET-kall til osvauco-agent.""" url = f"{OSVAUCO_AGENT_URL}{path}" async with httpx.AsyncClient(timeout=30) as c: r = await c.get(url, headers=_agent_headers()) @@ -104,6 +94,7 @@ async def _agent_get(path: str) -> Any: return r.json() async def _agent_post(path: str, body: dict) -> Any: + """POST-kall til osvauco-agent.""" url = f"{OSVAUCO_AGENT_URL}{path}" async with httpx.AsyncClient(timeout=45) as c: r = await c.post(url, json=body, headers=_agent_headers()) @@ -115,6 +106,10 @@ async def _agent_post(path: str, body: dict) -> Any: # --------------------------------------------------------------------------- def _get_workspace_service(api: str, version: str, scopes: list): + """ + Creates an authenticated Google API service object using a service account + key from Secret Manager and impersonating the admin user. + """ try: client = secretmanager.SecretManagerServiceClient() secret_name = "workspace-sa-key" @@ -132,6 +127,7 @@ def _get_workspace_service(api: str, version: str, scopes: list): raise async def create_email_alias(p: dict) -> dict: + """Creates a new email alias for a Google Workspace user.""" user_key = p.get("user_key") alias_email = p.get("alias") if not user_key or not alias_email: @@ -140,26 +136,34 @@ async def create_email_alias(p: dict) -> dict: alias_body = {'alias': alias_email} logger.info(f"Creating alias '{alias_email}' for user '{user_key}'...") result = service.users().aliases().insert(userKey=user_key, body=alias_body).execute() + logger.info(f"Successfully created alias.") return {"status": "success", "alias": result} async def list_user_aliases(p: dict) -> dict: + """Lists all aliases for a Google Workspace user.""" user_key = p.get("user_key") if not user_key: raise ValueError("Missing required parameter: 'user_key'") service = _get_workspace_service('admin', 'directory_v1', ['https://www.googleapis.com/auth/admin.directory.user']) + logger.info(f"Listing aliases for user '{user_key}'...") result = service.users().aliases().list(userKey=user_key).execute() + logger.info(f"Successfully listed aliases.") return {"status": "success", "aliases": result.get("aliases", [])} async def delete_email_alias(p: dict) -> dict: + """Deletes an email alias from a Google Workspace user.""" user_key = p.get("user_key") alias_email = p.get("alias") if not user_key or not alias_email: raise ValueError("Missing required parameters: 'user_key' and 'alias'") service = _get_workspace_service('admin', 'directory_v1', ['https://www.googleapis.com/auth/admin.directory.user']) + logger.info(f"Deleting alias '{alias_email}' for user '{user_key}'...") service.users().aliases().delete(userKey=user_key, alias=alias_email).execute() + logger.info(f"Successfully deleted alias.") return {"status": "success", "detail": f"Alias '{alias_email}' deleted."} async def send_email_as(p: dict) -> dict: + """Sends an email as a user or their alias, on their behalf.""" from_address, to_address, subject, body = p.get("from"), p.get("to"), p.get("subject"), p.get("body") if not all([from_address, to_address, subject, body]): raise ValueError("Missing required parameters: 'from', 'to', 'subject', 'body'") @@ -168,26 +172,36 @@ async def send_email_as(p: dict) -> dict: message['to'], message['from'], message['subject'] = to_address, from_address, subject encoded_message = base64.urlsafe_b64encode(message.as_bytes()).decode() create_message = {'raw': encoded_message} + logger.info(f"Sending email from '{from_address}' to '{to_address}'...") send_message = service.users().messages().send(userId='me', body=create_message).execute() + logger.info(f"Successfully sent email with ID: {send_message['id']}") return {"status": "success", "message_id": send_message['id']} async def get_workspace_user(p: dict) -> dict: + """Gets detailed information about a single user in Google Workspace.""" user_key = p.get("user_key") if not user_key: raise ValueError("Missing required parameter: 'user_key'") service = _get_workspace_service('admin', 'directory_v1', ['https://www.googleapis.com/auth/admin.directory.user']) + logger.info(f"Getting info for user '{user_key}'...") user = service.users().get(userKey=user_key).execute() + logger.info(f"Successfully retrieved user info.") return {"name": user.get("name", {}).get("fullName"), "email": user.get("primaryEmail"), "aliases": user.get("aliases", []), "suspended": user.get("suspended"), "lastLoginTime": user.get("lastLoginTime")} async def list_workspace_users(p: dict) -> dict: + """Lists all users in the Google Workspace domain.""" service = _get_workspace_service('admin', 'directory_v1', ['https://www.googleapis.com/auth/admin.directory.user']) + logger.info("Listing all workspace users...") result = service.users().list(domain='vauco.no', maxResults=50, orderBy='email').execute() users = result.get('users', []) + logger.info(f"Found {len(users)} users.") return {"users": [{"email": user.get("primaryEmail"), "name": user.get("name", {}).get("fullName")} for user in users]} async def get_emails(p: dict) -> dict: + """Searches a user's mailbox and retrieves a list of emails.""" user_key, query, max_results = p.get('user_key'), p.get('query', ''), p.get('max_results', 10) if not user_key: raise ValueError("Missing required parameter: 'user_key'") service = _get_workspace_service('gmail', 'v1', ['https://www.googleapis.com/auth/gmail.modify']) + logger.info(f"Searching emails for user '{user_key}' with query '{query}'...") list_result = service.users().messages().list(userId=user_key, q=query, maxResults=max_results).execute() messages = list_result.get('messages', []) if not messages: return {"messages": []} @@ -196,82 +210,67 @@ async def get_emails(p: dict) -> dict: detail = service.users().messages().get(userId=user_key, id=msg['id'], format='metadata', metadataHeaders=['subject', 'from']).execute() headers = {h['name']: h['value'] for h in detail['payload']['headers']} email_details.append({"id": msg['id'], "snippet": detail.get('snippet'), "subject": headers.get('Subject'), "from": headers.get('From')}) + logger.info(f"Successfully retrieved {len(email_details)} emails.") return {"messages": email_details} async def list_calendar_events(p: dict) -> dict: + """Lists events from a user's calendar.""" user_key, days_ahead = p.get('user_key'), p.get('days_ahead', 7) if not user_key: raise ValueError("Missing required parameter: 'user_key'") service = _get_workspace_service('calendar', 'v3', ['https://www.googleapis.com/auth/calendar']) + logger.info(f"Fetching calendar events for '{user_key}' for the next {days_ahead} days...") now, time_min = datetime.now(timezone.utc), (datetime.now(timezone.utc)).isoformat() time_max = (now + timedelta(days=days_ahead)).isoformat() events_result = service.events().list(calendarId=user_key, timeMin=time_min, timeMax=time_max, singleEvents=True, orderBy='startTime').execute() events = events_result.get('items', []) formatted_events = [{"summary": event.get('summary'), "start": event.get('start', {}).get('dateTime', event.get('start', {}).get('date')), "end": event.get('end', {}).get('dateTime', event.get('end', {}).get('date')), "organizer": event.get('organizer', {}).get('email')} for event in events] + logger.info(f"Found {len(formatted_events)} events.") return {"events": formatted_events} async def trigger_build(p: dict) -> dict: + """Trigger en Cloud Build manuelt ved å sende kildekode fra Gitea.""" import subprocess repo = p.get("repo", "OSVauco") branch = p.get("branch", "main") config = p.get("config", "cloudbuild.mcp.yaml") + result = subprocess.run([ "gcloud", "builds", "submit", f"/home/chris_christiansen/OSVauco", f"--config={config}", f"--project={GOOGLE_CLOUD_PROJECT}" ], capture_output=True, text=True) + if result.returncode != 0: - return {"status": "error", "message": result.stderr.strip()} + error_message = result.stderr.strip() + logger.error(f"Cloud Build trigger failed: {error_message}") + return {"status": "error", "message": error_message} + build_id = "Not found in output" for line in result.stdout.split('\n'): if "ID:" in line: build_id = line.split("ID:")[1].strip() break + return {"status": "triggered", "build_id": build_id} # --------------------------------------------------------------------------- -# Emma HTTP-server helpers — primær rute via port 8765 -# --------------------------------------------------------------------------- - -async def _emma_chat(prompt: str, system: str = "", mode: str = "chat") -> dict: - """Kaller Emma HTTP-serveren (port 8765) med fallback til Ollama.""" - payload = {"prompt": prompt, "system": system, "mode": mode} - try: - async with httpx.AsyncClient(timeout=180) as c: - r = await c.post(f"{EMMA_SERVER_URL}/chat", json=payload) - r.raise_for_status() - return r.json() - except Exception as e: - logger.warning(f"[EMMA SERVER] Fallback til Ollama: {type(e).__name__}: {e}") - return await _ollama_chat(EMMA_MODEL, prompt, system) - -async def _emma_models() -> list: - """Henter modeller fra Emma-serveren, med fallback til Ollama.""" - try: - async with httpx.AsyncClient(timeout=10) as c: - r = await c.get(f"{EMMA_SERVER_URL}/models") - if r.is_success: - return r.json().get("models", []) - except Exception: - pass - return await _ollama_models() - -# --------------------------------------------------------------------------- -# Ollama helpers — direkte fallback mot emma-gpu-vm +# Ollama helpers — direkte mot emma-gpu-vm # --------------------------------------------------------------------------- async def _ollama_chat(model: str, prompt: str, system: str = "") -> dict: + # ... (beholdt uendret) messages = [] if system: messages.append({"role": "system", "content": system}) messages.append({"role": "user", "content": prompt}) - payload = {"model": model, "messages": messages, "stream": False} + payload = { "model": model, "messages": messages, "stream": False } try: async with httpx.AsyncClient(timeout=120) as c: r = await c.post(f"{OLLAMA_BASE_URL}/api/chat", json=payload) r.raise_for_status() data = r.json() - return {"model": model, "response": data.get("message", {}).get("content", ""), "done": data.get("done", True), "total_duration_ms": round(data.get("total_duration", 0) / 1e6)} + return { "model": model, "response": data.get("message", {}).get("content", ""), "done": data.get("done", True), "total_duration_ms": round(data.get("total_duration", 0) / 1e6) } except Exception as e: logger.error(f"[OLLAMA ERROR] model={model} {type(e).__name__}: {e}") raise @@ -290,18 +289,21 @@ def _gitea_headers() -> dict: return {"Authorization": f"token {GITEA_TOKEN}", "Content-Type": "application/json", "Accept": "application/json"} async def _gitea_get(path: str) -> Any: + # ... (beholdt uendret) async with httpx.AsyncClient(timeout=30) as c: r = await c.get(f"{GITEA_URL}/api/v1{path}", headers=_gitea_headers()) r.raise_for_status() return r.json() async def _gitea_post(path: str, body: dict) -> Any: + # ... (beholdt uendret) async with httpx.AsyncClient(timeout=30) as c: r = await c.post(f"{GITEA_URL}/api/v1{path}", json=body, headers=_gitea_headers()) r.raise_for_status() return r.json() async def _gitea_put(path: str, body: dict) -> Any: + # ... (beholdt uendret) async with httpx.AsyncClient(timeout=30) as c: r = await c.put(f"{GITEA_URL}/api/v1{path}", json=body, headers=_gitea_headers()) r.raise_for_status() @@ -309,7 +311,7 @@ async def _gitea_put(path: str, body: dict) -> Any: # --------------------------------------------------------------------------- -# Tool implementations +# Tool implementations (refaktorert til å bruke _agent_get/_agent_post) # --------------------------------------------------------------------------- async def get_billing_summary(p): return await _agent_get("/billing/summary") @@ -343,39 +345,20 @@ async def get_telemetry(p): return await _agent_get("/telemetry/h async def run_terminal(p): return await _agent_post("/terminal/exec", {"cmd": p.get("command", p.get("cmd", "help"))}) async def tui_command(p): return await _agent_post("/tui-command", p) -# --- Gitea delegators --- -async def get_file_content(p): - return await handle_get_file_content(p, default_repo=GITEA_REPO) - -async def list_repo_files(p): - return await handle_list_repo_files(p, default_repo=GITEA_REPO) - -# --- AI Agents --- +# --- Uendrede funksjoner --- async def run_jason(p): + """Kaller /run på osvauco-agent, som nå har sin egen JASON_BACKEND-logikk.""" return await _agent_post("/run", {"message": p.get("prompt", p.get("message", "")), "user_id": p.get("user_id", "opax"), "session_id": p.get("session_id", "mcp"), "mode": p.get("mode", "light")}) - -async def run_emma(p): - """Kaller Emma HTTP-serveren (port 8765) med automatisk fallback til Ollama.""" - return await _emma_chat( - prompt=p.get("prompt", p.get("message", "")), - system=p.get("system", "Du er Emma Vauger, en intelligent og hjelpsom AI-assistent for Vauco-plattformen."), - mode=p.get("mode", "chat") - ) - -async def run_emma_fast(p): - return await _ollama_chat(EMMA_FAST_MODEL, p.get("prompt", p.get("message", "")), p.get("system", "Du er en rask og konsis AI-assistent...")) - -async def run_qwen(p): - return await _ollama_chat(EMMA_LIGHT_MODEL, p.get("prompt", p.get("message", ""))) - -async def list_emma_models(p): - return await _emma_models() - +async def run_emma(p): return await _ollama_chat(EMMA_MODEL, p.get("prompt", p.get("message", "")), p.get("system", "Du er Emma Vauger...")) +async def run_emma_fast(p): return await _ollama_chat(EMMA_FAST_MODEL, p.get("prompt", p.get("message", "")), p.get("system", "Du er en rask og konsis AI-assistent...")) +async def run_qwen(p): return await _ollama_chat(EMMA_LIGHT_MODEL, p.get("prompt", p.get("message", ""))) +async def list_emma_models(p): return await _ollama_models() async def list_commits(p): return await _gitea_get(f"/repos/{p.get('repo', GITEA_REPO)}/commits?limit={p.get('limit', 10)}") async def get_file(p): return await _gitea_get(f"/repos/{p.get('repo', GITEA_REPO)}/contents/{p.get('path', '')}?ref={p.get('ref', 'main')}") async def list_open_issues(p): return await _gitea_get(f"/repos/{p.get('repo', GITEA_REPO)}/issues?state=open&limit=20") async def create_issue(p): return await _gitea_post(f"/repos/{p.get('repo', GITEA_REPO)}/issues", {"title": p.get("title"), "body": p.get("body", "")}) -async def push_file(p): +async def push_file(p): + # ... (beholdt uendret) repo, path = p.get("repo", GITEA_REPO), p.get("path") content = base64.b64encode(p.get("content", "").encode()).decode() body = {"message": p.get("message", f"chore: update {path} via opax-mcp"), "content": content, "branch": p.get("branch", "main")} @@ -388,129 +371,6 @@ async def push_file(p): if "sha" in body: return await _gitea_put(f"/repos/{repo}/contents/{path}", body) return await _gitea_post(f"/repos/{repo}/contents/{path}", body) -async def describe_service(p: dict) -> dict: - import subprocess - service = p.get("service", "opax-mcp") - region = p.get("region", "us-central1") - result = subprocess.run( - ["gcloud", "run", "services", "describe", service, f"--region={region}", f"--project={GOOGLE_CLOUD_PROJECT}", "--format=json"], - capture_output=True, text=True, check=False - ) - if result.returncode != 0: - return {"error": result.stderr} - return json.loads(result.stdout) - -async def list_gce_instances(p: dict) -> dict: - import subprocess - result = subprocess.run( - ["gcloud", "compute", "instances", "list", f"--project={GOOGLE_CLOUD_PROJECT}", "--format=json"], - capture_output=True, text=True, check=False - ) - if result.returncode != 0: - return {"error": result.stderr} - instances = json.loads(result.stdout) - for instance in instances: - instance['choices'] = [] - zone = instance['zone'].split('/')[-1] - if instance['status'] == 'RUNNING': - instance['choices'].append({"id": f"tool_call:stop_gce_instance:instance={instance['name']},zone={zone}", "label": "Stopp VM", "style": "danger"}) - elif instance['status'] == 'TERMINATED': - instance['choices'].append({"id": f"tool_call:start_gce_instance:instance={instance['name']},zone={zone}", "label": "Start VM", "style": "primary"}) - return {"instances": instances} - -async def list_service_revisions(p: dict) -> dict: - import subprocess - service = p.get("service", "opax-mcp") - region = p.get("region", "us-central1") - result = subprocess.run( - ["gcloud", "run", "revisions", "list", f"--service={service}", f"--region={region}", f"--project={GOOGLE_CLOUD_PROJECT}", "--format=json"], - capture_output=True, text=True, check=False - ) - if result.returncode != 0: - return {"error": result.stderr} - return {"revisions": json.loads(result.stdout)} - -async def get_cloud_run_logs(p: dict) -> dict: - import subprocess - service = p.get("service", "opax-mcp") - limit = p.get("limit", "50") - result = subprocess.run( - ["gcloud", "logging", "read", f'resource.type="cloud_run_revision" AND resource.labels.service_name="{service}"', f"--project={GOOGLE_CLOUD_PROJECT}", f"--limit={limit}", "--format=json"], - capture_output=True, text=True, check=False - ) - if result.returncode != 0: - return {"error": result.stderr} - return {"logs": json.loads(result.stdout)} - -async def list_builds(p: dict) -> dict: - import subprocess - limit = p.get("limit", "20") - result = subprocess.run( - ["gcloud", "builds", "list", f"--project={GOOGLE_CLOUD_PROJECT}", f"--limit={limit}", "--format=json"], - capture_output=True, text=True, check=False - ) - if result.returncode != 0: - return {"error": result.stderr} - return {"builds": json.loads(result.stdout)} - -async def get_build_log(p: dict) -> dict: - import subprocess - build_id = p.get("build_id") - if not build_id: - raise ValueError("Missing required parameter: 'build_id'") - result = subprocess.run( - ["gcloud", "builds", "log", build_id, f"--project={GOOGLE_CLOUD_PROJECT}"], - capture_output=True, text=True, check=False - ) - if result.returncode != 0: - return {"error": result.stderr} - return {"log": result.stdout} - -async def get_connector_status(p: dict) -> tuple: - gitea_ok = False - try: - await _gitea_get("/version") - gitea_ok = True - except Exception: - gitea_ok = False - - emma_ok = False - try: - async with httpx.AsyncClient(timeout=5) as c: - r = await c.get(f"{EMMA_SERVER_URL}/health") - emma_ok = r.is_success - except Exception: - emma_ok = False - - status_text = f"OPAX-MCP: Live\nGitea: {'Online' if gitea_ok else 'Offline'}\nEmma: {'Online (8765)' if emma_ok else 'Offline'}\nTools: {len(TOOLS)} tilgjengelig" - raw_data = {"opax_mcp_status": "live", "gitea_status": "online" if gitea_ok else "offline", "emma_status": "online" if emma_ok else "offline", "tool_count": len(TOOLS)} - custom_meta = {"status": status_text, "next_action": "Klar for kommandoer.", "choices": [{"id": "list_tools", "label": "List alle verktøy", "style": "primary"}]} - return (raw_data, custom_meta) - -async def start_gce_instance(p: dict) -> tuple: - import subprocess - instance, zone, confirm = p.get("instance"), p.get("zone", "us-central1-b"), p.get("confirm", False) - if not instance: raise ValueError("Mangler 'instance'-parameter.") - if not confirm: - raw_data = {"action": "confirm_start", "instance": instance, "zone": zone} - custom_meta = {"status": f"Ønsker du å starte '{instance}'?", "next_action": "Instansen vil bli skrudd på.", "choices": [{"id": f'tool_call:start_gce_instance:instance={instance},zone={zone},confirm=true', "label": f"Ja, start '{instance}'", "style": "danger"}, {"id": "cancel", "label": "Avbryt", "style": "secondary"}]} - return (raw_data, custom_meta) - result = subprocess.run(["gcloud", "compute", "instances", "start", instance, f"--zone={zone}", f"--project={GOOGLE_CLOUD_PROJECT}"], capture_output=True, text=True, check=False) - if result.returncode != 0: return ({"error": result.stderr}, {"status": "Feil", "next_action": "Sjekk instans-navn og rettigheter."}) - return ({"status": f"Start-kommando sendt for {instance}."}, {"status": f"Start-kommando sendt til '{instance}'.", "next_action": "Vent 30s og sjekk status med `list_gce_instances`."}) - -async def stop_gce_instance(p: dict) -> tuple: - import subprocess - instance, zone, confirm = p.get("instance"), p.get("zone", "us-central1-b"), p.get("confirm", False) - if not instance: raise ValueError("Mangler 'instance'-parameter.") - if not confirm: - raw_data = {"action": "confirm_stop", "instance": instance, "zone": zone} - custom_meta = {"status": f"ADVARSEL: Stoppe '{instance}'?", "next_action": "Dette skrur av den virtuelle maskinen.", "choices": [{"id": f'tool_call:stop_gce_instance:instance={instance},zone={zone},confirm=true', "label": f"Ja, stopp '{instance}'", "style": "danger"}, {"id": "cancel", "label": "Avbryt", "style": "secondary"}]} - return (raw_data, custom_meta) - result = subprocess.run(["gcloud", "compute", "instances", "stop", instance, f"--zone={zone}", f"--project={GOOGLE_CLOUD_PROJECT}"], capture_output=True, text=True, check=False) - if result.returncode != 0: return ({"error": result.stderr}, {"status": "Feil", "next_action": "Sjekk instans-navn og rettigheter."}) - return ({"status": f"Stopp-kommando sendt for {instance}."}, {"status": f"Stopp-kommando sendt til '{instance}'.", "next_action": "Vent 30s og sjekk status med `list_gce_instances`."}) - # --------------------------------------------------------------------------- # Tool registry + MCP schema # --------------------------------------------------------------------------- @@ -574,27 +434,13 @@ TOOLS = { # System & Ops "get_health": (get_health, "Hent helsestatus for OPAX", {}), "get_build_status": (get_build_status, "Hent siste build-status", {}), - "trigger_build": (trigger_build, "Trigger en Cloud Build manuelt", {"type":"object","properties":{"repo":{"type":"string"},"branch":{"type":"string"},"config":{"type":"string"}},"required":["repo","branch","config"]}), + "trigger_build": (trigger_build, "Trigger en Cloud Build manuelt", {"type":"object","properties":{"repo":{"type":"string"},"branch":{"type":"string"},"config":{"type":"string"}}}), "get_state": (get_state, "Hent platform-tilstand", {}), "list_customers": (list_customers, "List alle kunder (alias for get_state)", {}), "run_terminal": (run_terminal, "Kjør terminalkommando på VM", {"type":"object","properties":{"command":{"type":"string"}}}), - "get_connector_status": (get_connector_status, "Hent en rask helsestatus for konnektoren", {}), - # Ops / GCE - "describe_service": (describe_service, "Hent detaljer for en Cloud Run service", {"type":"object","properties":{"service":{"type":"string"}}}), - "list_gce_instances": (list_gce_instances, "List GCE-instanser i prosjektet", {}), - "start_gce_instance": (start_gce_instance, "Start en GCE-instans (krever bekreftelse)", {"type":"object","properties":{"instance":{"type":"string"}, "zone":{"type":"string"}, "confirm":{"type":"boolean"}},"required":["instance"]}, {"destructiveHint": True, "readOnlyHint": False, "idempotentHint": False, "openWorldHint": True}), - "stop_gce_instance": (stop_gce_instance, "Stopp en GCE-instans (krever bekreftelse)", {"type":"object","properties":{"instance":{"type":"string"}, "zone":{"type":"string"}, "confirm":{"type":"boolean"}},"required":["instance"]}, {"destructiveHint": True, "readOnlyHint": False, "idempotentHint": False, "openWorldHint": True}), - # Ops / Deploy & Logs - "list_service_revisions": (list_service_revisions, "List revisjoner for en Cloud Run service", {"type":"object","properties":{"service":{"type":"string"}}}), - "get_cloud_run_logs": (get_cloud_run_logs, "Hent logger for en Cloud Run service", {"type":"object","properties":{"service":{"type":"string"}, "limit":{"type":"string"}},"required":["service","limit"]}), - "list_builds": (list_builds, "List de siste Cloud Builds", {"type":"object","properties":{"limit":{"type":"string"}},"required":["limit"]}), - "get_build_log": (get_build_log, "Hent loggen for en spesifikk Cloud Build", {"type":"object","properties":{"build_id":{"type":"string"}},"required":["build_id"]}), # Gitea / VCS "list_commits": (list_commits, "List siste commits i Gitea-repo", {}), "get_file": (get_file, "Hent fil fra Gitea-repo", {"type":"object","properties":{"path":{"type":"string"}},"required":["path"]}), - "get_file_content": (get_file_content, "Hent innholdet i en fil fra Gitea (ny handler)", {"type":"object","properties":{"path":{"type":"string"}},"required":["path"]}), - "list_repo_files": (list_repo_files, "List filer og mapper i Gitea (ny handler)", {"type":"object","properties":{"path":{"type":"string"}}}), - "push_file": (push_file, "Opprett eller oppdater fil i Gitea-repo", {"type":"object","properties":{"path":{"type":"string"},"content":{"type":"string"}},"required":["path","content"]}), # Google Workspace "create_email_alias": (create_email_alias, "Opprett et nytt e-postalias", {"type":"object","properties":{"user_key":{"type":"string"},"alias":{"type":"string"}},"required":["user_key","alias"]}), "list_user_aliases": (list_user_aliases, "List en brukers e-postaliaser", {"type":"object","properties":{"user_key":{"type":"string"}},"required":["user_key"]}), @@ -617,47 +463,26 @@ def _jsonrpc_err(req_id, code, message): @app.post("/") async def mcp_handler(request: Request): + # ... (beholdt uendret) _verify_auth(request) try: body = await request.json() except Exception: return JSONResponse(_jsonrpc_err(None, -32700, "Parse error"), status_code=400) method, req_id, params = body.get("method", ""), body.get("id"), body.get("params", {}) if method == "initialize": - return JSONResponse(_jsonrpc_ok(req_id, {"protocolVersion": "2024-11-05", "capabilities": {"tools": {}},"serverInfo": {"name": "opax-mcp", "version": "3.6.0"}})) + return JSONResponse(_jsonrpc_ok(req_id, {"protocolVersion": "2024-11-05", "capabilities": {"tools": {}},"serverInfo": {"name": "opax-mcp", "version": "3.5.0"}})) # BUMPED if method == "tools/list": - tools_list = [] - for name, tool_tuple in TOOLS.items(): - if len(tool_tuple) == 4: - _, desc, schema, annotations = tool_tuple - else: - _, desc, schema = tool_tuple - annotations = None - tool_entry = { - "name": name, "description": desc, - "inputSchema": schema if schema else {"type": "object", "properties": {}} - } - if annotations: - tool_entry["annotations"] = annotations - tools_list.append(tool_entry) - return JSONResponse(_jsonrpc_ok(req_id, {"tools": tools_list})) + return JSONResponse(_jsonrpc_ok(req_id, {"tools": [ + {"name": name, "description": desc, "inputSchema": schema if schema else {"type": "object", "properties": {}}} + for name, (_, desc, schema) in TOOLS.items() + ]})) if method == "tools/call": tool_name, tool_args = params.get("name") or params.get("tool"), params.get("arguments", params.get("params", {})) entry = TOOLS.get(tool_name) if not entry: return JSONResponse(_jsonrpc_err(req_id, -32601, f"Unknown tool: {tool_name}")) - handler = entry[0] + handler, _, _ = entry try: - handler_result = await handler(tool_args) - if isinstance(handler_result, tuple) and len(handler_result) == 2: - raw_data, custom_meta = handler_result - else: - raw_data = handler_result - custom_meta = { - "status": "Vellykket.", - "next_action": "Se over data og fortsett.", - "choices": [{"id": "continue", "label": "Fortsett", "style": "primary"}] - } - meta_part = {"meta": custom_meta} - content_part = {"content": [{"type": "text", "text": json.dumps(raw_data, ensure_ascii=False)}]} - return JSONResponse(_jsonrpc_ok(req_id, {**meta_part, **content_part})) + result = await handler(tool_args) + return JSONResponse(_jsonrpc_ok(req_id, {"content": [{"type": "text", "text": json.dumps(result, ensure_ascii=False)}]})) except Exception as e: logger.error(f"[MCP HANDLER ERROR] tool={tool_name} {type(e).__name__}: {e}", exc_info=True) return JSONResponse(_jsonrpc_err(req_id, -32000, str(e))) @@ -670,4 +495,4 @@ async def mcp_handler(request: Request): @app.get("/health") async def health(): - return {"status": "ok", "service": "opax-mcp", "version": "3.6.0", "emma_server": EMMA_SERVER_URL, "ollama": OLLAMA_BASE_URL} + return {"status": "ok", "service": "opax-mcp", "version": "3.5.0", "ollama": OLLAMA_BASE_URL} # BUMPED