feat(tf): convert all remaining scripts to terraform

This commit is contained in:
Chris Christiansen 2026-05-26 12:20:33 +00:00
parent 9092f4e002
commit 4c5034df88
7 changed files with 286 additions and 34 deletions

View File

@ -1,6 +1,27 @@
# This file is maintained automatically by "terraform init". # This file is maintained automatically by "terraform init".
# Manual edits may be lost in future updates. # 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" { provider "registry.terraform.io/hashicorp/google" {
version = "7.33.0" version = "7.33.0"
constraints = ">= 5.0.0" constraints = ">= 5.0.0"

View File

@ -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 men pass 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")

View File

@ -0,0 +1,2 @@
functions-framework==3.*
google-cloud-secret-manager>=2.0.0

View File

@ -139,33 +139,59 @@ resource "google_artifact_registry_repository" "repo" {
format = "DOCKER" format = "DOCKER"
} }
# Managed via Cloud Build CI/CD not Terraform resource "google_cloud_run_v2_service" "agent_service" {
# resource "google_cloud_run_v2_service" "agent_service" { name = var.cloud_run_service_name
# name = var.cloud_run_service_name location = var.cloud_run_region
# location = var.cloud_run_region
# template {
# template { service_account = google_service_account.agent_sa.email
# service_account = google_service_account.agent_sa.email
# scaling {
# containers { min_instance_count = 1
# image = var.container_image max_instance_count = 10
# env { }
# name = "CUSTOMER_ID"
# value = var.customer_id containers {
# } image = var.container_image
# }
# } resources {
# limits = {
# depends_on = [google_project_service.apis] cpu = "1"
# } memory = "1Gi"
# }
# resource "google_cloud_run_v2_service_iam_member" "invoker" { }
# project = var.project_id
# location = google_cloud_run_v2_service.agent_service.location env {
# name = google_cloud_run_v2_service.agent_service.name name = "PROJECT_ID"
# role = "roles/run.invoker" value = var.project_id
# member = "user:chris.christiansen@vauco.no" }
# } 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 # BigQuery Cloud Run structured logs
resource "google_bigquery_dataset" "logs" { resource "google_bigquery_dataset" "logs" {
@ -219,3 +245,102 @@ resource "google_project_iam_member" "log_sink_bq_writer" {
role = "roles/bigquery.dataEditor" role = "roles/bigquery.dataEditor"
member = google_logging_project_sink.cloudrun_to_bq.writer_identity 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}"
}

View File

@ -1,8 +1,8 @@
project_id = "propane-will-491900-m5" project_id = "propane-will-491900-m5"
region = "europe-west1" region = "europe-west1"
alert_email = "chris.christiansen@vauco.no" alert_email = "chris.christiansen@vauco.no"
billing_account_id = "0171F6-057E6B-A260BA" billing_account_id = "0171F6-057E6B-A260BA"
cloud_run_region = "europe-west1" cloud_run_region = "europe-west1"
artifact_region = "europe-west1" artifact_region = "europe-west1"
cloud_run_service_name = "osvauco-agent" cloud_run_service_name = "osvauco-agent"
artifact_repo_name = "agent-images" artifact_repo_name = "agent-images"

View File

@ -55,3 +55,9 @@ variable "container_image" {
default = "europe-west1-docker.pkg.dev/propane-will-491900-m5/agent-images/osvauco-agent:latest" 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"]
}

View File

@ -6,5 +6,9 @@ terraform {
source = "hashicorp/google" source = "hashicorp/google"
version = ">= 5.0" version = ">= 5.0"
} }
archive = {
source = "hashicorp/archive"
version = ">= 2.2.0"
}
} }
} }