diff --git a/docs/discord-support.md b/docs/discord-support.md index 279aed87..a5e408b0 100644 --- a/docs/discord-support.md +++ b/docs/discord-support.md @@ -188,10 +188,13 @@ self-heals by re-reading messages after the stored cursors. Dormant unless **Freshdesk-side setup**: the ticket-update **automation rule** (reply / public note / status change, performed by agent → webhook to the capability -URL with the `x-freshdesk-secret` header) and the per-env **agent group** are -**TF-managed** (`freshdesk.tf` in each talos stack, `slop-place/freshdesk` -provider); the rule's events/actions JSON is validated by Freshdesk itself on -first apply. What stays +URL with the `x-freshdesk-secret` header), the per-env **agent group**, and +the webhook credentials themselves are owned by the partition-wide +**`tf/deployment//global/freshdesk` stack** (`slop-place/freshdesk` +provider); the talos stack consumes the minted values back through 1P +(`YUCCA_FRESHDESK_WEBHOOK_{PATH,SECRET}`, `YUCCA_FRESHDESK_GROUP_ID`) into +the bot Secret and cluster-secrets. The rule's events/actions JSON is +validated by Freshdesk itself on first apply. What stays manual, once per account: create a dedicated **bot agent** and put its API key in the `YUCCA_FRESHDESK_API_KEY` item (with `YUCCA_FRESHDESK_URL` beside it), and put an **admin** agent's key in `YUCCA_FRESHDESK_ADMIN_API_KEY` — diff --git a/tf/.env b/tf/.env index 486a2290..bef69996 100644 --- a/tf/.env +++ b/tf/.env @@ -105,3 +105,7 @@ export TF_VAR_sietch_transcripts_secret_key="op://yucca_tf_staging/SIETCH_CEPH_S export TF_VAR_yucca_freshdesk_url="op://yucca_tf_staging/YUCCA_FRESHDESK_URL/password" export TF_VAR_yucca_freshdesk_api_key="op://yucca_tf_staging/YUCCA_FRESHDESK_API_KEY/password" export TF_VAR_yucca_freshdesk_admin_api_key="op://yucca_tf_staging/YUCCA_FRESHDESK_ADMIN_API_KEY/password" +# Minted by the staging/global/freshdesk stack — uncomment after its first apply. +# export TF_VAR_yucca_freshdesk_webhook_secret="op://yucca_tf_staging/YUCCA_FRESHDESK_WEBHOOK_SECRET/password" +# export TF_VAR_yucca_freshdesk_webhook_path="op://yucca_tf_staging/YUCCA_FRESHDESK_WEBHOOK_PATH/password" +# export TF_VAR_yucca_freshdesk_group_id="op://yucca_tf_staging/YUCCA_FRESHDESK_GROUP_ID/password" diff --git a/tf/.env.prod b/tf/.env.prod index 8a7b80e6..739b7293 100644 --- a/tf/.env.prod +++ b/tf/.env.prod @@ -105,3 +105,7 @@ export TF_VAR_vmauth_remote_write_password="op://shared_tf_prod/O11Y_VICTORIAMET export TF_VAR_yucca_freshdesk_url="op://yucca_tf_prod/YUCCA_FRESHDESK_URL/password" export TF_VAR_yucca_freshdesk_api_key="op://yucca_tf_prod/YUCCA_FRESHDESK_API_KEY/password" export TF_VAR_yucca_freshdesk_admin_api_key="op://yucca_tf_prod/YUCCA_FRESHDESK_ADMIN_API_KEY/password" +# Minted by the prod/global/freshdesk stack — uncomment after its first apply. +# export TF_VAR_yucca_freshdesk_webhook_secret="op://yucca_tf_prod/YUCCA_FRESHDESK_WEBHOOK_SECRET/password" +# export TF_VAR_yucca_freshdesk_webhook_path="op://yucca_tf_prod/YUCCA_FRESHDESK_WEBHOOK_PATH/password" +# export TF_VAR_yucca_freshdesk_group_id="op://yucca_tf_prod/YUCCA_FRESHDESK_GROUP_ID/password" diff --git a/tf/deployment/prod/global/freshdesk/.terraform.lock.hcl b/tf/deployment/prod/global/freshdesk/.terraform.lock.hcl new file mode 100644 index 00000000..0ae22891 --- /dev/null +++ b/tf/deployment/prod/global/freshdesk/.terraform.lock.hcl @@ -0,0 +1,69 @@ +# This file is maintained automatically by "tofu init". +# Manual edits may be lost in future updates. + +provider "registry.opentofu.org/1password/onepassword" { + version = "2.2.1" + constraints = "~> 2.1" + hashes = [ + "h1:aH5pZUimlQdiGnDLHRC49W4xnkx52wfd8XRLt69+764=", + "zh:025709a6b5f1b3685d277f2c48f7cb8b53d14b3699c1123d7e9a2135c099c533", + "zh:037fc89d150063a8aacdcab08ba26038b489fe2468d509b842d298ea59096ca6", + "zh:233777182b25faf1658e8ce171b684460983bb41cff79fb243662f3f9dc5ca6c", + "zh:2fb5ca2fc8c37b1d1c54da646ed13bf40897941fe92eece784fba496f677b533", + "zh:4b25b5ce1f694ec265e65234fc85d6bdf3810297ffeaced54ad46a1ba28142de", + "zh:5509d1e4fb7b45c63124ec66fd1f9d6757daa8bf1f7bdd724d5adb2965b61436", + "zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f", + "zh:a23ba946c629ec912b2fcbf606a2eb8853626ec0e0bee749f2d39146a872c082", + "zh:a3d3024485426237d7b4a4350b12dda4d29d88f3942246a9370be35ec2a51e9e", + "zh:a6ef65544ab8fc26d468b38636407a3d2d902e35c51b648729bf97c31d1937f9", + "zh:afbe9480a0da0ad8dc514b277f1e4be36b8931f045021d05c21665ef1ac0b7c8", + "zh:b2e96e69fa9ff7e179dccdef5b785cd020eb46bb2b3d1d507d009d71be6b0c26", + "zh:ceefaede9e8a3104463523ba267e3e985b27a706f7628a9ddd37330c2ca59d4d", + "zh:ea77786bd6809ff4f8043b84a0212fec4de18b7d51bc420417ba10999ca99887", + "zh:f7d8160c3669c8ab76a2da14ea740d91a08ca23d1fb657669e52a840b2b113d9", + ] +} + +provider "registry.opentofu.org/hashicorp/random" { + version = "3.9.0" + constraints = "~> 3.6" + hashes = [ + "h1:U8KXqGCoNI9/guYbTvzgdtVk3fRthoG0UXwm1JoEpIs=", + "zh:03f1114cc20b8913523735ab76e0f0a2b16ce13c92923a53304bf85f07fc0dbc", + "zh:105b678ee72322a3067f105d7e05e940f6143238f377f6e87ff4ec909246ac2a", + "zh:55f3bbf13ea18cbace61a706566a80f25f33fe2b1780b6f3d7b582af2a05b6d2", + "zh:63adf996db48f082f7a6351eb485e219cd88795fc71e6ec60a837263ab0d2cb1", + "zh:7e99550738a4e3cc68b8a467714b0d69371025fe95e3326d5323d026d55653e9", + "zh:8342b54af3a18a37e075eeae61be57f4de2ba71b35d95c5075d402dd2c1f289d", + "zh:83ee18e32ac9dd5fc91298554b7c4cfa4c3a1db50f4c797945637cc93c0844ae", + "zh:993ecc0adbf6bd535a59fbc9b735d8c33950e6f6eb5e621d750da9b71d65d80a", + "zh:ad722bc59d4edbf1415e827fc007c0efe6e0e9462d5568bae20b34be1058a261", + "zh:ae9448e1f87b2f9a6c5197a0e9862162ec6b137cb3a3835e11522995d8939e7c", + "zh:bc9cdd3aac784f759125c6627f6f6416e8726a1c184eb9cf3e55b9edbc94c627", + "zh:c8e35b89572ba1c40a9b20022e033a3395fb8d42e7604d50c900f193ba10382e", + "zh:e2deaa8a9975ef81d9f62baed12c41286918b0a10908e0e031f13f69a3b730a1", + "zh:ee39707557210a0ab1098aa357d2cdfe502e5a312d0dbdffb09d08facc4d3fc5", + "zh:f81afe4eb63e8aa9e0ea71be6c990f0dc69cb360e7191c0742a991f4a5081b64", + ] +} + +provider "registry.terraform.io/slop-place/freshdesk" { + version = "0.1.1" + constraints = "~> 0.1" + hashes = [ + "h1:VyOKR4IfX4LWwW1Gv6rAa5A5x/h0IEDqKHL4hjliao8=", + "zh:19326bde07045235a0b41a1e0936a0929ee5b8a63d1856199dde34c1e2a2f641", + "zh:2221a63af314b9fae115bd455564e80105ead873856ebff4a9ae967fa91b9408", + "zh:239a2dc1433199705b587470fc86fc5d75761e2b6b2d640a112d416966676479", + "zh:4b7678d13a51cf6525ea7aec5788e2fcb96c8ef6b9f853937d224946f928ddb4", + "zh:74e33bffa31c664a3ae1cb20d098c804ff92c83fc042ec7d24b68f0ebd8f027f", + "zh:7943bf339e8f825b56c109ad92702102cdf4d07d577c48e45228e2f0d7889eaf", + "zh:7d4958ed366239294b085542859d5cc135b8041e1c5c5ab2a0b8c278c45df665", + "zh:91b8bcc4e58ba08f4e3380d07d6df32719d8464607cc1a0c56f75978cccd0cc9", + "zh:9deb669d3cd5dd598f83d51112987540c71c46f49b6d7841fd72a88fcb84f3f0", + "zh:ae3a04d72429f1b45db0cb1b1741ff007fc558c864c3b64652a48f1ab636f102", + "zh:d477d2919ff96f3d58be4df678f8eca52fb24254d53590fddca6b34d49d48215", + "zh:dd743e45b051dcff9b4f88bd99a6c5c24be9a614ee750625f900b4d0022634e8", + "zh:f809ab383cca0a5f83072981c64208cbd7fa67e986a86ee02dd2c82333221e32", + ] +} diff --git a/tf/deployment/prod/global/freshdesk/discovery.tf b/tf/deployment/prod/global/freshdesk/discovery.tf new file mode 100644 index 00000000..db4e3896 --- /dev/null +++ b/tf/deployment/prod/global/freshdesk/discovery.tf @@ -0,0 +1,31 @@ +# ── Discovery contract ────────────────────────────────────────────────────── +# Single non-sensitive envelope consumed by yuctl. The credential values stay +# in 1P — only the item titles are output. See tf/README.md. + +output "discovery_schema_version" { + description = "Schema version of the discovery output envelope." + value = 1 +} + +output "discovery" { + description = "Freshdesk payload for this partition (non-sensitive)." + value = { + schema_version = 1 + partition = var.partition + region = var.region + slug = var.slug + role = var.role + stack = var.stack + stack_type = "freshdesk" + freshdesk = { + vault = "yucca_tf_${var.partition}" + group_name = "FUTO Cloud" + rule_managed = nonsensitive(local.enabled) + item_titles = { + webhook_path = "YUCCA_FRESHDESK_WEBHOOK_PATH" + webhook_secret = "YUCCA_FRESHDESK_WEBHOOK_SECRET" + group_id = "YUCCA_FRESHDESK_GROUP_ID" + } + } + } +} diff --git a/tf/deployment/prod/global/freshdesk/main.tf b/tf/deployment/prod/global/freshdesk/main.tf new file mode 100644 index 00000000..ab7ce9e9 --- /dev/null +++ b/tf/deployment/prod/global/freshdesk/main.tf @@ -0,0 +1,99 @@ +# The webhook contract, TF-owned end to end: this stack mints the capability- +# URL path segment and the x-freshdesk-secret header, mirrors them into 1P, +# and points the Freshdesk automation rule at them — no human ever handles +# the values, and the talos stack consumes them back via op:// refs (bot +# Secret + cluster-secrets). See docs/discord-support.md. + +locals { + # yucca-manual-secrets placeholders read back literally as REPLACE_ME — + # never let that masquerade as config. + url = var.yucca_freshdesk_url == "REPLACE_ME" ? "" : var.yucca_freshdesk_url + admin_api_key = var.yucca_freshdesk_admin_api_key == "REPLACE_ME" ? "" : var.yucca_freshdesk_admin_api_key + enabled = local.url != "" && local.admin_api_key != "" +} + +# Fallbacks keep the provider configurable while the manual items are +# unfilled — never contacted, the freshdesk resources are count-gated. +provider "freshdesk" { + domain = coalesce(local.url, "https://freshdesk.invalid") + api_key = coalesce(local.admin_api_key, "unset") +} + +provider "onepassword" {} + +data "onepassword_vault" "partition" { + name = "yucca_tf_${var.partition}" +} + +# Generated unconditionally (no Freshdesk dependency) so the talos stack's +# op:// refs can be uncommented right after this stack's first apply. +resource "random_password" "webhook_secret" { + length = 48 + special = false +} + +resource "onepassword_item" "webhook_secret" { + vault = data.onepassword_vault.partition.uuid + title = "YUCCA_FRESHDESK_WEBHOOK_SECRET" + category = "password" + + password = random_password.webhook_secret.result +} + +resource "random_password" "webhook_path" { + length = 32 + special = false +} + +resource "onepassword_item" "webhook_path" { + vault = data.onepassword_vault.partition.uuid + title = "YUCCA_FRESHDESK_WEBHOOK_PATH" + category = "password" + + password = random_password.webhook_path.result +} + +# Bot-created tickets land here; the id reaches the bot Secret through the +# 1P item below. +resource "freshdesk_group" "support" { + count = local.enabled ? 1 : 0 + name = "FUTO Cloud" + description = "FUTO Backups support tickets, managed by yucca tf" +} + +resource "onepassword_item" "group_id" { + count = local.enabled ? 1 : 0 + vault = data.onepassword_vault.partition.uuid + title = "YUCCA_FRESHDESK_GROUP_ID" + category = "password" + + password = tostring(freshdesk_group.support[0].id) +} + +# rule_type 4 = observer (ticket updates); performer type 1 = agent. events/ +# actions are raw Automations-API JSON (the provider passes them through), +# mirroring a rule read back via GET /api/v2/automations/4/rules — NB +# content_type is required, content_layout is a string, and content is a JSON +# object whose string values carry the placeholders. Scoping to yucca tickets +# happens bot-side, so the rule has no conditions. +resource "freshdesk_automation_rule" "ticket_sync" { + count = local.enabled ? 1 : 0 + name = "yucca prod ticket sync" + rule_type = 4 + active = true + performer = jsonencode({ type = 1 }) + events = jsonencode([ + { field_name = "reply_sent" }, + { field_name = "note_type", value = "public" }, + { field_name = "status", from = "--", to = "--" }, + ]) + actions = jsonencode([{ + field_name = "trigger_webhook" + request_type = "POST" + content_type = "JSON" + content_layout = "2" + url = "https://${var.yucca_app_domain}/hooks/${random_password.webhook_path.result}" + content = { ticket_id = "{{ticket.id}}" } + custom_headers = { "x-freshdesk-secret" = random_password.webhook_secret.result } + }]) +} diff --git a/tf/deployment/prod/global/freshdesk/terragrunt.hcl b/tf/deployment/prod/global/freshdesk/terragrunt.hcl new file mode 100644 index 00000000..cce38bfb --- /dev/null +++ b/tf/deployment/prod/global/freshdesk/terragrunt.hcl @@ -0,0 +1,8 @@ +include "root" { + path = find_in_parent_folders("terragrunt.hcl") +} + +# Freshdesk-side support wiring for the partition (docs/discord-support.md): +# webhook credentials, the agent group and the ticket-update automation rule. +# partition/region are injected by the root config (parsed from the path: +# deployment//global/freshdesk). diff --git a/tf/deployment/prod/global/freshdesk/variables.tf b/tf/deployment/prod/global/freshdesk/variables.tf new file mode 100644 index 00000000..32be379d --- /dev/null +++ b/tf/deployment/prod/global/freshdesk/variables.tf @@ -0,0 +1,71 @@ +variable "partition" { + description = "Partition slug, injected by terragrunt from the path (deployment//global/freshdesk)." + type = string +} + +variable "region" { + description = "Region slug (global for this partition-wide stack)." + type = string + default = null +} + +variable "stack" { + description = "Stack name (freshdesk)." + type = string + default = null +} + +variable "slug" { + description = "Canonical - slug." + type = string + default = null +} + +variable "role" { + description = "Region role (null for the global pseudo-region)." + type = string + default = null +} + +variable "site_id" { + description = "Fabric site id (null for global)." + type = number + default = null +} + +variable "datacenter" { + description = "Datacenter segment of the region FQDN (null for global)." + type = string + default = null +} + +variable "provider_code" { + description = "Provider segment of the region FQDN (null for global)." + type = string + default = null +} + +variable "domain" { + description = "Region FQDN suffix (null for global)." + type = string + default = null +} + +variable "yucca_freshdesk_url" { + description = "Freshdesk base URL (manual YUCCA_FRESHDESK_URL item). Empty = the group/rule stay unmanaged." + type = string + default = "" +} + +variable "yucca_freshdesk_admin_api_key" { + description = "Freshdesk API key of an ADMIN agent, used only by this stack for group/rule CRUD (manual YUCCA_FRESHDESK_ADMIN_API_KEY item; never lands in a cluster)." + type = string + sensitive = true + default = "" +} + +variable "yucca_app_domain" { + description = "Public app domain the webhook rule targets. Must match APP_DOMAIN in the partition's cluster-settings.generated.yaml." + type = string + default = "backups.futo.cloud" +} diff --git a/tf/deployment/prod/global/freshdesk/versions.tf b/tf/deployment/prod/global/freshdesk/versions.tf new file mode 100644 index 00000000..9c6d1475 --- /dev/null +++ b/tf/deployment/prod/global/freshdesk/versions.tf @@ -0,0 +1,19 @@ +terraform { + required_version = "~> 1.11" + required_providers { + freshdesk = { + source = "registry.terraform.io/slop-place/freshdesk" + version = "~> 0.1" + } + # Webhook path + header secret generation. + random = { + source = "hashicorp/random" + version = "~> 3.6" + } + # Mirrors the generated credentials into 1P for the talos stack to consume. + onepassword = { + source = "1Password/onepassword" + version = "~> 2.1" + } + } +} diff --git a/tf/deployment/prod/htz-fsn1/talos/freshdesk.tf b/tf/deployment/prod/htz-fsn1/talos/freshdesk.tf deleted file mode 100644 index f70c085e..00000000 --- a/tf/deployment/prod/htz-fsn1/talos/freshdesk.tf +++ /dev/null @@ -1,46 +0,0 @@ -# Freshdesk-side webhook wiring, TF-owned end to end (slop-place/freshdesk -# provider): the ticket-update automation rule and the per-env agent group. -# The rule's two secret ingredients — the capability-URL path segment and the -# x-freshdesk-secret header — are the random_password resources in -# secrets.tf, so no human ever handles the values and CI plans mask them. -# performer/events/actions are raw Automations-API JSON (the provider passes -# them through); Freshdesk 400s with field-level errors if the shape drifts. -# Scoping to discord tickets happens bot-side, so the rule has no conditions. - -locals { - # yucca-manual-secrets placeholders read back literally as REPLACE_ME — - # never let that masquerade as config. - freshdesk_url = var.yucca_freshdesk_url == "REPLACE_ME" ? "" : var.yucca_freshdesk_url - freshdesk_api_key = var.yucca_freshdesk_api_key == "REPLACE_ME" ? "" : var.yucca_freshdesk_api_key - freshdesk_admin_api_key = var.yucca_freshdesk_admin_api_key == "REPLACE_ME" ? "" : var.yucca_freshdesk_admin_api_key - freshdesk_rules_enabled = local.freshdesk_url != "" && local.freshdesk_admin_api_key != "" -} - -# Bot-created tickets land here (FRESHDESK_GROUP_ID in the bot Secret). -resource "freshdesk_group" "discord" { - count = local.freshdesk_rules_enabled ? 1 : 0 - name = "FUTO Cloud" - description = "FUTO Backups Discord tickets, managed by yucca tf" -} - -# rule_type 4 = observer (ticket updates); performer type 1 = agent. -resource "freshdesk_automation_rule" "discord_webhook" { - count = local.freshdesk_rules_enabled ? 1 : 0 - name = "yucca prod discord ticket sync" - rule_type = 4 - active = true - performer = jsonencode({ type = 1 }) - events = jsonencode([ - { field_name = "reply_sent" }, - { field_name = "note_type", value = "public" }, - { field_name = "status", from = "--", to = "--" }, - ]) - actions = jsonencode([{ - field_name = "trigger_webhook" - request_type = "POST" - url = "https://${var.yucca_app_domain}/hooks/${random_password.yucca_freshdesk_webhook_path.result}" - content_layout = 2 - content = "{\"ticket_id\": {{ticket.id}}}" - custom_headers = { "x-freshdesk-secret" = random_password.yucca_freshdesk_webhook_secret.result } - }]) -} diff --git a/tf/deployment/prod/htz-fsn1/talos/providers.tf b/tf/deployment/prod/htz-fsn1/talos/providers.tf index 2d6ef8ab..f1718927 100644 --- a/tf/deployment/prod/htz-fsn1/talos/providers.tf +++ b/tf/deployment/prod/htz-fsn1/talos/providers.tf @@ -23,10 +23,10 @@ provider "kubernetes" { # via OP_SERVICE_ACCOUNT_TOKEN (op run). provider "onepassword" {} -# Freshdesk automation rule + group (freshdesk.tf). Fallbacks keep the -# provider configurable while the manual items are unfilled — never contacted, -# every freshdesk resource is count-gated on the real config. +# TRANSITIONAL (see versions.tf): authenticates the destroys of the state-held +# freshdesk resources; the group/rule are now owned by the partition's +# global/freshdesk stack. provider "freshdesk" { domain = coalesce(local.freshdesk_url, "https://freshdesk.invalid") - api_key = coalesce(local.freshdesk_admin_api_key, "unset") + api_key = coalesce(var.yucca_freshdesk_admin_api_key == "REPLACE_ME" ? "" : var.yucca_freshdesk_admin_api_key, "unset") } diff --git a/tf/deployment/prod/htz-fsn1/talos/secrets.tf b/tf/deployment/prod/htz-fsn1/talos/secrets.tf index d84dd5ac..76f45696 100644 --- a/tf/deployment/prod/htz-fsn1/talos/secrets.tf +++ b/tf/deployment/prod/htz-fsn1/talos/secrets.tf @@ -240,34 +240,6 @@ resource "onepassword_item" "yucca_internal_secret" { password = random_password.yucca_internal_secret.result } -# Freshdesk webhook credentials, both TF-generated and mirrored into 1P: the -# header secret rides the bot Secret; the capability-URL path segment reaches -# the HTTPRoute via cluster-secrets (below) so it never appears in git or CI. -resource "random_password" "yucca_freshdesk_webhook_secret" { - length = 48 - special = false -} - -resource "onepassword_item" "yucca_freshdesk_webhook_secret" { - vault = data.onepassword_vault.prod.uuid - title = "YUCCA_FRESHDESK_WEBHOOK_SECRET" - category = "password" - - password = random_password.yucca_freshdesk_webhook_secret.result -} - -resource "random_password" "yucca_freshdesk_webhook_path" { - length = 32 - special = false -} - -resource "onepassword_item" "yucca_freshdesk_webhook_path" { - vault = data.onepassword_vault.prod.uuid - title = "YUCCA_FRESHDESK_WEBHOOK_PATH" - category = "password" - - password = random_password.yucca_freshdesk_webhook_path.result -} # Substituted into the Flux tree (apps.yaml postBuild, optional Secret source): # the one config channel that bypasses the git-committed cluster-settings. @@ -277,7 +249,7 @@ resource "kubernetes_secret_v1" "cluster_secrets" { namespace = "flux-system" } data = { - FRESHDESK_WEBHOOK_PATH = random_password.yucca_freshdesk_webhook_path.result + FRESHDESK_WEBHOOK_PATH = var.yucca_freshdesk_webhook_path } depends_on = [helm_release.flux_operator] } @@ -288,6 +260,13 @@ resource "kubernetes_secret_v1" "cluster_secrets" { # empty so this Secret can land before their 1P items exist — the bot idles # without a token, skips the archive sweep without S3 keys and leaves the # Freshdesk sync dormant without creds. +locals { + # yucca-manual-secrets placeholders read back literally as REPLACE_ME — + # never let that masquerade as config. + freshdesk_url = var.yucca_freshdesk_url == "REPLACE_ME" ? "" : var.yucca_freshdesk_url + freshdesk_api_key = var.yucca_freshdesk_api_key == "REPLACE_ME" ? "" : var.yucca_freshdesk_api_key +} + resource "kubernetes_secret_v1" "futo_backups_bot" { metadata { name = "futo-backups-bot" @@ -304,12 +283,13 @@ resource "kubernetes_secret_v1" "futo_backups_bot" { DISCORD_CUSTOMER_ROLE_ID = var.yucca_discord_customer_role_id TRANSCRIPT_S3_ACCESS_KEY_ID = var.spice_transcripts_access_key TRANSCRIPT_S3_SECRET_ACCESS_KEY = var.spice_transcripts_secret_key - # REPLACE_ME-guarded locals (freshdesk.tf) — an unfilled placeholder - # keeps the sync dormant. + # REPLACE_ME-guarded locals (below) — an unfilled placeholder keeps the + # sync dormant. The webhook credentials and group id come from the + # partition's global/freshdesk stack via 1P. FRESHDESK_URL = local.freshdesk_url FRESHDESK_API_KEY = local.freshdesk_api_key - FRESHDESK_GROUP_ID = try(tostring(freshdesk_group.discord[0].id), "") - FRESHDESK_WEBHOOK_SECRET = random_password.yucca_freshdesk_webhook_secret.result + FRESHDESK_GROUP_ID = var.yucca_freshdesk_group_id + FRESHDESK_WEBHOOK_SECRET = var.yucca_freshdesk_webhook_secret } } diff --git a/tf/deployment/prod/htz-fsn1/talos/variables.tf b/tf/deployment/prod/htz-fsn1/talos/variables.tf index 2bcea14a..e7da48e4 100644 --- a/tf/deployment/prod/htz-fsn1/talos/variables.tf +++ b/tf/deployment/prod/htz-fsn1/talos/variables.tf @@ -282,16 +282,30 @@ variable "yucca_freshdesk_api_key" { } variable "yucca_freshdesk_admin_api_key" { - description = "Freshdesk API key of an ADMIN agent, used only by the freshdesk provider to manage the automation rule and group (manual YUCCA_FRESHDESK_ADMIN_API_KEY item; never lands in the cluster). Empty = the rules are unmanaged." + description = "TRANSITIONAL (see versions.tf): admin key for destroying the state-held freshdesk resources (manual YUCCA_FRESHDESK_ADMIN_API_KEY item)." type = string sensitive = true default = "" } -variable "yucca_app_domain" { - description = "Public app domain the Freshdesk webhook rule targets. Must match APP_DOMAIN in the cluster-settings.generated.yaml of this cluster." +variable "yucca_freshdesk_webhook_secret" { + description = "Webhook header secret minted by the prod/global/freshdesk stack (YUCCA_FRESHDESK_WEBHOOK_SECRET); refs stay commented in tf/.env.prod until its first apply." type = string - default = "backups.futo.cloud" + sensitive = true + default = "" +} + +variable "yucca_freshdesk_webhook_path" { + description = "Capability-URL path segment minted by the prod/global/freshdesk stack (YUCCA_FRESHDESK_WEBHOOK_PATH); substituted into the Flux tree via cluster-secrets." + type = string + sensitive = true + default = "" +} + +variable "yucca_freshdesk_group_id" { + description = "Freshdesk agent-group id minted by the prod/global/freshdesk stack (YUCCA_FRESHDESK_GROUP_ID)." + type = string + default = "" } variable "yucca_discord_guild_id" { diff --git a/tf/deployment/prod/htz-fsn1/talos/versions.tf b/tf/deployment/prod/htz-fsn1/talos/versions.tf index ca835dd6..7baebf0a 100644 --- a/tf/deployment/prod/htz-fsn1/talos/versions.tf +++ b/tf/deployment/prod/htz-fsn1/talos/versions.tf @@ -32,7 +32,10 @@ terraform { source = "hashicorp/random" version = "~> 3.6" } - # Freshdesk automation rule + agent group (freshdesk.tf). + # TRANSITIONAL: no freshdesk resources remain in config — the declaration + # only lets tofu destroy the state-held group/rule from the pre- + # global/freshdesk layout. Remove together with the provider block and + # yucca_freshdesk_admin_api_key after one apply. freshdesk = { source = "registry.terraform.io/slop-place/freshdesk" version = "~> 0.1" diff --git a/tf/deployment/staging/austin/talos/freshdesk.tf b/tf/deployment/staging/austin/talos/freshdesk.tf deleted file mode 100644 index 7f171f63..00000000 --- a/tf/deployment/staging/austin/talos/freshdesk.tf +++ /dev/null @@ -1,46 +0,0 @@ -# Freshdesk-side webhook wiring, TF-owned end to end (slop-place/freshdesk -# provider): the ticket-update automation rule and the per-env agent group. -# The rule's two secret ingredients — the capability-URL path segment and the -# x-freshdesk-secret header — are the random_password resources in -# secrets.tf, so no human ever handles the values and CI plans mask them. -# performer/events/actions are raw Automations-API JSON (the provider passes -# them through); Freshdesk 400s with field-level errors if the shape drifts. -# Scoping to discord tickets happens bot-side, so the rule has no conditions. - -locals { - # yucca-manual-secrets placeholders read back literally as REPLACE_ME — - # never let that masquerade as config. - freshdesk_url = var.yucca_freshdesk_url == "REPLACE_ME" ? "" : var.yucca_freshdesk_url - freshdesk_api_key = var.yucca_freshdesk_api_key == "REPLACE_ME" ? "" : var.yucca_freshdesk_api_key - freshdesk_admin_api_key = var.yucca_freshdesk_admin_api_key == "REPLACE_ME" ? "" : var.yucca_freshdesk_admin_api_key - freshdesk_rules_enabled = local.freshdesk_url != "" && local.freshdesk_admin_api_key != "" -} - -# Bot-created tickets land here (FRESHDESK_GROUP_ID in the bot Secret). -resource "freshdesk_group" "discord" { - count = local.freshdesk_rules_enabled ? 1 : 0 - name = "FUTO Cloud (Staging)" - description = "FUTO Backups Discord tickets from staging, managed by yucca tf" -} - -# rule_type 4 = observer (ticket updates); performer type 1 = agent. -resource "freshdesk_automation_rule" "discord_webhook" { - count = local.freshdesk_rules_enabled ? 1 : 0 - name = "yucca staging discord ticket sync" - rule_type = 4 - active = true - performer = jsonencode({ type = 1 }) - events = jsonencode([ - { field_name = "reply_sent" }, - { field_name = "note_type", value = "public" }, - { field_name = "status", from = "--", to = "--" }, - ]) - actions = jsonencode([{ - field_name = "trigger_webhook" - request_type = "POST" - url = "https://${var.yucca_app_domain}/hooks/${random_password.yucca_freshdesk_webhook_path[0].result}" - content_layout = 2 - content = "{\"ticket_id\": {{ticket.id}}}" - custom_headers = { "x-freshdesk-secret" = random_password.yucca_freshdesk_webhook_secret[0].result } - }]) -} diff --git a/tf/deployment/staging/austin/talos/providers.tf b/tf/deployment/staging/austin/talos/providers.tf index 8a663c79..e0da888a 100644 --- a/tf/deployment/staging/austin/talos/providers.tf +++ b/tf/deployment/staging/austin/talos/providers.tf @@ -35,10 +35,10 @@ provider "kubernetes" { # same token the rest of the stack uses); no Connect host needed. provider "onepassword" {} -# Freshdesk automation rule + group (freshdesk.tf). Fallbacks keep the -# provider configurable while the manual items are unfilled — never contacted, -# every freshdesk resource is count-gated on the real config. +# TRANSITIONAL (see versions.tf): authenticates the destroys of the state-held +# freshdesk resources; the group/rule are now owned by the partition's +# global/freshdesk stack. provider "freshdesk" { domain = coalesce(local.freshdesk_url, "https://freshdesk.invalid") - api_key = coalesce(local.freshdesk_admin_api_key, "unset") + api_key = coalesce(var.yucca_freshdesk_admin_api_key == "REPLACE_ME" ? "" : var.yucca_freshdesk_admin_api_key, "unset") } diff --git a/tf/deployment/staging/austin/talos/secrets.tf b/tf/deployment/staging/austin/talos/secrets.tf index c3efc4b3..f74bf01a 100644 --- a/tf/deployment/staging/austin/talos/secrets.tf +++ b/tf/deployment/staging/austin/talos/secrets.tf @@ -237,38 +237,6 @@ resource "onepassword_item" "yucca_internal_secret" { password = random_password.yucca_internal_secret[0].result } -# Freshdesk webhook credentials, both TF-generated and mirrored into 1P: the -# header secret rides the bot Secret; the capability-URL path segment reaches -# the HTTPRoute via cluster-secrets (below) so it never appears in git or CI. -resource "random_password" "yucca_freshdesk_webhook_secret" { - count = local.provision_secrets ? 1 : 0 - length = 48 - special = false -} - -resource "onepassword_item" "yucca_freshdesk_webhook_secret" { - count = local.provision_secrets ? 1 : 0 - vault = data.onepassword_vault.staging[0].uuid - title = "YUCCA_FRESHDESK_WEBHOOK_SECRET" - category = "password" - - password = random_password.yucca_freshdesk_webhook_secret[0].result -} - -resource "random_password" "yucca_freshdesk_webhook_path" { - count = local.provision_secrets ? 1 : 0 - length = 32 - special = false -} - -resource "onepassword_item" "yucca_freshdesk_webhook_path" { - count = local.provision_secrets ? 1 : 0 - vault = data.onepassword_vault.staging[0].uuid - title = "YUCCA_FRESHDESK_WEBHOOK_PATH" - category = "password" - - password = random_password.yucca_freshdesk_webhook_path[0].result -} # Substituted into the Flux tree (apps.yaml postBuild, optional Secret source): # the one config channel that bypasses the git-committed cluster-settings. @@ -279,7 +247,7 @@ resource "kubernetes_secret_v1" "cluster_secrets" { namespace = "flux-system" } data = { - FRESHDESK_WEBHOOK_PATH = random_password.yucca_freshdesk_webhook_path[0].result + FRESHDESK_WEBHOOK_PATH = var.yucca_freshdesk_webhook_path } depends_on = [helm_release.flux_operator] } @@ -289,6 +257,13 @@ resource "kubernetes_secret_v1" "cluster_secrets" { # token and S3 keys default empty so this Secret can land before their 1P # items exist — the bot idles without a token and skips the archive sweep # without S3 keys. +locals { + # yucca-manual-secrets placeholders read back literally as REPLACE_ME — + # never let that masquerade as config. + freshdesk_url = var.yucca_freshdesk_url == "REPLACE_ME" ? "" : var.yucca_freshdesk_url + freshdesk_api_key = var.yucca_freshdesk_api_key == "REPLACE_ME" ? "" : var.yucca_freshdesk_api_key +} + resource "kubernetes_secret_v1" "futo_backups_bot" { count = local.provision_secrets ? 1 : 0 metadata { @@ -306,12 +281,13 @@ resource "kubernetes_secret_v1" "futo_backups_bot" { DISCORD_CUSTOMER_ROLE_ID = var.yucca_discord_customer_role_id TRANSCRIPT_S3_ACCESS_KEY_ID = var.sietch_transcripts_access_key TRANSCRIPT_S3_SECRET_ACCESS_KEY = var.sietch_transcripts_secret_key - # REPLACE_ME-guarded locals (freshdesk.tf) — an unfilled placeholder - # keeps the sync dormant. + # REPLACE_ME-guarded locals (below) — an unfilled placeholder keeps the + # sync dormant. The webhook credentials and group id come from the + # partition's global/freshdesk stack via 1P. FRESHDESK_URL = local.freshdesk_url FRESHDESK_API_KEY = local.freshdesk_api_key - FRESHDESK_GROUP_ID = try(tostring(freshdesk_group.discord[0].id), "") - FRESHDESK_WEBHOOK_SECRET = random_password.yucca_freshdesk_webhook_secret[0].result + FRESHDESK_GROUP_ID = var.yucca_freshdesk_group_id + FRESHDESK_WEBHOOK_SECRET = var.yucca_freshdesk_webhook_secret # Staging shares the prod Freshdesk account; the tag keeps its tickets # filterable in the agent view. FRESHDESK_TAGS = "staging" diff --git a/tf/deployment/staging/austin/talos/variables.tf b/tf/deployment/staging/austin/talos/variables.tf index 13069d49..c311e2a8 100644 --- a/tf/deployment/staging/austin/talos/variables.tf +++ b/tf/deployment/staging/austin/talos/variables.tf @@ -255,16 +255,30 @@ variable "yucca_freshdesk_api_key" { } variable "yucca_freshdesk_admin_api_key" { - description = "Freshdesk API key of an ADMIN agent, used only by the freshdesk provider to manage the automation rule and group (manual YUCCA_FRESHDESK_ADMIN_API_KEY item; never lands in the cluster). Empty = the rules are unmanaged." + description = "TRANSITIONAL (see versions.tf): admin key for destroying the state-held freshdesk resources (manual YUCCA_FRESHDESK_ADMIN_API_KEY item)." type = string sensitive = true default = "" } -variable "yucca_app_domain" { - description = "Public app domain the Freshdesk webhook rule targets. Must match APP_DOMAIN in the cluster-settings.generated.yaml of this cluster." +variable "yucca_freshdesk_webhook_secret" { + description = "Webhook header secret minted by the staging/global/freshdesk stack (YUCCA_FRESHDESK_WEBHOOK_SECRET); refs stay commented in tf/.env until its first apply." type = string - default = "staging.backups.futo.cloud" + sensitive = true + default = "" +} + +variable "yucca_freshdesk_webhook_path" { + description = "Capability-URL path segment minted by the staging/global/freshdesk stack (YUCCA_FRESHDESK_WEBHOOK_PATH); substituted into the Flux tree via cluster-secrets." + type = string + sensitive = true + default = "" +} + +variable "yucca_freshdesk_group_id" { + description = "Freshdesk agent-group id minted by the staging/global/freshdesk stack (YUCCA_FRESHDESK_GROUP_ID)." + type = string + default = "" } variable "yucca_discord_guild_id" { diff --git a/tf/deployment/staging/austin/talos/versions.tf b/tf/deployment/staging/austin/talos/versions.tf index 18c14a5e..f824b574 100644 --- a/tf/deployment/staging/austin/talos/versions.tf +++ b/tf/deployment/staging/austin/talos/versions.tf @@ -10,6 +10,14 @@ terraform { source = "hashicorp/random" version = "~> 3.6" } + # TRANSITIONAL: no freshdesk resources remain in config — the declaration + # only lets tofu destroy the state-held group/rule from the pre- + # global/freshdesk layout. Remove together with the provider block and + # yucca_freshdesk_admin_api_key after one apply. + freshdesk = { + source = "registry.terraform.io/slop-place/freshdesk" + version = "~> 0.1" + } helm = { source = "hashicorp/helm" version = "~> 3.1" @@ -29,10 +37,5 @@ terraform { source = "1Password/onepassword" version = "~> 2.1" } - # Freshdesk automation rule + agent group (freshdesk.tf). - freshdesk = { - source = "registry.terraform.io/slop-place/freshdesk" - version = "~> 0.1" - } } } diff --git a/tf/deployment/staging/global/freshdesk/.terraform.lock.hcl b/tf/deployment/staging/global/freshdesk/.terraform.lock.hcl new file mode 100644 index 00000000..0ae22891 --- /dev/null +++ b/tf/deployment/staging/global/freshdesk/.terraform.lock.hcl @@ -0,0 +1,69 @@ +# This file is maintained automatically by "tofu init". +# Manual edits may be lost in future updates. + +provider "registry.opentofu.org/1password/onepassword" { + version = "2.2.1" + constraints = "~> 2.1" + hashes = [ + "h1:aH5pZUimlQdiGnDLHRC49W4xnkx52wfd8XRLt69+764=", + "zh:025709a6b5f1b3685d277f2c48f7cb8b53d14b3699c1123d7e9a2135c099c533", + "zh:037fc89d150063a8aacdcab08ba26038b489fe2468d509b842d298ea59096ca6", + "zh:233777182b25faf1658e8ce171b684460983bb41cff79fb243662f3f9dc5ca6c", + "zh:2fb5ca2fc8c37b1d1c54da646ed13bf40897941fe92eece784fba496f677b533", + "zh:4b25b5ce1f694ec265e65234fc85d6bdf3810297ffeaced54ad46a1ba28142de", + "zh:5509d1e4fb7b45c63124ec66fd1f9d6757daa8bf1f7bdd724d5adb2965b61436", + "zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f", + "zh:a23ba946c629ec912b2fcbf606a2eb8853626ec0e0bee749f2d39146a872c082", + "zh:a3d3024485426237d7b4a4350b12dda4d29d88f3942246a9370be35ec2a51e9e", + "zh:a6ef65544ab8fc26d468b38636407a3d2d902e35c51b648729bf97c31d1937f9", + "zh:afbe9480a0da0ad8dc514b277f1e4be36b8931f045021d05c21665ef1ac0b7c8", + "zh:b2e96e69fa9ff7e179dccdef5b785cd020eb46bb2b3d1d507d009d71be6b0c26", + "zh:ceefaede9e8a3104463523ba267e3e985b27a706f7628a9ddd37330c2ca59d4d", + "zh:ea77786bd6809ff4f8043b84a0212fec4de18b7d51bc420417ba10999ca99887", + "zh:f7d8160c3669c8ab76a2da14ea740d91a08ca23d1fb657669e52a840b2b113d9", + ] +} + +provider "registry.opentofu.org/hashicorp/random" { + version = "3.9.0" + constraints = "~> 3.6" + hashes = [ + "h1:U8KXqGCoNI9/guYbTvzgdtVk3fRthoG0UXwm1JoEpIs=", + "zh:03f1114cc20b8913523735ab76e0f0a2b16ce13c92923a53304bf85f07fc0dbc", + "zh:105b678ee72322a3067f105d7e05e940f6143238f377f6e87ff4ec909246ac2a", + "zh:55f3bbf13ea18cbace61a706566a80f25f33fe2b1780b6f3d7b582af2a05b6d2", + "zh:63adf996db48f082f7a6351eb485e219cd88795fc71e6ec60a837263ab0d2cb1", + "zh:7e99550738a4e3cc68b8a467714b0d69371025fe95e3326d5323d026d55653e9", + "zh:8342b54af3a18a37e075eeae61be57f4de2ba71b35d95c5075d402dd2c1f289d", + "zh:83ee18e32ac9dd5fc91298554b7c4cfa4c3a1db50f4c797945637cc93c0844ae", + "zh:993ecc0adbf6bd535a59fbc9b735d8c33950e6f6eb5e621d750da9b71d65d80a", + "zh:ad722bc59d4edbf1415e827fc007c0efe6e0e9462d5568bae20b34be1058a261", + "zh:ae9448e1f87b2f9a6c5197a0e9862162ec6b137cb3a3835e11522995d8939e7c", + "zh:bc9cdd3aac784f759125c6627f6f6416e8726a1c184eb9cf3e55b9edbc94c627", + "zh:c8e35b89572ba1c40a9b20022e033a3395fb8d42e7604d50c900f193ba10382e", + "zh:e2deaa8a9975ef81d9f62baed12c41286918b0a10908e0e031f13f69a3b730a1", + "zh:ee39707557210a0ab1098aa357d2cdfe502e5a312d0dbdffb09d08facc4d3fc5", + "zh:f81afe4eb63e8aa9e0ea71be6c990f0dc69cb360e7191c0742a991f4a5081b64", + ] +} + +provider "registry.terraform.io/slop-place/freshdesk" { + version = "0.1.1" + constraints = "~> 0.1" + hashes = [ + "h1:VyOKR4IfX4LWwW1Gv6rAa5A5x/h0IEDqKHL4hjliao8=", + "zh:19326bde07045235a0b41a1e0936a0929ee5b8a63d1856199dde34c1e2a2f641", + "zh:2221a63af314b9fae115bd455564e80105ead873856ebff4a9ae967fa91b9408", + "zh:239a2dc1433199705b587470fc86fc5d75761e2b6b2d640a112d416966676479", + "zh:4b7678d13a51cf6525ea7aec5788e2fcb96c8ef6b9f853937d224946f928ddb4", + "zh:74e33bffa31c664a3ae1cb20d098c804ff92c83fc042ec7d24b68f0ebd8f027f", + "zh:7943bf339e8f825b56c109ad92702102cdf4d07d577c48e45228e2f0d7889eaf", + "zh:7d4958ed366239294b085542859d5cc135b8041e1c5c5ab2a0b8c278c45df665", + "zh:91b8bcc4e58ba08f4e3380d07d6df32719d8464607cc1a0c56f75978cccd0cc9", + "zh:9deb669d3cd5dd598f83d51112987540c71c46f49b6d7841fd72a88fcb84f3f0", + "zh:ae3a04d72429f1b45db0cb1b1741ff007fc558c864c3b64652a48f1ab636f102", + "zh:d477d2919ff96f3d58be4df678f8eca52fb24254d53590fddca6b34d49d48215", + "zh:dd743e45b051dcff9b4f88bd99a6c5c24be9a614ee750625f900b4d0022634e8", + "zh:f809ab383cca0a5f83072981c64208cbd7fa67e986a86ee02dd2c82333221e32", + ] +} diff --git a/tf/deployment/staging/global/freshdesk/discovery.tf b/tf/deployment/staging/global/freshdesk/discovery.tf new file mode 100644 index 00000000..dd7eb4ba --- /dev/null +++ b/tf/deployment/staging/global/freshdesk/discovery.tf @@ -0,0 +1,31 @@ +# ── Discovery contract ────────────────────────────────────────────────────── +# Single non-sensitive envelope consumed by yuctl. The credential values stay +# in 1P — only the item titles are output. See tf/README.md. + +output "discovery_schema_version" { + description = "Schema version of the discovery output envelope." + value = 1 +} + +output "discovery" { + description = "Freshdesk payload for this partition (non-sensitive)." + value = { + schema_version = 1 + partition = var.partition + region = var.region + slug = var.slug + role = var.role + stack = var.stack + stack_type = "freshdesk" + freshdesk = { + vault = "yucca_tf_${var.partition}" + group_name = "FUTO Cloud (Staging)" + rule_managed = nonsensitive(local.enabled) + item_titles = { + webhook_path = "YUCCA_FRESHDESK_WEBHOOK_PATH" + webhook_secret = "YUCCA_FRESHDESK_WEBHOOK_SECRET" + group_id = "YUCCA_FRESHDESK_GROUP_ID" + } + } + } +} diff --git a/tf/deployment/staging/global/freshdesk/main.tf b/tf/deployment/staging/global/freshdesk/main.tf new file mode 100644 index 00000000..1b26f996 --- /dev/null +++ b/tf/deployment/staging/global/freshdesk/main.tf @@ -0,0 +1,99 @@ +# The webhook contract, TF-owned end to end: this stack mints the capability- +# URL path segment and the x-freshdesk-secret header, mirrors them into 1P, +# and points the Freshdesk automation rule at them — no human ever handles +# the values, and the talos stack consumes them back via op:// refs (bot +# Secret + cluster-secrets). See docs/discord-support.md. + +locals { + # yucca-manual-secrets placeholders read back literally as REPLACE_ME — + # never let that masquerade as config. + url = var.yucca_freshdesk_url == "REPLACE_ME" ? "" : var.yucca_freshdesk_url + admin_api_key = var.yucca_freshdesk_admin_api_key == "REPLACE_ME" ? "" : var.yucca_freshdesk_admin_api_key + enabled = local.url != "" && local.admin_api_key != "" +} + +# Fallbacks keep the provider configurable while the manual items are +# unfilled — never contacted, the freshdesk resources are count-gated. +provider "freshdesk" { + domain = coalesce(local.url, "https://freshdesk.invalid") + api_key = coalesce(local.admin_api_key, "unset") +} + +provider "onepassword" {} + +data "onepassword_vault" "partition" { + name = "yucca_tf_${var.partition}" +} + +# Generated unconditionally (no Freshdesk dependency) so the talos stack's +# op:// refs can be uncommented right after this stack's first apply. +resource "random_password" "webhook_secret" { + length = 48 + special = false +} + +resource "onepassword_item" "webhook_secret" { + vault = data.onepassword_vault.partition.uuid + title = "YUCCA_FRESHDESK_WEBHOOK_SECRET" + category = "password" + + password = random_password.webhook_secret.result +} + +resource "random_password" "webhook_path" { + length = 32 + special = false +} + +resource "onepassword_item" "webhook_path" { + vault = data.onepassword_vault.partition.uuid + title = "YUCCA_FRESHDESK_WEBHOOK_PATH" + category = "password" + + password = random_password.webhook_path.result +} + +# Bot-created tickets land here; the id reaches the bot Secret through the +# 1P item below. +resource "freshdesk_group" "support" { + count = local.enabled ? 1 : 0 + name = "FUTO Cloud (Staging)" + description = "FUTO Backups support tickets from staging, managed by yucca tf" +} + +resource "onepassword_item" "group_id" { + count = local.enabled ? 1 : 0 + vault = data.onepassword_vault.partition.uuid + title = "YUCCA_FRESHDESK_GROUP_ID" + category = "password" + + password = tostring(freshdesk_group.support[0].id) +} + +# rule_type 4 = observer (ticket updates); performer type 1 = agent. events/ +# actions are raw Automations-API JSON (the provider passes them through), +# mirroring a rule read back via GET /api/v2/automations/4/rules — NB +# content_type is required, content_layout is a string, and content is a JSON +# object whose string values carry the placeholders. Scoping to yucca tickets +# happens bot-side, so the rule has no conditions. +resource "freshdesk_automation_rule" "ticket_sync" { + count = local.enabled ? 1 : 0 + name = "yucca staging ticket sync" + rule_type = 4 + active = true + performer = jsonencode({ type = 1 }) + events = jsonencode([ + { field_name = "reply_sent" }, + { field_name = "note_type", value = "public" }, + { field_name = "status", from = "--", to = "--" }, + ]) + actions = jsonencode([{ + field_name = "trigger_webhook" + request_type = "POST" + content_type = "JSON" + content_layout = "2" + url = "https://${var.yucca_app_domain}/hooks/${random_password.webhook_path.result}" + content = { ticket_id = "{{ticket.id}}" } + custom_headers = { "x-freshdesk-secret" = random_password.webhook_secret.result } + }]) +} diff --git a/tf/deployment/staging/global/freshdesk/terragrunt.hcl b/tf/deployment/staging/global/freshdesk/terragrunt.hcl new file mode 100644 index 00000000..cce38bfb --- /dev/null +++ b/tf/deployment/staging/global/freshdesk/terragrunt.hcl @@ -0,0 +1,8 @@ +include "root" { + path = find_in_parent_folders("terragrunt.hcl") +} + +# Freshdesk-side support wiring for the partition (docs/discord-support.md): +# webhook credentials, the agent group and the ticket-update automation rule. +# partition/region are injected by the root config (parsed from the path: +# deployment//global/freshdesk). diff --git a/tf/deployment/staging/global/freshdesk/variables.tf b/tf/deployment/staging/global/freshdesk/variables.tf new file mode 100644 index 00000000..0bdc14d9 --- /dev/null +++ b/tf/deployment/staging/global/freshdesk/variables.tf @@ -0,0 +1,71 @@ +variable "partition" { + description = "Partition slug, injected by terragrunt from the path (deployment//global/freshdesk)." + type = string +} + +variable "region" { + description = "Region slug (global for this partition-wide stack)." + type = string + default = null +} + +variable "stack" { + description = "Stack name (freshdesk)." + type = string + default = null +} + +variable "slug" { + description = "Canonical - slug." + type = string + default = null +} + +variable "role" { + description = "Region role (null for the global pseudo-region)." + type = string + default = null +} + +variable "site_id" { + description = "Fabric site id (null for global)." + type = number + default = null +} + +variable "datacenter" { + description = "Datacenter segment of the region FQDN (null for global)." + type = string + default = null +} + +variable "provider_code" { + description = "Provider segment of the region FQDN (null for global)." + type = string + default = null +} + +variable "domain" { + description = "Region FQDN suffix (null for global)." + type = string + default = null +} + +variable "yucca_freshdesk_url" { + description = "Freshdesk base URL (manual YUCCA_FRESHDESK_URL item). Empty = the group/rule stay unmanaged." + type = string + default = "" +} + +variable "yucca_freshdesk_admin_api_key" { + description = "Freshdesk API key of an ADMIN agent, used only by this stack for group/rule CRUD (manual YUCCA_FRESHDESK_ADMIN_API_KEY item; never lands in a cluster)." + type = string + sensitive = true + default = "" +} + +variable "yucca_app_domain" { + description = "Public app domain the webhook rule targets. Must match APP_DOMAIN in the partition's cluster-settings.generated.yaml." + type = string + default = "staging.backups.futo.cloud" +} diff --git a/tf/deployment/staging/global/freshdesk/versions.tf b/tf/deployment/staging/global/freshdesk/versions.tf new file mode 100644 index 00000000..9c6d1475 --- /dev/null +++ b/tf/deployment/staging/global/freshdesk/versions.tf @@ -0,0 +1,19 @@ +terraform { + required_version = "~> 1.11" + required_providers { + freshdesk = { + source = "registry.terraform.io/slop-place/freshdesk" + version = "~> 0.1" + } + # Webhook path + header secret generation. + random = { + source = "hashicorp/random" + version = "~> 3.6" + } + # Mirrors the generated credentials into 1P for the talos stack to consume. + onepassword = { + source = "1Password/onepassword" + version = "~> 2.1" + } + } +} diff --git a/tf/shared/modules/fabric-login/variables.tf b/tf/shared/modules/fabric-login/variables.tf index 1d9fa219..e9933d95 100644 --- a/tf/shared/modules/fabric-login/variables.tf +++ b/tf/shared/modules/fabric-login/variables.tf @@ -21,7 +21,7 @@ variable "users" { # key landing in a read-only class, as happened with nutgood/netops both at # uid 3000). validation { - condition = length(distinct([for u in var.users : u.uid if u.uid != null])) == length([for u in var.users : u.uid if u.uid != null]) + condition = length(distinct([for u in var.users : u.uid if u.uid != null])) == length([for u in var.users : u.uid if u.uid != null]) error_message = "Duplicate uid across login users: Junos treats same-uid logins as one user and silently merges their classes/keys. Give every user a unique uid." } }