ci: test webhook deploy
This commit is contained in:
parent
16f2b86fa6
commit
3cb41172d9
103
ci/deploy-webhook-listener.py
Executable file
103
ci/deploy-webhook-listener.py
Executable 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()
|
||||
139
cloudbuild.yaml
139
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
|
||||
|
|
|
|||
82
deploy-mcp.sh
Normal file
82
deploy-mcp.sh
Normal 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
272
docs/CI_FIRST_RUN.md
Normal 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:**
|
||||
- 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://<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
386
docs/PLATFORM_MAP.md
Normal 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 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=<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:<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 trigger’s branch pattern and the mirror configuration.
|
||||
400
gitea-backup-candidate.sh
Normal file
400
gitea-backup-candidate.sh
Normal 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
16
opax-mcp/quick_verify.py
Normal 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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user