feat: cost-guard komplett — dev-budsjett, Pub/Sub auto-teardown, webhook-alert, cost-check i CI/CD
This commit is contained in:
parent
4502ae09fd
commit
aff36f2f35
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
156
infrastructure/10-cost-guard.sh
Normal file
156
infrastructure/10-cost-guard.sh
Normal 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}'"
|
||||
51
infrastructure/notifications/webhook-setup.md
Normal file
51
infrastructure/notifications/webhook-setup.md
Normal 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
|
||||
```
|
||||
Loading…
Reference in New Issue
Block a user