ci: test webhook deploy

This commit is contained in:
Chris Christiansen 2026-09-01 20:37:56 +00:00
parent 16f2b86fa6
commit 3cb41172d9
8 changed files with 1404 additions and 357 deletions

103
ci/deploy-webhook-listener.py Executable file
View File

@ -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()

View File

@ -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

82
deploy-mcp.sh Normal file
View File

@ -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 <staging|prod>"
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}"

272
docs/CI_FIRST_RUN.md Normal file
View File

@ -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:**
- Youre 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; youll 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://<VM_IP>: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://<VM_IP>: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`.*

386
docs/PLATFORM_MAP.md Normal file
View File

@ -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 Runbased 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=<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:
- Perplexitys 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 Managerdriven 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:<SHORT_SHA>`
Each image includes labels:
- `gcb-commit-sha=<SHORT_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 triggers branch pattern and the mirror configuration.

400
gitea-backup-candidate.sh Normal file
View File

@ -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}"

16
opax-mcp/quick_verify.py Normal file
View File

@ -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)

View File

@ -4,6 +4,7 @@ Auth: Authorization: Bearer <secret> OR X-MCP-Secret: <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