feat: cost-guard komplett — dev-budsjett, Pub/Sub auto-teardown, webhook-alert, cost-check i CI/CD

This commit is contained in:
chrischristiansen-glitch 2026-05-23 23:22:39 +02:00
parent 4502ae09fd
commit aff36f2f35
4 changed files with 295 additions and 14 deletions

View File

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

View File

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

View File

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

View File

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