From aff36f2f35b36d2fad413f10d55ea9e74da3c0bf Mon Sep 17 00:00:00 2001 From: chrischristiansen-glitch Date: Sat, 23 May 2026 23:22:39 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20cost-guard=20komplett=20=E2=80=94=20dev?= =?UTF-8?q?-budsjett,=20Pub/Sub=20auto-teardown,=20webhook-alert,=20cost-c?= =?UTF-8?q?heck=20i=20CI/CD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cloudbuild.yaml | 46 +++++- infrastructure/01-setupenv.sh | 56 +++++-- infrastructure/10-cost-guard.sh | 156 ++++++++++++++++++ infrastructure/notifications/webhook-setup.md | 51 ++++++ 4 files changed, 295 insertions(+), 14 deletions(-) create mode 100644 infrastructure/10-cost-guard.sh create mode 100644 infrastructure/notifications/webhook-setup.md diff --git a/cloudbuild.yaml b/cloudbuild.yaml index 19494cb..c62789c 100644 --- a/cloudbuild.yaml +++ b/cloudbuild.yaml @@ -1,7 +1,20 @@ -# cloudbuild.yaml — Auto-build and deploy osvauco-agent on push to main +# cloudbuild.yaml — Auto-build, cost-check og deploy osvauco-agent på push til main steps: - # Step 1: Build Docker image + # 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 ===" + + # Steg 2: Bygg Docker-image - name: 'gcr.io/cloud-builders/docker' + id: build + waitFor: ['cost-check'] args: - 'build' - '-t' @@ -10,15 +23,19 @@ steps: - '${_REGION}-docker.pkg.dev/${PROJECT_ID}/${_ARTIFACT_REPO}/osvauco-agent:latest' - 'agents/core-logic' - # Step 2: Push image + # Steg 3: Push image - name: 'gcr.io/cloud-builders/docker' + id: push + waitFor: ['build'] args: - 'push' - '--all-tags' - '${_REGION}-docker.pkg.dev/${PROJECT_ID}/${_ARTIFACT_REPO}/osvauco-agent' - # Step 3: Deploy to Cloud Run + # Steg 4: Deploy til Cloud Run - name: 'gcr.io/google.com/cloudsdktool/cloud-sdk' + id: deploy + waitFor: ['push'] entrypoint: gcloud args: - 'run' @@ -37,6 +54,27 @@ steps: - '--max-instances=3' - '--quiet' + # Steg 5: Deploy-notifikasjon til webhook/chat-app + - name: 'gcr.io/google.com/cloudsdktool/cloud-sdk' + id: notify + waitFor: ['deploy'] + entrypoint: bash + args: + - '-c' + - | + WEBHOOK_URL=$(gcloud secrets versions access latest \ + --secret=webhook-url \ + --project=${PROJECT_ID} 2>/dev/null || echo "") + if [[ -n "${WEBHOOK_URL}" ]]; then + MSG="✅ *OSVauco Deploy fullført*\nCommit: \`$COMMIT_SHA\`\nService: \`${_CLOUD_RUN_SERVICE}\`\nRegion: \`${_REGION}\`" + curl -s -X POST "${WEBHOOK_URL}" \ + -H 'Content-Type: application/json' \ + -d "{\"text\": \"$MSG\"}" || true + echo "✓ Deploy-notifikasjon sendt" + else + echo "⚠️ Ingen webhook-url i Secret Manager — hopper over varsling" + fi + substitutions: _REGION: us-central1 _ARTIFACT_REPO: osvauco-repo diff --git a/infrastructure/01-setupenv.sh b/infrastructure/01-setupenv.sh index 377d656..396c7ea 100644 --- a/infrastructure/01-setupenv.sh +++ b/infrastructure/01-setupenv.sh @@ -1,5 +1,5 @@ #!/bin/bash -# 01-setupenv.sh — Enable APIs, create staging bucket with lifecycle, SA, billing budget +# 01-setupenv.sh — Enable APIs, create staging bucket with lifecycle, SA, billing budgets # Run in: local VS Code terminal OR Cloud Shell # Source .env before running: source .env @@ -11,7 +11,9 @@ set -euo pipefail : "${AGENT_SA:?Set AGENT_SA in .env}" BUCKET_NAME="gs://${PROJECT_ID}-agent-staging" -BUDGET_NAME="OSVauco-Agent-Budget-500USD" +BUDGET_PROD_NAME="OSVauco-Agent-Budget-500USD" +BUDGET_DEV_NAME="OSVauco-Dev-Budget-75USD" +PUBSUB_TOPIC="billing-alert-auto-teardown" echo "=== 01: Environment Setup for ${PROJECT_ID} ===" @@ -29,10 +31,13 @@ gcloud services enable \ run.googleapis.com \ artifactregistry.googleapis.com \ cloudbuild.googleapis.com \ + cloudbuildv2.googleapis.com \ secretmanager.googleapis.com \ monitoring.googleapis.com \ logging.googleapis.com \ cloudtrace.googleapis.com \ + pubsub.googleapis.com \ + cloudfunctions.googleapis.com \ --project="$PROJECT_ID" --quiet echo "✓ APIs enabled." @@ -73,24 +78,55 @@ for ROLE in \ done echo "✓ IAM bindings configured." -# 5. Billing budget alert (idempotent check by display name) -EXISTING=$(gcloud billing budgets list \ +# 5. Pub/Sub topic for billing auto-teardown (idempotent) +if ! gcloud pubsub topics describe "${PUBSUB_TOPIC}" --project="${PROJECT_ID}" >/dev/null 2>&1; then + gcloud pubsub topics create "${PUBSUB_TOPIC}" --project="${PROJECT_ID}" --quiet + echo "✓ Pub/Sub topic created: ${PUBSUB_TOPIC}" +else + echo "✓ Pub/Sub topic already exists: ${PUBSUB_TOPIC}" +fi + +# 6. Prod billing budget — $500, varsler på 50% / 80% / 100% + Pub/Sub (idempotent) +EXISTING_PROD=$(gcloud billing budgets list \ --billing-account="${BILLING_ACCOUNT_ID}" \ - --filter="displayName=${BUDGET_NAME}" \ + --filter="displayName=${BUDGET_PROD_NAME}" \ --format="value(name)" 2>/dev/null | head -1 || true) -if [[ -z "${EXISTING}" ]]; then +if [[ -z "${EXISTING_PROD}" ]]; then gcloud billing budgets create \ --billing-account="${BILLING_ACCOUNT_ID}" \ - --display-name="${BUDGET_NAME}" \ + --display-name="${BUDGET_PROD_NAME}" \ --budget-amount=500USD \ --threshold-rule=percent=0.5 \ --threshold-rule=percent=0.8 \ - --threshold-rule=percent=1.0 - echo "✓ Budget alert created" + --threshold-rule=percent=1.0 \ + --notifications-rule-pubsub-topic="projects/${PROJECT_ID}/topics/${PUBSUB_TOPIC}" + echo "✓ Prod budget alert created ($500, Pub/Sub koblet)" else - echo "✓ Budget alert already exists" + echo "✓ Prod budget alert already exists" +fi + +# 7. Dev billing budget — $75, varsler på 70% / 90% + Pub/Sub (idempotent) +EXISTING_DEV=$(gcloud billing budgets list \ + --billing-account="${BILLING_ACCOUNT_ID}" \ + --filter="displayName=${BUDGET_DEV_NAME}" \ + --format="value(name)" 2>/dev/null | head -1 || true) + +if [[ -z "${EXISTING_DEV}" ]]; then + gcloud billing budgets create \ + --billing-account="${BILLING_ACCOUNT_ID}" \ + --display-name="${BUDGET_DEV_NAME}" \ + --budget-amount=75USD \ + --threshold-rule=percent=0.7 \ + --threshold-rule=percent=0.9 \ + --threshold-rule=percent=1.0 \ + --notifications-rule-pubsub-topic="projects/${PROJECT_ID}/topics/${PUBSUB_TOPIC}" + echo "✓ Dev budget alert created ($75, Pub/Sub koblet)" +else + echo "✓ Dev budget alert already exists" fi echo "" echo "=== 01: Environment setup COMPLETE ===" +echo " Kjør nå: bash infrastructure/10-cost-guard.sh" +echo " for å aktivere auto-teardown Cloud Function" diff --git a/infrastructure/10-cost-guard.sh b/infrastructure/10-cost-guard.sh new file mode 100644 index 0000000..99961b1 --- /dev/null +++ b/infrastructure/10-cost-guard.sh @@ -0,0 +1,156 @@ +#!/usr/bin/env bash +# 10-cost-guard.sh — Deploy Cloud Function som lytter på billing Pub/Sub +# og utfører auto-teardown + sender webhook-alert til chat-app +# ved 100% av budsjett. +# +# Forutsetter: +# - Pub/Sub topic 'billing-alert-auto-teardown' finnes (01-setupenv.sh) +# - Secret Manager secret 'webhook-url' inneholder chat-webhook URL +# gcloud secrets create webhook-url --data-file=<(echo -n "https://DIN-WEBHOOK-URL") +# Source .env før kjøring: source .env +set -euo pipefail + +: "${PROJECT_ID:?Set PROJECT_ID}" +: "${REGION:?Set REGION}" +: "${CLOUD_RUN_SERVICE:?Set CLOUD_RUN_SERVICE}" + +FUNCTION_NAME="billing-auto-teardown" +PUBSUB_TOPIC="billing-alert-auto-teardown" +FUNC_DIR="/tmp/cost-guard-fn" + +echo "=== 10: Cost Guard — Cloud Function deployment ===" + +bash "$(dirname "$0")/00-authcheck.sh" + +gcloud services enable cloudfunctions.googleapis.com pubsub.googleapis.com --project="${PROJECT_ID}" --quiet + +# --- Skriv Cloud Function kildekode --- +mkdir -p "${FUNC_DIR}" + +cat > "${FUNC_DIR}/main.py" << 'PYEOF' +import base64 +import json +import os +import subprocess +import urllib.request + +import functions_framework +from google.cloud import secretmanager + + +def get_webhook_url(project_id: str) -> str | None: + try: + client = secretmanager.SecretManagerServiceClient() + name = f"projects/{project_id}/secrets/webhook-url/versions/latest" + response = client.access_secret_version(request={"name": name}) + return response.payload.data.decode("UTF-8") + except Exception: + return None + + +def send_webhook(url: str, message: str) -> None: + payload = json.dumps({"text": message}).encode() + req = urllib.request.Request( + url, + data=payload, + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(req, timeout=10): + pass + + +@functions_framework.cloud_event +def billing_alert(cloud_event): + project_id = os.environ.get("PROJECT_ID", "") + service = os.environ.get("CLOUD_RUN_SERVICE", "") + region = os.environ.get("REGION", "") + + # Dekod Pub/Sub-melding + data = base64.b64decode(cloud_event.data["message"]["data"]).decode() + alert = json.loads(data) + + budget_amount = alert.get("budgetAmount", "?") + cost_amount = alert.get("costAmount", "?") + alert_threshold = alert.get("alertThresholdExceeded", 0) + + print(f"Billing alert: {cost_amount} / {budget_amount} ({alert_threshold*100:.0f}%)") + + webhook_url = get_webhook_url(project_id) + base_msg = ( + f"🚨 *OSVauco Cost Alert*\n" + f"Prosjekt: `{project_id}`\n" + f"Forbruk: `{cost_amount}` av budsjett `{budget_amount}`\n" + f"Terskel: `{alert_threshold*100:.0f}%`" + ) + + # Kjør auto-teardown kun ved 100% terskel + if alert_threshold >= 1.0: + print("100% terskel nådd — starter auto-teardown") + try: + subprocess.run( + [ + "gcloud", "run", "services", "delete", service, + f"--region={region}", + f"--project={project_id}", + "--quiet", + ], + check=True, + capture_output=True, + ) + teardown_msg = base_msg + "\n\nℹ️ Auto-teardown utført: Cloud Run stoppet." + except subprocess.CalledProcessError as e: + teardown_msg = base_msg + f"\n\n⚠️ Auto-teardown FEILET: {e.stderr.decode()}" + msg = teardown_msg + else: + msg = base_msg + "\n\n⚠️ Ingen tiltak nå — men pass på forbruket." + + if webhook_url: + try: + send_webhook(webhook_url, msg) + print("Webhook-alert sendt") + except Exception as ex: + print(f"Webhook feilet: {ex}") + else: + print("Ingen webhook-url i Secret Manager — hopper over varsling") +PYEOF + +cat > "${FUNC_DIR}/requirements.txt" << 'EOF' +functions-framework==3.* +google-cloud-secret-manager>=2.0.0 +EOF + +echo "✓ Cloud Function kildekode skrevet til ${FUNC_DIR}" + +# --- Deploy Cloud Function --- +gcloud functions deploy "${FUNCTION_NAME}" \ + --gen2 \ + --runtime=python312 \ + --region="${REGION}" \ + --source="${FUNC_DIR}" \ + --entry-point=billing_alert \ + --trigger-topic="${PUBSUB_TOPIC}" \ + --project="${PROJECT_ID}" \ + --set-env-vars="PROJECT_ID=${PROJECT_ID},CLOUD_RUN_SERVICE=${CLOUD_RUN_SERVICE},REGION=${REGION}" \ + --service-account="${AGENT_SA:-$(gcloud config get-value account)}" \ + --quiet + +echo "✓ Cloud Function deployed: ${FUNCTION_NAME}" + +# --- IAM: Cloud Function SA trenger run.admin --- +PROJECT_NUMBER=$(gcloud projects describe "${PROJECT_ID}" --format="value(projectNumber)") +gcloud projects add-iam-policy-binding "${PROJECT_ID}" \ + --member="serviceAccount:${PROJECT_NUMBER}-compute@developer.gserviceaccount.com" \ + --role="roles/run.admin" --quiet +gcloud projects add-iam-policy-binding "${PROJECT_ID}" \ + --member="serviceAccount:${PROJECT_NUMBER}-compute@developer.gserviceaccount.com" \ + --role="roles/secretmanager.secretAccessor" --quiet +echo "✓ IAM: run.admin + secretmanager.secretAccessor gitt til Cloud Function SA" + +echo "" +echo "=== 10: Cost Guard COMPLETE ===" +echo " Legg til webhook URL i Secret Manager:" +echo " gcloud secrets create webhook-url --data-file=<(echo -n \"https://DIN-WEBHOOK-URL\")" +echo " Kompatibel med: Google Chat, Slack, Discord, Teams" +echo " Tester: publiser en test-melding på Pub/Sub:" +echo " gcloud pubsub topics publish ${PUBSUB_TOPIC} --message='{\"budgetAmount\":75,\"costAmount\":75,\"alertThresholdExceeded\":1.0}'" diff --git a/infrastructure/notifications/webhook-setup.md b/infrastructure/notifications/webhook-setup.md new file mode 100644 index 0000000..23b47ce --- /dev/null +++ b/infrastructure/notifications/webhook-setup.md @@ -0,0 +1,51 @@ +# Webhook / Chat-app oppsett + +Budsjett-alerts og deploy-notifikasjoner sendes til en valgfri chat-app +via en enkel webhook-URL lagret i Secret Manager. + +## Kompatible apper + +| App | Webhook-format | Instruksjon | +|---|---|---| +| Google Chat | `{"text": "..."}` | Rom → Apps → Webhooks → Legg til webhook | +| Slack | `{"text": "..."}` | Apps → Incoming Webhooks → Add to Slack | +| Discord | `{"content": "..."}` | Kanal-innstillinger → Integrations → Webhooks | +| Teams | `{"text": "..."}` | Kanal → Connectors → Incoming Webhook | + +> **Discord-merk:** Discord bruker `content` i stedet for `text`. Oppdater +> `main.py` i `10-cost-guard.sh` og `cloudbuild.yaml` notify-steget til +> `{"content": "$MSG"}` hvis du bruker Discord. + +## Legge til webhook URL + +```bash +# Ny secret +gcloud secrets create webhook-url \ + --data-file=<(echo -n "https://DIN-WEBHOOK-URL") \ + --project=propane-will-491900-m5 + +# Oppdater eksisterende secret +gcloud secrets versions add webhook-url \ + --data-file=<(echo -n "https://NY-WEBHOOK-URL") \ + --project=propane-will-491900-m5 +``` + +## Hva utløser varsler + +| Hendelse | Avsender | Terskel | +|---|---|---| +| Dev-budsjett 70% nådd | Cloud Function (`10-cost-guard.sh`) | $52.50 av $75 | +| Dev-budsjett 90% nådd | Cloud Function | $67.50 av $75 | +| Prod-budsjett 50% nådd | Cloud Function | $250 av $500 | +| Prod-budsjett 80% nådd | Cloud Function | $400 av $500 | +| Budsjett 100% — auto-teardown | Cloud Function | Cloud Run stoppes | +| Deploy fullført | `cloudbuild.yaml` steg 5 | Hver push til main | + +## Teste varsling manuelt + +```bash +# Simuler 100% budsjett-alert (trigger auto-teardown) +gcloud pubsub topics publish billing-alert-auto-teardown \ + --message='{"budgetAmount":75,"costAmount":75,"alertThresholdExceeded":1.0}' \ + --project=propane-will-491900-m5 +```