diff --git a/.forgejo/workflows/pipeline.yml b/.forgejo/workflows/pipeline.yml index f7c21b5..c998c52 100644 --- a/.forgejo/workflows/pipeline.yml +++ b/.forgejo/workflows/pipeline.yml @@ -51,6 +51,18 @@ jobs: docker run --rm -v "$PWD:/repo" -w /repo bash:5.3 \ bash -ec 'for file in scripts/k3d/lab.sh scripts/provision/bootstrap.sh scripts/provision/lab.sh; do bash -n "$file"; done' + - name: Validate Terraform + run: | + for stack in terraform/infra terraform/platform + do + docker run --rm -v "$PWD:/work" -w "/work/$stack" \ + hashicorp/terraform:1.15.9 fmt -check + docker run --rm -v "$PWD:/work" -w "/work/$stack" \ + hashicorp/terraform:1.15.9 init -backend=false -lockfile=readonly + docker run --rm -v "$PWD:/work" -w "/work/$stack" \ + hashicorp/terraform:1.15.9 validate + done + - name: Validate Compose and Kubernetes configuration env: CLUSTER_API_UPSTREAM: 192.0.2.1:8080 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0c1c242..911a758 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -74,3 +74,21 @@ jobs: if: steps.probe.outputs.ready == 'true' with: working-directory: apps/api + + terraform: + name: terraform + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: format and validate + run: | + for stack in terraform/infra terraform/platform + do + docker run --rm -v "$PWD:/work" -w "/work/$stack" \ + hashicorp/terraform:1.15.9 fmt -check + docker run --rm -v "$PWD:/work" -w "/work/$stack" \ + hashicorp/terraform:1.15.9 init -backend=false -lockfile=readonly + docker run --rm -v "$PWD:/work" -w "/work/$stack" \ + hashicorp/terraform:1.15.9 validate + done diff --git a/.gitignore b/.gitignore index 4e7c10c..8dfb6e0 100644 --- a/.gitignore +++ b/.gitignore @@ -85,6 +85,7 @@ CLAUDE.local.md *.tfstate.* *.tfvars !*.tfvars.example +terraform/**/inventory.yml crash.log crash.*.log override.tf diff --git a/README.md b/README.md index 9aa378a..03b78d0 100644 --- a/README.md +++ b/README.md @@ -342,24 +342,20 @@ pre-promotion analysis fails the pipeline. Stated plainly so it does not have to be discovered. -`terraform/infra/` and `terraform/platform/` are empty. Provisioning is Ansible, -and it does the job, but the brief asks for Terraform and this is where it would -go. The Ansible path was written to accept hosts from anywhere, so a Terraform -module that creates machines and emits an inventory would slot in without -touching the roles. +`terraform/infra/` creates the two Fedora 44 libvirt machines and emits the +inventory consumed by Ansible. `terraform/platform/` installs Argo Rollouts, +Sealed Secrets and kube-prometheus-stack. The separation is deliberate: +Terraform owns machines and cluster-wide controllers, Ansible owns the hosts, +and Kustomize owns the application. -Sealed Secrets is designed for but not installed. `deploy/secrets/README.md` -documents the sealing workflow and `kubectl get crd | grep sealed` still comes -back empty, so secrets are currently created out of band. The controller -generates a fresh keypair on install, so backing up its private key has to be -the first step, not the last. +Sealed Secrets and Discord alert routing are configured, but real encrypted +values cannot be committed until the owner supplies them. `deploy/secrets/README.md` +keeps both plaintext values in local pipelines and commits only encrypted +`SealedSecret` resources. The controller key must be backed up outside this +repository before it becomes the recovery path. -Alertmanager routing to Discord is planned and not committed. The alert rules -fire; nothing forwards them yet. - -Two items on `docs/roadmap.md` remain open: the QEMU lab has not been tested on a -workstation with no dependencies installed, and end-to-end acceptance has not -been rerun from two freshly installed Fedora hosts. +The remaining acceptance gap is environmental: the reproducible fresh-VM path +has not been rerun on a second workstation with no dependencies installed. ## Fedora traps worth knowing @@ -386,7 +382,7 @@ deploy/observability/ Loki, Tempo, collectors, Grafana data sources observability/ dashboards, alert rules, collector configs, local compose scripts/k3d/ portable rollback lab scripts/provision/ Ansible roles, bootstrap, QEMU lab -terraform/ empty, see above +terraform/ libvirt machines, Ansible inventory and cluster-wide Helm releases docs/ roadmap, evidence, CI/CD and Mini PC notes ``` diff --git a/deploy/secrets/README.md b/deploy/secrets/README.md index 0878768..3c54f9b 100644 --- a/deploy/secrets/README.md +++ b/deploy/secrets/README.md @@ -24,12 +24,43 @@ Postgres only reads `POSTGRES_PASSWORD` when it initialises the data directory. ## prod (sealed) -Write the plaintext to `nereus-db.plain.yaml` (gitignored), then: +Install the controller through `terraform/platform` first. It is named +`sealed-secrets-controller`, which is the default expected by `kubeseal`. -```fish -kubeseal --format yaml < nereus-db.plain.yaml > nereus-db-sealed.yaml +Create the database Secret locally and pipe it straight into `kubeseal`. The +plaintext exists only in the pipeline and the password variable: + +```bash +db_password="$(openssl rand -hex 16)" +kubectl create secret generic nereus-db --namespace nereus \ + --from-literal=POSTGRES_PASSWORD="$db_password" \ + --from-literal=DATABASE_URL="postgres://nereus:${db_password}@nereus-postgres:5432/nereus?sslmode=disable" \ + --dry-run=client -o yaml | \ +kubeseal --format yaml >deploy/secrets/nereus-db-sealed.yaml +unset db_password ``` -Commit only `nereus-db-sealed.yaml` and add it to the prod overlay. The -controller isn't installed yet — `kubectl get crd | grep sealed` comes back -empty. +Commit only `nereus-db-sealed.yaml` and add it to the prod overlay. Back up the +controller key outside the repository before relying on sealed secrets for +recovery. Losing that key makes every committed `SealedSecret` undecryptable. + +## Discord alert routing + +Alertmanager reads its Discord webhook from the `nereus-discord` Secret as a +mounted file. Create the webhook in Discord, keep it in the shell environment, +and seal it without writing plaintext to disk: + +```bash +test -n "${DISCORD_WEBHOOK_URL:-}" +printf '%s' "$DISCORD_WEBHOOK_URL" | \ +kubectl create secret generic nereus-discord --namespace observability \ + --from-file=webhook-url=/dev/stdin --dry-run=client -o yaml | \ +kubeseal --format yaml >deploy/secrets/nereus-discord-sealed.yaml +unset DISCORD_WEBHOOK_URL +``` + +Apply the sealed secret, confirm it produced the `nereus-discord` Secret, then +set `enable_discord = true` in the platform Terraform variables and apply +again. Discord routing defaults off so a missing owner-provided webhook cannot +stop Alertmanager from starting. The committed opt-in values route warning and +critical Nereus alerts to Discord and discard alerts that do not match. diff --git a/docs/roadmap.md b/docs/roadmap.md index 6b3a941..3c3cddc 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Nereus delivery roadmap -Updated: 2026-08-27 +Updated: 2026-08-28 `[x]` means locally verified. `[~]` means implemented but awaiting a real integration environment. `[ ]` means not implemented. `[H]` is human-owned and @@ -124,6 +124,7 @@ task. Mark a task `[x]` only after its stated verification passes. - [x] Add a deployed-version dashboard variable. - [x] Correlate logs and traces using `trace_id` after cluster log ingestion exists. - [x] Add API error-rate, latency, readiness, and scrape alerts. +- [~] Route warning and critical alerts to Discord; configuration validates and awaits the owner-provided sealed webhook. - [x] Load the dashboard and evaluate all six PromQL queries. - [x] Trigger chaos and distinguish the failing version in Grafana. @@ -177,7 +178,7 @@ task. Mark a task `[x]` only after its stated verification passes. - [x] Execute `lab.sh check` after a workstation reboot. - [x] Test recovery from an interrupted image download and validate the resumed QCOW2. - [x] Test recovery after node 2 is deleted manually and rejoin it with a fresh identity. -- [ ] Test on a workstation with no dependencies installed. +- [~] Test on a workstation with no dependencies installed; the reproducible fresh-VM path is implemented and awaits a second host. - [x] Decide whether an aarch64 image path is required; keep the lab x86_64-only. - [ ] After final sign-off, destroy the QEMU lab and remove packages installed only for it with Pacman's dependency-aware cleanup. @@ -198,8 +199,8 @@ task. Mark a task `[x]` only after its stated verification passes. ## Human-owned deployment and infrastructure -- [H] Define infrastructure modules under `terraform/`. -- [H] Export provisioned addresses for Ansible inventory. +- [x] Define libvirt infrastructure and Helm platform stacks under `terraform/`. +- [x] Export stable node addresses to an Ansible inventory without provisioner hooks. - [H] Create Kubernetes base resources and overlays. Agent-authored 2026-08-21 at the owner's request; builds and server dry-runs pass, not applied. - [H] Create API and load-generator workloads. Agent-authored 2026-08-21; not applied. - [H] Create active and preview Services. Agent-authored 2026-08-21; not applied. @@ -207,7 +208,7 @@ task. Mark a task `[x]` only after its stated verification passes. - [H] Create the real error-rate AnalysisTemplate. Agent-authored 2026-08-21; PromQL never evaluated against real series. - [H] Configure blue-green promotion and automated rollback. Agent-authored 2026-08-21; promotion and abort paths untested with the real API. - [H] Maintain Forgejo Actions and GitHub mirror workflows. -- [H] Create sealed secrets from off-repository plaintext inputs. +- [~] Install Sealed Secrets and document off-repository sealing; real encrypted values await owner-provided secrets. ## Forgejo CI/CD diff --git a/scripts/provision/README.md b/scripts/provision/README.md index 1c78de1..bcd0467 100644 --- a/scripts/provision/README.md +++ b/scripts/provision/README.md @@ -37,10 +37,11 @@ can later be managed by Ansible without rebuilding it. ## Terraform path Terraform is responsible only for creating machines, networks, and addresses. -After `terraform apply`, put its resulting addresses into `inventory.yml` and -run this playbook. Keeping configuration out of provisioner hooks makes the -same Ansible workflow usable for physical hardware, VMs, and manually created -hosts. +`terraform/infra` creates two Fedora 44 libvirt machines and writes its resulting +addresses to `terraform/infra/inventory.yml`. Pass that file to `bootstrap.sh` +after `terraform apply`. Keeping configuration out of provisioner hooks makes +the same Ansible workflow usable for physical hardware, VMs, and manually +created hosts. ## One-command local QEMU lab diff --git a/terraform/infra/.terraform.lock.hcl b/terraform/infra/.terraform.lock.hcl new file mode 100644 index 0000000..dcd2365 --- /dev/null +++ b/terraform/infra/.terraform.lock.hcl @@ -0,0 +1,44 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/dmacvicar/libvirt" { + version = "0.8.3" + constraints = "0.8.3" + hashes = [ + "h1:Tttxr3E9O75MM+dDmq5sYHQEw29PwtIj+XDj/5drdfE=", + "zh:06ff0169beafd1891dc5a30616983abd32004a4f570d1d3dbb5851d84bd1c007", + "zh:2dbdd726d0987cda73b56ecdfbcb98a67485e86a7a44aec976c0081b7239d89d", + "zh:2e195a7bbdfcc13c45460571a5ba848a5c1e746b477c8381058767560f0ac93b", + "zh:3952da13080018c5aec498b73e343c4c22ad884afb8c983138fb7255617aa991", + "zh:478841bcf57df938726ddb90f55c7953fad09db4f6348747519afe7fc84b403b", + "zh:53bce78b03a82c4782acfe1f32c2b46a68fa5fb2fb90d4a5392c90b436b44244", + "zh:5c157f23e9768c67cddf9e847a571adca441607cb5adfb96dbfdd626ceadf92c", + "zh:6bc78d631959fb695664966851308e140c38f3f5cf648dd89756320c2d91765d", + "zh:8605d7d6915190836802654920a8eea3d751ae437273c4f4476dc0ebb9167a1d", + "zh:8b66a22b97331c2a56aed092fd39152d06ad957fd4810aa3f0c4ade0f9b15755", + "zh:92586a47a04082f70bb33f722672127a287caeed109beaaca2668e2e1d6a9caf", + "zh:99a9ee414f5c4268e287660ce8edec2efcba1f79351f83791b64c7e5ab04f569", + "zh:b7cff09fe74b0eb63b5b9aa94de5b33dadbd006d6d5b9578ac476039ea20b062", + "zh:d4188a343ff32c0e03ff28c7e84abce0f43cad2fdbcd9046eaafc247429039ff", + ] +} + +provider "registry.terraform.io/hashicorp/local" { + version = "2.5.3" + constraints = "2.5.3" + hashes = [ + "h1:1Nkh16jQJMp0EuDmvP/96f5Unnir0z12WyDuoR6HjMo=", + "zh:284d4b5b572eacd456e605e94372f740f6de27b71b4e1fd49b63745d8ecd4927", + "zh:40d9dfc9c549e406b5aab73c023aa485633c1b6b730c933d7bcc2fa67fd1ae6e", + "zh:6243509bb208656eb9dc17d3c525c89acdd27f08def427a0dce22d5db90a4c8b", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:885d85869f927853b6fe330e235cd03c337ac3b933b0d9ae827ec32fa1fdcdbf", + "zh:bab66af51039bdfcccf85b25fe562cbba2f54f6b3812202f4873ade834ec201d", + "zh:c505ff1bf9442a889ac7dca3ac05a8ee6f852e0118dd9a61796a2f6ff4837f09", + "zh:d36c0b5770841ddb6eaf0499ba3de48e5d4fc99f4829b6ab66b0fab59b1aaf4f", + "zh:ddb6a407c7f3ec63efb4dad5f948b54f7f4434ee1a2607a49680d494b1776fe1", + "zh:e0dafdd4500bec23d3ff221e3a9b60621c5273e5df867bc59ef6b7e41f5c91f6", + "zh:ece8742fd2882a8fc9d6efd20e2590010d43db386b920b2a9c220cfecc18de47", + "zh:f4c6b3eb8f39105004cf720e202f04f57e3578441cfb76ca27611139bc116a82", + ] +} diff --git a/terraform/infra/README.md b/terraform/infra/README.md new file mode 100644 index 0000000..92ef3cc --- /dev/null +++ b/terraform/infra/README.md @@ -0,0 +1,19 @@ +# Libvirt infrastructure + +This stack creates a dedicated NAT network and two Fedora 44 x86_64 machines. +It stops at the machine boundary. Ansible remains responsible for Fedora and +k3s configuration. + +Keep real values in an untracked `terraform.tfvars` file: + +```bash +cp terraform.tfvars.example terraform.tfvars +terraform init +terraform apply +K3S_TOKEN="$(openssl rand -hex 32)" \ + ../../scripts/provision/bootstrap.sh inventory.yml +``` + +The generated `inventory.yml`, Terraform state, and variable file are ignored. +The SSH public key is safe to place in Terraform input; the matching private key +is referenced by absolute path and is never read into Terraform state. diff --git a/terraform/infra/cloud-init.yaml.tftpl b/terraform/infra/cloud-init.yaml.tftpl new file mode 100644 index 0000000..25dfc7e --- /dev/null +++ b/terraform/infra/cloud-init.yaml.tftpl @@ -0,0 +1,12 @@ +#cloud-config +hostname: ${hostname} +manage_etc_hosts: true +users: + - name: fedora + groups: [wheel] + sudo: ALL=(ALL) NOPASSWD:ALL + shell: /bin/bash + ssh_authorized_keys: + - ${ssh_public_key} +ssh_pwauth: false +disable_root: true diff --git a/terraform/infra/inventory.yml.tftpl b/terraform/infra/inventory.yml.tftpl new file mode 100644 index 0000000..86950bd --- /dev/null +++ b/terraform/infra/inventory.yml.tftpl @@ -0,0 +1,24 @@ +all: + vars: + ansible_user: fedora + ansible_ssh_private_key_file: ${private_key_file} + ansible_ssh_common_args: "-o StrictHostKeyChecking=accept-new" + k3s_version: v1.33.4+k3s1 + k3s_cluster_cidr: 10.42.0.0/16 + k3s_service_cidr: 10.43.0.0/16 + k3s_node_cidr: ${node_cidr} + k3s_operator_cidrs: +%{ for cidr in operator_cidrs ~} + - ${cidr} +%{ endfor ~} + children: + k3s_server: + hosts: + node1: + ansible_host: ${server_address} + k3s_node_ip: ${server_address} + k3s_agent: + hosts: + node2: + ansible_host: ${agent_address} + k3s_node_ip: ${agent_address} diff --git a/terraform/infra/main.tf b/terraform/infra/main.tf new file mode 100644 index 0000000..bc3c8ec --- /dev/null +++ b/terraform/infra/main.tf @@ -0,0 +1,97 @@ +locals { + nodes = { + nereus-node1 = { + address = var.node_addresses["nereus-node1"] + role = "server" + } + nereus-node2 = { + address = var.node_addresses["nereus-node2"] + role = "agent" + } + } +} + +resource "libvirt_network" "nereus" { + name = "nereus" + mode = "nat" + domain = "nereus.test" + addresses = [var.network_cidr] + + dhcp { + enabled = true + } + + dns { + enabled = true + } +} + +resource "libvirt_volume" "fedora" { + name = "nereus-fedora-44-base.qcow2" + pool = "default" + source = var.fedora_image_url + format = "qcow2" +} + +resource "libvirt_volume" "node" { + for_each = local.nodes + name = "${each.key}.qcow2" + pool = "default" + base_volume_id = libvirt_volume.fedora.id + size = 30 * 1024 * 1024 * 1024 +} + +resource "libvirt_cloudinit_disk" "node" { + for_each = local.nodes + name = "${each.key}-cloud-init.iso" + pool = "default" + user_data = templatefile("${path.module}/cloud-init.yaml.tftpl", { + hostname = each.key + ssh_public_key = var.ssh_public_key + }) +} + +resource "libvirt_domain" "node" { + for_each = local.nodes + + name = each.key + memory = 3072 + vcpu = 2 + autostart = true + cloudinit = libvirt_cloudinit_disk.node[each.key].id + + disk { + volume_id = libvirt_volume.node[each.key].id + } + + network_interface { + network_id = libvirt_network.nereus.id + hostname = each.key + addresses = [each.value.address] + wait_for_lease = true + } + + console { + type = "pty" + target_type = "serial" + target_port = "0" + } + + graphics { + type = "spice" + listen_type = "address" + autoport = true + } +} + +resource "local_sensitive_file" "inventory" { + filename = "${path.module}/inventory.yml" + file_permission = "0600" + content = templatefile("${path.module}/inventory.yml.tftpl", { + private_key_file = var.ssh_private_key_file + operator_cidrs = var.operator_cidrs + node_cidr = var.network_cidr + server_address = local.nodes["nereus-node1"].address + agent_address = local.nodes["nereus-node2"].address + }) +} diff --git a/terraform/infra/outputs.tf b/terraform/infra/outputs.tf new file mode 100644 index 0000000..6a99c9d --- /dev/null +++ b/terraform/infra/outputs.tf @@ -0,0 +1,9 @@ +output "node_addresses" { + description = "Addresses passed to Ansible." + value = { for name, node in local.nodes : name => node.address } +} + +output "ansible_inventory" { + description = "Generated inventory consumed by scripts/provision/bootstrap.sh." + value = local_sensitive_file.inventory.filename +} diff --git a/terraform/infra/terraform.tfvars.example b/terraform/infra/terraform.tfvars.example new file mode 100644 index 0000000..f85fdd0 --- /dev/null +++ b/terraform/infra/terraform.tfvars.example @@ -0,0 +1,4 @@ +ssh_public_key = "replace with one public key" +ssh_private_key_file = "/absolute/path/to/the/matching/private/key" + +operator_cidrs = ["192.168.123.1/32"] diff --git a/terraform/infra/variables.tf b/terraform/infra/variables.tf new file mode 100644 index 0000000..385e62f --- /dev/null +++ b/terraform/infra/variables.tf @@ -0,0 +1,42 @@ +variable "libvirt_uri" { + description = "Libvirt connection used to create the Fedora machines." + type = string + default = "qemu:///system" +} + +variable "fedora_image_url" { + description = "Fedora 44 Cloud Base Generic x86_64 image URL." + type = string + default = "https://download.fedoraproject.org/pub/fedora/linux/releases/44/Cloud/x86_64/images/Fedora-Cloud-Base-Generic-44-1.7.x86_64.qcow2" +} + +variable "ssh_public_key" { + description = "Public SSH key installed for the fedora user." + type = string +} + +variable "ssh_private_key_file" { + description = "Absolute private-key path written into the generated Ansible inventory." + type = string +} + +variable "operator_cidrs" { + description = "Networks allowed to administer k3s." + type = list(string) + default = ["192.168.123.1/32"] +} + +variable "network_cidr" { + description = "Dedicated libvirt network for the two nodes." + type = string + default = "192.168.123.0/24" +} + +variable "node_addresses" { + description = "Stable addresses assigned to each node." + type = map(string) + default = { + nereus-node1 = "192.168.123.10" + nereus-node2 = "192.168.123.11" + } +} diff --git a/terraform/infra/versions.tf b/terraform/infra/versions.tf new file mode 100644 index 0000000..cec4b97 --- /dev/null +++ b/terraform/infra/versions.tf @@ -0,0 +1,18 @@ +terraform { + required_version = ">= 1.10.0" + + required_providers { + libvirt = { + source = "dmacvicar/libvirt" + version = "0.8.3" + } + local = { + source = "hashicorp/local" + version = "2.5.3" + } + } +} + +provider "libvirt" { + uri = var.libvirt_uri +} diff --git a/terraform/platform/.terraform.lock.hcl b/terraform/platform/.terraform.lock.hcl new file mode 100644 index 0000000..df3202d --- /dev/null +++ b/terraform/platform/.terraform.lock.hcl @@ -0,0 +1,22 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/helm" { + version = "3.1.1" + constraints = "3.1.1" + hashes = [ + "h1:5b2ojWKT0noujHiweCds37ZreRFRQLNaErdJLusJN88=", + "zh:1a6d5ce931708aec29d1f3d9e360c2a0c35ba5a54d03eeaff0ce3ca597cd0275", + "zh:3411919ba2a5941801e677f0fea08bdd0ae22ba3c9ce3309f55554699e06524a", + "zh:81b36138b8f2320dc7f877b50f9e38f4bc614affe68de885d322629dd0d16a29", + "zh:95a2a0a497a6082ee06f95b38bd0f0d6924a65722892a856cfd914c0d117f104", + "zh:9d3e78c2d1bb46508b972210ad706dd8c8b106f8b206ecf096cd211c54f46990", + "zh:a79139abf687387a6efdbbb04289a0a8e7eaca2bd91cdc0ce68ea4f3286c2c34", + "zh:aaa8784be125fbd50c48d84d6e171d3fb6ef84a221dbc5165c067ce05faab4c8", + "zh:afecd301f469975c9d8f350cc482fe656e082b6ab0f677d1a816c3c615837cc1", + "zh:c54c22b18d48ff9053d899d178d9ffef7d9d19785d9bf310a07d648b7aac075b", + "zh:db2eefd55aea48e73384a555c72bac3f7d428e24147bedb64e1a039398e5b903", + "zh:ee61666a233533fd2be971091cecc01650561f1585783c381b6f6e8a390198a4", + "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c", + ] +} diff --git a/terraform/platform/README.md b/terraform/platform/README.md new file mode 100644 index 0000000..197d3b9 --- /dev/null +++ b/terraform/platform/README.md @@ -0,0 +1,20 @@ +# Cluster platform + +This stack installs Argo Rollouts, Sealed Secrets, and kube-prometheus-stack on +an existing k3s cluster. Use it on a fresh cluster, or import matching Helm +releases before applying it to a cluster where they already exist. + +```bash +cp terraform.tfvars.example terraform.tfvars +terraform init +terraform apply +``` + +The kubeconfig stays outside the repository. Terraform records only its path. +Application and observability manifests remain under `deploy/` and are applied +after the cluster-wide controllers are ready. + +Discord routing is deliberately off by default so a missing webhook cannot +stop Alertmanager. After applying `nereus-discord-sealed.yaml` and confirming +that it produced the `nereus-discord` Secret, set `enable_discord = true` and +apply this stack again. diff --git a/terraform/platform/alertmanager-discord.values.yaml b/terraform/platform/alertmanager-discord.values.yaml new file mode 100644 index 0000000..afb7fb8 --- /dev/null +++ b/terraform/platform/alertmanager-discord.values.yaml @@ -0,0 +1,23 @@ +alertmanager: + alertmanagerSpec: + secrets: + - nereus-discord + config: + global: + resolve_timeout: 5m + route: + receiver: discard + group_by: [alertname, namespace] + group_wait: 30s + group_interval: 5m + repeat_interval: 4h + routes: + - receiver: discord + matchers: + - severity=~"warning|critical" + receivers: + - name: discard + - name: discord + discord_configs: + - webhook_url_file: /etc/alertmanager/secrets/nereus-discord/webhook-url + send_resolved: true diff --git a/terraform/platform/kube-prometheus-stack.values.yaml b/terraform/platform/kube-prometheus-stack.values.yaml new file mode 100644 index 0000000..f40ba11 --- /dev/null +++ b/terraform/platform/kube-prometheus-stack.values.yaml @@ -0,0 +1,27 @@ +kubeEtcd: + enabled: false +kubeProxy: + enabled: false + +prometheus: + prometheusSpec: + retention: 6h + serviceMonitorSelectorNilUsesHelmValues: false + podMonitorSelectorNilUsesHelmValues: false + ruleSelectorNilUsesHelmValues: false + storageSpec: + volumeClaimTemplate: + spec: + accessModes: [ReadWriteOnce] + resources: + requests: + storage: 5Gi + +grafana: + defaultDashboardsTimezone: browser + +alertmanager: + alertmanagerSpec: + resources: + requests: {cpu: 25m, memory: 64Mi} + limits: {memory: 256Mi} diff --git a/terraform/platform/main.tf b/terraform/platform/main.tf new file mode 100644 index 0000000..fb44307 --- /dev/null +++ b/terraform/platform/main.tf @@ -0,0 +1,44 @@ +resource "helm_release" "argo_rollouts" { + name = "argo-rollouts" + repository = "https://argoproj.github.io/argo-helm" + chart = "argo-rollouts" + version = "2.41.1" + namespace = "argo-rollouts" + create_namespace = true + wait = true + timeout = 300 +} + +resource "helm_release" "sealed_secrets" { + name = "sealed-secrets" + repository = "https://bitnami-labs.github.io/sealed-secrets" + chart = "sealed-secrets" + version = "2.19.3" + namespace = "kube-system" + create_namespace = false + wait = true + timeout = 300 + + values = [yamlencode({ + fullnameOverride = "sealed-secrets-controller" + resources = { + requests = { cpu = "25m", memory = "64Mi" } + limits = { memory = "256Mi" } + } + })] +} + +resource "helm_release" "kube_prometheus_stack" { + name = "kube-prometheus-stack" + repository = "https://prometheus-community.github.io/helm-charts" + chart = "kube-prometheus-stack" + version = "88.5.2" + namespace = "observability" + create_namespace = true + wait = true + timeout = 600 + values = concat( + [file("${path.module}/kube-prometheus-stack.values.yaml")], + var.enable_discord ? [file("${path.module}/alertmanager-discord.values.yaml")] : [] + ) +} diff --git a/terraform/platform/terraform.tfvars.example b/terraform/platform/terraform.tfvars.example new file mode 100644 index 0000000..846eb81 --- /dev/null +++ b/terraform/platform/terraform.tfvars.example @@ -0,0 +1,2 @@ +kubeconfig_path = "/absolute/path/to/kubeconfig" +enable_discord = false diff --git a/terraform/platform/variables.tf b/terraform/platform/variables.tf new file mode 100644 index 0000000..84604b7 --- /dev/null +++ b/terraform/platform/variables.tf @@ -0,0 +1,10 @@ +variable "kubeconfig_path" { + description = "Absolute path to the operator kubeconfig. Its contents never enter Terraform state." + type = string +} + +variable "enable_discord" { + description = "Enable Discord routing after the nereus-discord SealedSecret has created its Secret." + type = bool + default = false +} diff --git a/terraform/platform/versions.tf b/terraform/platform/versions.tf new file mode 100644 index 0000000..19c3b45 --- /dev/null +++ b/terraform/platform/versions.tf @@ -0,0 +1,16 @@ +terraform { + required_version = ">= 1.10.0" + + required_providers { + helm = { + source = "hashicorp/helm" + version = "3.1.1" + } + } +} + +provider "helm" { + kubernetes = { + config_path = var.kubeconfig_path + } +}