diff --git a/infrastructure/terraform/.terraform.lock.hcl b/infrastructure/terraform/.terraform.lock.hcl index 80037ee..3699b44 100644 --- a/infrastructure/terraform/.terraform.lock.hcl +++ b/infrastructure/terraform/.terraform.lock.hcl @@ -1,6 +1,27 @@ # This file is maintained automatically by "terraform init". # Manual edits may be lost in future updates. +provider "registry.terraform.io/hashicorp/archive" { + version = "2.8.0" + constraints = ">= 2.2.0" + hashes = [ + "h1:jdmKm+xl6ZcQrijxapnZ94RVuz/G4vk7hsIa1N0VT5Q=", + "zh:0d14713fdc259fb377d0b899ad3c650a34194bd52194c863303ef22a65a580e2", + "zh:369b56040c7a8085d04e7e8ffac1e2b321a3170e502f788819bc34b868ec016f", + "zh:4d1a3b983ed6af5a52bfe12794674ae55cbadfa6021b37106ade68b433ad216a", + "zh:5c547549e26e083573c78a966ca68ce6d7df6bb8f3948f66a575f07da46b74ea", + "zh:6de093e62a975eb19a5e3017ce38e6e3cb639c17b79648d2000e0a8348f0e997", + "zh:7267936c2cdbc448efeb594d73e6b56a53d6a7ae14fe88cdd2a4133adc3302f0", + "zh:7482f023050ed426b4b45116e1761643bc33b1fd4ce4a6fab207ae2571f35940", + "zh:76bbd93b234e5a2927d98b511d86565700f549b570871a194c35f944b96cefb7", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:c6afc4bc1f002bac9c173007dd4da05fde788cd14c2916089f958c33fedb0dfa", + "zh:d3ba40bd806a3a08e9237dece679193c99afb2085de6b45d7f5d1f673cfcd368", + "zh:e1ad7ded53ecd6f0e5b473a3b44eae2b2e885653a56050ab583d387332be02e4", + "zh:e93e78575ce82be6084cc153c24ba8f385dc8d6880888ee66e918460c870953d", + ] +} + provider "registry.terraform.io/hashicorp/google" { version = "7.33.0" constraints = ">= 5.0.0" diff --git a/infrastructure/terraform/cost-guard-fn/main.py b/infrastructure/terraform/cost-guard-fn/main.py new file mode 100644 index 0000000..23ffd93 --- /dev/null +++ b/infrastructure/terraform/cost-guard-fn/main.py @@ -0,0 +1,94 @@ +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* +" + f"Prosjekt: `{project_id}` +" + f"Forbruk: `{cost_amount}` av budsjett `{budget_amount}` +" + 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 + " + +ℹ️ Auto-teardown utført: Cloud Run stoppet." + except subprocess.CalledProcessError as e: + teardown_msg = base_msg + f" + +⚠️ Auto-teardown FEILET: {e.stderr.decode()}" + msg = teardown_msg + else: + msg = base_msg + " + +⚠️ 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") diff --git a/infrastructure/terraform/cost-guard-fn/requirements.txt b/infrastructure/terraform/cost-guard-fn/requirements.txt new file mode 100644 index 0000000..6977871 --- /dev/null +++ b/infrastructure/terraform/cost-guard-fn/requirements.txt @@ -0,0 +1,2 @@ +functions-framework==3.* +google-cloud-secret-manager>=2.0.0 diff --git a/infrastructure/terraform/main.tf b/infrastructure/terraform/main.tf index f7f62dc..7ee386d 100644 --- a/infrastructure/terraform/main.tf +++ b/infrastructure/terraform/main.tf @@ -139,33 +139,59 @@ resource "google_artifact_registry_repository" "repo" { format = "DOCKER" } -# Managed via Cloud Build CI/CD — not Terraform -# resource "google_cloud_run_v2_service" "agent_service" { -# name = var.cloud_run_service_name -# location = var.cloud_run_region -# -# template { -# service_account = google_service_account.agent_sa.email -# -# containers { -# image = var.container_image -# env { -# name = "CUSTOMER_ID" -# value = var.customer_id -# } -# } -# } -# -# depends_on = [google_project_service.apis] -# } -# -# resource "google_cloud_run_v2_service_iam_member" "invoker" { -# project = var.project_id -# location = google_cloud_run_v2_service.agent_service.location -# name = google_cloud_run_v2_service.agent_service.name -# role = "roles/run.invoker" -# member = "user:chris.christiansen@vauco.no" -# } +resource "google_cloud_run_v2_service" "agent_service" { + name = var.cloud_run_service_name + location = var.cloud_run_region + + template { + service_account = google_service_account.agent_sa.email + + scaling { + min_instance_count = 1 + max_instance_count = 10 + } + + containers { + image = var.container_image + + resources { + limits = { + cpu = "1" + memory = "1Gi" + } + } + + env { + name = "PROJECT_ID" + value = var.project_id + } + env { + name = "REGION" + value = var.region + } + } + + timeout = "300s" + max_instance_request_concurrency = 80 + + } + + labels = { + env = "prod" + team = "osvaucoe" + agent = "orchestrator" + } + + depends_on = [google_project_service.apis] +} + +resource "google_cloud_run_v2_service_iam_member" "invoker" { + project = var.project_id + location = google_cloud_run_v2_service.agent_service.location + name = google_cloud_run_v2_service.agent_service.name + role = "roles/run.invoker" + member = "user:chris.christiansen@vauco.no" +} # BigQuery — Cloud Run structured logs resource "google_bigquery_dataset" "logs" { @@ -219,3 +245,102 @@ resource "google_project_iam_member" "log_sink_bq_writer" { role = "roles/bigquery.dataEditor" member = google_logging_project_sink.cloudrun_to_bq.writer_identity } + +# From 04-observability-setup.sh +resource "google_monitoring_notification_channel" "email" { + display_name = "OSVauco Alert Email" + type = "email" + labels = { + email_address = var.alert_email + } +} + +resource "google_logging_metric" "agent_error_count" { + name = "agent-error-count" + description = "Count of ERROR severity logs from agent" + filter = "resource.type=\"cloud_run_revision\" severity=ERROR" +} + +resource "google_monitoring_uptime_check_config" "cloud_run_uptime" { + display_name = "${var.cloud_run_service_name}-uptime" + http_check { + path = "/health" + port = 443 + use_ssl = true + } + monitored_resource { + type = "uptime_url" + labels = { + host = replace(google_cloud_run_v2_service.agent_service.uri, "https://", "") + } + } + timeout = "10s" + period = "60s" +} + +# From 10-cost-guard.sh +data "archive_file" "cost_guard_fn_zip" { + type = "zip" + source_dir = "${path.module}/cost-guard-fn" + output_path = "${path.module}/cost-guard-fn.zip" +} + +resource "google_storage_bucket_object" "cost_guard_fn_source" { + name = "cost-guard-fn-source.zip" + bucket = google_storage_bucket.agent_staging.name + source = data.archive_file.cost_guard_fn_zip.output_path +} + +resource "google_cloudfunctions2_function" "billing_auto_teardown" { + name = "billing-auto-teardown" + location = var.region + + build_config { + runtime = "python312" + entry_point = "billing_alert" + source { + storage_source { + bucket = google_storage_bucket.agent_staging.name + object = google_storage_bucket_object.cost_guard_fn_source.name + } + } + } + + service_config { + service_account_email = google_service_account.agent_sa.email + environment_variables = { + PROJECT_ID = var.project_id + CLOUD_RUN_SERVICE = var.cloud_run_service_name + REGION = var.region + } + } + + event_trigger { + trigger_region = "global" + event_type = "google.cloud.pubsub.topic.v1.messagePublished" + pubsub_topic = google_pubsub_topic.billing_alerts.id + } +} + +data "google_project" "project" {} + +resource "google_project_iam_member" "function_sa_run_admin" { + project = var.project_id + role = "roles/run.admin" + member = "serviceAccount:${data.google_project.project.number}-compute@developer.gserviceaccount.com" +} + +resource "google_project_iam_member" "function_sa_secret_accessor" { + project = var.project_id + role = "roles/secretmanager.secretAccessor" + member = "serviceAccount:${data.google_project.project.number}-compute@developer.gserviceaccount.com" +} + +# From 11-billing-iam-hardening.sh +resource "google_billing_account_iam_member" "billing_viewers" { + for_each = toset(var.billing_viewer_emails) + + billing_account_id = var.billing_account_id + role = "roles/billing.viewer" + member = "user:${each.key}" +} diff --git a/infrastructure/terraform/terraform.tfvars b/infrastructure/terraform/terraform.tfvars index 39bc911..8139f63 100644 --- a/infrastructure/terraform/terraform.tfvars +++ b/infrastructure/terraform/terraform.tfvars @@ -1,8 +1,8 @@ -project_id = "propane-will-491900-m5" -region = "europe-west1" -alert_email = "chris.christiansen@vauco.no" -billing_account_id = "0171F6-057E6B-A260BA" -cloud_run_region = "europe-west1" -artifact_region = "europe-west1" +project_id = "propane-will-491900-m5" +region = "europe-west1" +alert_email = "chris.christiansen@vauco.no" +billing_account_id = "0171F6-057E6B-A260BA" +cloud_run_region = "europe-west1" +artifact_region = "europe-west1" cloud_run_service_name = "osvauco-agent" -artifact_repo_name = "agent-images" \ No newline at end of file +artifact_repo_name = "agent-images" \ No newline at end of file diff --git a/infrastructure/terraform/variables.tf b/infrastructure/terraform/variables.tf index 7c48869..385b83b 100644 --- a/infrastructure/terraform/variables.tf +++ b/infrastructure/terraform/variables.tf @@ -55,3 +55,9 @@ variable "container_image" { default = "europe-west1-docker.pkg.dev/propane-will-491900-m5/agent-images/osvauco-agent:latest" } +variable "billing_viewer_emails" { + description = "A list of user emails to be granted billing viewer role." + type = list(string) + default = ["chris.christiansen@vauco.no", "jason.vauger@vauco.no"] +} + diff --git a/infrastructure/terraform/versions.tf b/infrastructure/terraform/versions.tf index 464562b..a20b1a3 100644 --- a/infrastructure/terraform/versions.tf +++ b/infrastructure/terraform/versions.tf @@ -6,5 +6,9 @@ terraform { source = "hashicorp/google" version = ">= 5.0" } + archive = { + source = "hashicorp/archive" + version = ">= 2.2.0" + } } }