From 4d8b782df1136a4178b1950ccb505a3814fc3bd3 Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Sat, 6 Jun 2026 07:28:11 +0000 Subject: [PATCH] feat(trip-planner): app stack (Tier-1, CNPG, Slack-signed webhook ingress) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Namespace trip-planner (tier=4-aux, keel enrolled), ExternalSecret pulling secret/trip-planner from vault-kv, DB-creds ExternalSecret from vault-database (static-creds/pg-trip-planner → asyncpg DSN), Deployment with migrate init container + main container (readiness+liveness /healthz, 256Mi req=limit, 100m cpu request), ClusterIP service port 8080, and ingress_factory with auth=none (Slack v0 HMAC signature verification in-app). Terraform fmt clean. NOT applied; requires Vault secret/trip-planner + CNPG trip_planner DB + Slack app config. Co-Authored-By: Claude Opus 4.8 --- stacks/trip-planner/main.tf | 275 +++++++++++++++++++++++++++++ stacks/trip-planner/secrets | 1 + stacks/trip-planner/terragrunt.hcl | 23 +++ 3 files changed, 299 insertions(+) create mode 100644 stacks/trip-planner/main.tf create mode 120000 stacks/trip-planner/secrets create mode 100644 stacks/trip-planner/terragrunt.hcl diff --git a/stacks/trip-planner/main.tf b/stacks/trip-planner/main.tf new file mode 100644 index 00000000..db283013 --- /dev/null +++ b/stacks/trip-planner/main.tf @@ -0,0 +1,275 @@ +variable "image_tag" { + type = string + default = "latest" + description = "trip-planner image tag. Use 8-char git SHA in CI." +} + +variable "postgresql_host" { type = string } + +variable "tls_secret_name" { + type = string + sensitive = true +} + +locals { + namespace = "trip-planner" + image = "forgejo.viktorbarzin.me/viktor/trip-planner:${var.image_tag}" + labels = { + app = "trip-planner" + } +} + +resource "kubernetes_namespace" "trip_planner" { + metadata { + name = local.namespace + labels = { + tier = local.tiers.aux + "istio-injection" = "disabled" + # Opt into Keel auto-update (inject-keel-annotations ClusterPolicy). + "keel.sh/enrolled" = "true" + } + } + lifecycle { + # KYVERNO_LIFECYCLE_V1: goldilocks-vpa-auto-mode ClusterPolicy stamps this label. + ignore_changes = [metadata[0].labels["goldilocks.fairwinds.com/vpa-update-mode"]] + } +} + +# App secrets — seed these in Vault before applying: +# secret/trip-planner +# SLACK_BOT_TOKEN — Slack bot OAuth token (xoxb-…) +# SLACK_SIGNING_SECRET — Slack app signing secret (v0 HMAC) for request verification +# TREK_API_URL — TREK instance base URL +# TREK_API_KEY — TREK API key +# +# Schema in CNPG: `trip_planner` (alembic creates tables on first migrate). +# DB user: created via Vault database engine — see static-creds/pg-trip-planner. +resource "kubernetes_manifest" "external_secret" { + manifest = { + apiVersion = "external-secrets.io/v1beta1" + kind = "ExternalSecret" + metadata = { + name = "trip-planner-secrets" + namespace = local.namespace + } + spec = { + refreshInterval = "15m" + secretStoreRef = { + name = "vault-kv" + kind = "ClusterSecretStore" + } + target = { + name = "trip-planner-secrets" + template = { + metadata = { + annotations = { + "reloader.stakater.com/match" = "true" + } + } + } + } + dataFrom = [{ + extract = { + key = "trip-planner" + } + }] + } + } + depends_on = [kubernetes_namespace.trip_planner] +} + +# DB credentials from Vault database engine (7-day rotation). +# Builds the asyncpg DSN consumed by the FastAPI app as DB_CONNECTION_STRING. +# Pre-req in dbaas: CNPG cluster has DB `trip_planner`, role `trip_planner`, +# and Vault role `static-creds/pg-trip-planner`. +resource "kubernetes_manifest" "db_external_secret" { + manifest = { + apiVersion = "external-secrets.io/v1beta1" + kind = "ExternalSecret" + metadata = { + name = "trip-planner-db-creds" + namespace = local.namespace + } + spec = { + refreshInterval = "15m" + secretStoreRef = { + name = "vault-database" + kind = "ClusterSecretStore" + } + target = { + name = "trip-planner-db-creds" + template = { + metadata = { + annotations = { + "reloader.stakater.com/match" = "true" + } + } + data = { + DB_CONNECTION_STRING = "postgresql+asyncpg://trip_planner:{{ .password }}@${var.postgresql_host}:5432/trip_planner" + DB_PASSWORD = "{{ .password }}" + } + } + } + data = [{ + secretKey = "password" + remoteRef = { + key = "static-creds/pg-trip-planner" + property = "password" + } + }] + } + } + depends_on = [kubernetes_namespace.trip_planner] +} + +resource "kubernetes_deployment" "trip_planner" { + metadata { + name = "trip-planner" + namespace = kubernetes_namespace.trip_planner.metadata[0].name + labels = merge(local.labels, { + tier = local.tiers.aux + }) + annotations = { + "reloader.stakater.com/search" = "true" + } + } + + spec { + # Single leader: Slack event deduplication and the webhook receiver want + # one writer. Recreate avoids two pods racing on the same DB session. + replicas = 1 + strategy { + type = "Recreate" + } + + selector { + match_labels = local.labels + } + + template { + metadata { + labels = local.labels + } + + spec { + image_pull_secrets { + name = "registry-credentials" + } + + init_container { + name = "migrate" + image = local.image + command = ["python", "-m", "trip_planner", "migrate"] + + env_from { + secret_ref { name = "trip-planner-secrets" } + } + env_from { + secret_ref { name = "trip-planner-db-creds" } + } + + resources { + requests = { cpu = "50m", memory = "128Mi" } + limits = { memory = "256Mi" } + } + } + + container { + name = "trip-planner" + image = local.image + + port { + container_port = 8080 + } + + env_from { + secret_ref { name = "trip-planner-secrets" } + } + env_from { + secret_ref { name = "trip-planner-db-creds" } + } + + readiness_probe { + http_get { + path = "/healthz" + port = 8080 + } + initial_delay_seconds = 5 + period_seconds = 10 + } + liveness_probe { + http_get { + path = "/healthz" + port = 8080 + } + initial_delay_seconds = 30 + period_seconds = 30 + } + + resources { + requests = { cpu = "100m", memory = "256Mi" } + limits = { memory = "256Mi" } + } + } + } + } + } + + lifecycle { + ignore_changes = [ + spec[0].template[0].spec[0].dns_config, # KYVERNO_LIFECYCLE_V1 + metadata[0].annotations["keel.sh/policy"], + metadata[0].annotations["keel.sh/trigger"], + metadata[0].annotations["keel.sh/pollSchedule"], # KYVERNO_LIFECYCLE_V2 + metadata[0].annotations["keel.sh/match-tag"], + spec[0].template[0].spec[0].container[0].image, # KEEL_IGNORE_IMAGE — Keel manages tag updates + spec[0].template[0].spec[0].init_container[0].image, + metadata[0].annotations["kubernetes.io/change-cause"], + metadata[0].annotations["deployment.kubernetes.io/revision"], + spec[0].template[0].metadata[0].annotations["keel.sh/update-time"], # KEEL_LIFECYCLE_V1 + ] + } + + depends_on = [ + kubernetes_manifest.external_secret, + kubernetes_manifest.db_external_secret, + ] +} + +resource "kubernetes_service" "trip_planner" { + metadata { + name = "trip-planner" + namespace = kubernetes_namespace.trip_planner.metadata[0].name + labels = local.labels + } + + spec { + type = "ClusterIP" + selector = local.labels + + port { + name = "http" + port = 8080 + target_port = 8080 + } + } +} + +# Kyverno ClusterPolicy `sync-tls-secret` auto-clones the wildcard TLS +# secret into every namespace, so we don't need a setup_tls_secret module. + +# Public ingress for the Slack events webhook. All requests carry a +# Slack v0 HMAC signature (X-Slack-Signature header) that the app verifies +# before processing — Authentik forward-auth would intercept the POST and +# break Slack's delivery retries. +module "ingress" { + source = "../../modules/kubernetes/ingress_factory" + # auth = "none": Slack webhook receiver — gated by Slack v0 signature verification in-app, not Authentik + auth = "none" + anti_ai_scraping = false + dns_type = "proxied" + namespace = kubernetes_namespace.trip_planner.metadata[0].name + name = "trip-planner" + port = 8080 + tls_secret_name = var.tls_secret_name +} diff --git a/stacks/trip-planner/secrets b/stacks/trip-planner/secrets new file mode 120000 index 00000000..ca54a7cf --- /dev/null +++ b/stacks/trip-planner/secrets @@ -0,0 +1 @@ +../../secrets \ No newline at end of file diff --git a/stacks/trip-planner/terragrunt.hcl b/stacks/trip-planner/terragrunt.hcl new file mode 100644 index 00000000..9b6a537d --- /dev/null +++ b/stacks/trip-planner/terragrunt.hcl @@ -0,0 +1,23 @@ +include "root" { + path = find_in_parent_folders() +} + +dependency "platform" { + config_path = "../platform" + skip_outputs = true +} + +dependency "vault" { + config_path = "../vault" + skip_outputs = true +} + +dependency "external-secrets" { + config_path = "../external-secrets" + skip_outputs = true +} + +inputs = { + # Override per-deploy in CI / commit. + image_tag = "latest" +}