Both of these were learned the expensive way today and existed only in commit
messages and PR descriptions, which is not where anyone looks.
providers.tf carried a warning that a UniFi PUT may require _id in the body,
which restapi does not send, and that a 400 should be answered by tainting the
object so CI recreates it. That is wrong on this controller. Tested against a
live rule with exactly the body tofu sends: HTTP 200, object returned intact,
rule byte-identical before and after, 60 rules before and after. Confirmed at
scale by apply 5675 — all 60 modified, no 400s, ci.renner.uno and
immich.renner.uno still 200.
That wrong warning had a cost. A note saying an apply might break firewall
rules is one nobody argues with, so the apply did not get run, and the
renner-uno-k8s-mariadb bucket added in b976a67b sat uncreated for three days
while the WordPress backup failed nightly with "The specified bucket does not
exist". The warning is replaced with the evidence that disproves it.
unifi_nat_hairpin.tf now records that the 60-rule `- _id` diff is permanent and
cosmetic, and lists the four attempts to remove it with the plan number that
disproved each: ignore_changes_to (5661), ignore_all_server_changes (5662),
restapi 3.x ignore_server_additions (5667), and create_returns_object (5677).
The first three cannot work in principle — `data` is a configured attribute, so
Terraform core diffs it against state before provider-side ignore logic runs.
The fourth was the better theory, that PUT responses re-polluted `data`, and it
is wrong too: the diff returned on the plan immediately after the apply. That
means the response is written into `data` on the read path, and settling it
needs the provider source rather than more guesses at its schema.
No behaviour change. Documentation only, so the next person inherits the
findings instead of the dead ends.
Co-Authored-By: Claude Bot <claude-bot@renner.uno>
107 lines
4.3 KiB
Terraform
107 lines
4.3 KiB
Terraform
terraform {
|
|
backend "s3" {
|
|
# Migrated off the nas-1 Garage S3 to Hetzner Object Storage (offsite,
|
|
# nas-1-independent). State copied via the migrate-tfstate pipeline.
|
|
bucket = "renner-uno-tfstate"
|
|
key = "terraform.tfstate"
|
|
region = "fsn1"
|
|
endpoint = "https://fsn1.your-objectstorage.com"
|
|
skip_credentials_validation = true
|
|
skip_requesting_account_id = true
|
|
skip_metadata_api_check = true
|
|
skip_region_validation = true
|
|
use_path_style = false
|
|
}
|
|
required_providers {
|
|
proxmox = {
|
|
source = "bpg/proxmox"
|
|
version = "~> 0.95"
|
|
}
|
|
unifi = {
|
|
source = "ubiquiti-community/unifi"
|
|
# Pinned to 0.49.x — the last line that accepts an integer
|
|
# dns_record `ttl`. 0.50.0 switched `ttl` to a Go duration string,
|
|
# which breaks every plan against the existing integer TTLs
|
|
# (ttl = 300, 60). Bumping past 0.49 requires rewriting those TTLs
|
|
# to duration strings ("300s", "60s") first.
|
|
version = "~> 0.49.0"
|
|
}
|
|
minio = {
|
|
source = "aminueza/minio"
|
|
version = "~> 3.33"
|
|
}
|
|
# Generic REST client for UniFi v2 API objects (NAT rules) that no
|
|
# UniFi provider models yet — see opentofu/unifi_nat_hairpin.tf.
|
|
restapi = {
|
|
source = "Mastercard/restapi"
|
|
version = "~> 2.0"
|
|
}
|
|
}
|
|
}
|
|
|
|
provider "proxmox" {
|
|
endpoint = var.proxmox_api_url
|
|
api_token = "${var.proxmox_user}!${var.proxmox_token_id}=${var.proxmox_token_secret}"
|
|
insecure = var.proxmox_tls_insecure
|
|
|
|
ssh {
|
|
agent = false
|
|
username = "root"
|
|
private_key = var.ssh_private_key
|
|
}
|
|
}
|
|
|
|
provider "unifi" {
|
|
api_key = var.unifi_api_key
|
|
api_url = var.unifi_api_url
|
|
allow_insecure = var.unifi_insecure
|
|
}
|
|
|
|
# UniFi Network v2 API (policy-table NAT rules). Same controller and API key
|
|
# as the unifi provider above; the v2 endpoints are simply not covered by it.
|
|
# UniFi PUT quirk — DISPROVEN 2026-08-19, left here because it was wrong for
|
|
# long enough to do damage. The claim was that a UniFi PUT may require _id in
|
|
# the body, which restapi does not send, and that a 400 should be answered by
|
|
# tainting the object so CI recreates it. Tested directly against a live rule
|
|
# with exactly the body tofu sends:
|
|
#
|
|
# PUT /proxy/network/v2/api/site/default/nat/6a61c7d7bbd7b2ca90981f51
|
|
# body: the object, _id removed -> HTTP 200, object returned intact
|
|
# rule re-read and diffed before/after -> byte-identical
|
|
# rule count -> 60 before, 60 after
|
|
#
|
|
# Confirmed again at scale by apply 5675: all 60 rules "Modifications complete
|
|
# after 0s", no 400s, ci.renner.uno and immich.renner.uno both still 200.
|
|
#
|
|
# This mattered. A warning that applying might break firewall rules is a
|
|
# warning nobody argues with, so the apply did not get run — and the
|
|
# renner-uno-k8s-mariadb bucket added in b976a67b sat uncreated for three days
|
|
# while the WordPress backup failed nightly with "The specified bucket does not
|
|
# exist". Applying these NAT rules is safe. Do not taint them.
|
|
#
|
|
# create_returns_object, NOT write_returns_object. Both make the provider store
|
|
# the API response into `data`; write_returns_object does it for PUT as well as
|
|
# POST. Since `data` in unifi_nat_hairpin.tf never sets the UDM-assigned `_id`,
|
|
# every write put a field into state that config does not have, so all 60 rules
|
|
# planned as "will be updated in-place" with `- _id` as the whole diff — and
|
|
# re-dirtied themselves on the next write, forever.
|
|
#
|
|
# No provider-side ignore can suppress that: `data` is a configured attribute,
|
|
# compared by Terraform core before provider logic runs. Tried and disproven —
|
|
# ignore_changes_to = ["_id"] (plan 5661), ignore_all_server_changes (5662),
|
|
# and ignore_server_additions on restapi 3.x (5667). All three left the 60.
|
|
#
|
|
# create_returns_object keeps what the provider actually needs — the POST
|
|
# response is how it learns `_id` for id_attribute below — without letting
|
|
# updates rewrite `data`.
|
|
provider "restapi" {
|
|
uri = "${var.unifi_api_url}/proxy/network/v2/api/site/default"
|
|
insecure = var.unifi_insecure
|
|
create_returns_object = true
|
|
id_attribute = "_id"
|
|
headers = {
|
|
"X-API-KEY" = var.unifi_api_key
|
|
"Content-Type" = "application/json"
|
|
}
|
|
}
|