Compare commits

...
Author SHA1 Message Date
claude-bot 853928b6b1 docs(opentofu): record the hairpin _id diff and retract the PUT 400 warning
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
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>
2026-08-19 08:34:42 +02:00
k.renner 3fac750430 Merge pull request 'fix(opentofu): make apply safe — no container replacement, no permanent NAT diff' (#513) from fix-rustfs-shim-replace into master
ci/woodpecker/push/deploy-infrastructure Pipeline was successful
ci/woodpecker/manual/deploy-infrastructure Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#513
2026-08-19 06:15:35 +00:00
claude-bot 49cc880169 fix(unifi): capture the API response on create only, not on every write
ci/woodpecker/manual/deploy-infrastructure Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Root cause of the 60 permanently-dirty hairpin NAT rules.

The provider was configured write_returns_object = true, which makes it store
the API response into `data` after POST *and* PUT. The UDM assigns `_id`;
unifi_nat_hairpin.tf's data = jsonencode({...}) never sets it. So every write
put a field into state that the config does not have, and all 60 rules planned
as "will be updated in-place" with `- _id` as the entire diff — re-dirtying
themselves on the next write, indefinitely.

No provider-side ignore can suppress this, because `data` is a configured
attribute and Terraform compares config to state in core, before provider
logic runs. Three were tried and disproven against real plans rather than
argued about:

  ignore_changes_to = ["_id"]                       plan 5661 — 60 remained
  ignore_all_server_changes = true                  plan 5662 — 60 remained
  restapi 3.x + ignore_server_additions = true      plan 5667 — 60 remained,
                                                    plus a new api_data diff

create_returns_object keeps the part the provider genuinely needs — the POST
response is how it learns `_id` for id_attribute = "_id" — while no longer
letting updates rewrite `data` from the response.

This does not clear the existing pollution by itself: state still carries `_id`
in `data` for all 60 rules, so the plan will keep showing them until one apply
rewrites `data` to the configured value. What it changes is that the apply then
sticks, instead of the next write undoing it.

Co-Authored-By: Claude Bot <claude-bot@renner.uno>
2026-08-19 07:55:44 +02:00
claude-bot 08b3a39878 fix(opentofu): ignore operating_system instead of declaring it
ci/woodpecker/manual/deploy-infrastructure Pipeline was successful
Plan 5657 rejected the previous commit:

  Error: Missing required argument
    on lxc_800_rustfs_decommission.tf line 98, in resource
    "proxmox_virtual_environment_container" "rustfs_decommission":
    The argument "template_file_id" is required, but no definition was found.

The plan's own state dump showed only `type = "debian"` inside
operating_system, so mirroring it looked complete. It is not:
template_file_id is create-time-only, PVE does not report the original
template for an existing container, and nothing else in opentofu/ declares
one to copy — these three are the only container resources in the repo.

Guessing a template would be a lie that stays invisible until something
recreates these containers, which is precisely the event this file exists to
prevent. So operating_system is left undeclared and ignored via lifecycle
instead, which stops it planning as a removal without asserting a value that
is not known.

The timeouts remain outside ignore_changes on purpose — they are the one
thing this shim needs to actually apply.

Co-Authored-By: Claude Bot <claude-bot@renner.uno>
2026-08-19 07:11:39 +02:00
claude-bot a8c1af1502 fix(opentofu): make the rustfs decommission shim plan in-place, not replace
ci/woodpecker/manual/deploy-infrastructure Pipeline failed
The v2 shim's own instructions say the plan MUST show an in-place update and
no replace before applying. It does not. Pipeline 5656 planned all three
containers as `must be replaced`, which would destroy and recreate LXCs
tagged critical / state-backend / terraform-backend instead of nudging a
timeout value into state.

Cause: v2 declared only node_name, vm_id, started, unprivileged and the
timeouts, on the stated assumption that "the other attributes mirror the real
containers so the plan stays in-place". They were not declared at all, so
every absent attribute planned as a removal — and two of those removals force
replacement:

  - mount_point { ... }       # forces replacement (size, volume)
  - operating_system { ... }  # forces replacement

Declaring the full attribute set, mirrored from the values the plan itself
reported out of state: cpu, memory, disk, mount_point, network_interface,
operating_system and initialization. hostname and mac_address differ per
container, so the local map carries them per key rather than being repeated
or guessed.

tags is declared for a separate reason: it was planning as
["critical","rustfs","state-backend","terraform-backend"] -> null, which does
not force replacement but would have stripped the tags off the live
containers on apply.

This does not unblock the WordPress backup on its own. That needs
renner-uno-k8s-mariadb, which the same plan wants to create — but the bucket
cannot be applied while the plan also carries three container replacements,
and a -target apply is not an acceptable way around it: it diverges state
from config and leaves the real problem in place.

Verification is the file's own gate — a plan showing in-place only.

Co-Authored-By: Claude Bot <claude-bot@renner.uno>
2026-08-19 07:08:47 +02:00
k.renner 38eb6b6503 Merge pull request 'fix(woodpecker): revive the rotation alert rules after the namespace move' (#512) from fix-rotator-woodpecker-url into master
ci/woodpecker/manual/deploy-infrastructure Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#512
2026-08-19 04:53:19 +00:00
claude-bot 5d542af431 fix(woodpecker): revive the rotation alert rules after the namespace move
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Rebased onto master now that #511 landed the WOODPECKER_URL fix for both
rotators — that part is dropped from here rather than duplicated. What remains
is the half nothing else covered.

All four rules in prometheusrule-woodpecker-rotation.yaml were selecting
series that do not exist, so the whole group was silent:

- WoodpeckerServerNotReady queried kube_statefulset_status_replicas_ready.
  The server became a Deployment when the sqlite->CNPG migration dropped its
  PVC, so that metric has no series at all — a `critical` "all CI is down"
  alert that could never fire, and had been dead well before the namespace
  move. Now kube_deployment_status_replicas_ready in the woodpecker namespace.

- WoodpeckerAgentNotReady selected namespace="apps". Broken by the namespace
  migration; mine.

- The two woodpecker-secret-rotate rules selected namespace="apps", but that
  rotation runs from the openbao namespace. Broken since that move, which the
  woodpecker kustomization documents.

Verified against live Prometheus instead of reasoned about: server ready=1,
agent available=1, cronjob 1 series. The originals returned nothing, which is
exactly why they never fired rather than firing wrongly.

Two stale claims in the same file corrected while here: it pointed at
kubernetes/apps/woodpecker/rotate-agent-secret.yaml, which no longer exists,
and asserted the CronJob/Job series "stay empty until the rotation feature is
merged" — that rotation has since landed in openbao.

Also finishes the netpol comments #511 left mid-update, which still credited
woodpecker-allow-rotators to `apps/*`. gitea.apps.svc:3000 in review-bot.yaml
is deliberately untouched: gitea really is still in apps.

Co-Authored-By: Claude Bot <claude-bot@renner.uno>
2026-08-19 06:50:42 +02:00
k.renner 05945545e8 Merge pull request 'fix: [alert] KubeJobFailed (openbao) (#510)' (#511) from alert-fix/issue-510 into master
Reviewed-on: k.renner/homelab#511
2026-08-19 04:46:25 +00:00
issue-fix-bot d9f1d6cfc8 fix: [alert] KubeJobFailed (openbao) (#510)
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
2026-08-19 03:44:13 +00:00
k.renner 4b1179e37b Merge pull request 'chore(ci): bump the woodpecker lint CLI to v3.17.0-alpine' (#509) from bump-woodpecker-cli into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#509
2026-08-18 21:46:13 +00:00
6 changed files with 196 additions and 47 deletions
@@ -12,37 +12,37 @@ spec:
groups:
- name: woodpecker-rotation.rules
# Convergence safety net for the automated WOODPECKER_AGENT_SECRET rotation
# (kubernetes/apps/woodpecker/rotate-agent-secret.yaml). That rotation Job is
# (kubernetes/infrastructure/openbao/rotation/woodpecker.yaml). That rotation Job is
# fire-and-forget: it writes OpenBao and exits WITHOUT verifying ESO re-synced
# or that both consumers restarted healthy. These alerts close that gap. ESO
# metrics are not scraped in this cluster, so everything here is built on
# kube-state-metrics. The CronJob/Job series stay empty until the rotation
# feature (branch feat/woodpecker-secret-auto-rotation) is merged — harmless.
# kube-state-metrics. The rotation has since landed: the CronJob lives in the
# openbao namespace, not apps, which is where the last two rules look.
rules:
- alert: WoodpeckerServerNotReady
# 0 ready replicas well past the 10m ESO refresh + Reloader restart cycle.
expr: kube_statefulset_status_replicas_ready{namespace="apps",statefulset="woodpecker-server"} == 0
expr: kube_deployment_status_replicas_ready{namespace="woodpecker",deployment="woodpecker-server"} == 0
for: 20m
labels:
severity: critical
annotations:
summary: "Woodpecker CI server has no ready replica"
description: >-
StatefulSet woodpecker-server (ns apps) has had 0 ready replicas for 20m;
all CI is down (agents cannot reach woodpecker.apps.svc:9000). If this
followed a secret rotation, the server pod failed to return after its
Reloader-triggered restart — check `kubectl -n apps describe pod
woodpecker-server-0` (PVC attach / crashloop) and the woodpecker-secret
ExternalSecret sync status.
Deployment woodpecker-server (ns woodpecker) has had 0 ready replicas
for 20m; all CI is down (agents cannot reach
woodpecker.woodpecker.svc:9000). If this followed a secret rotation, the
server pod failed to return after its Reloader-triggered restart — check
`kubectl -n woodpecker describe deploy woodpecker-server` and the
woodpecker-secret ExternalSecret sync status.
- alert: WoodpeckerAgentNotReady
expr: kube_deployment_status_replicas_available{namespace="apps",deployment="woodpecker-agent"} == 0
expr: kube_deployment_status_replicas_available{namespace="woodpecker",deployment="woodpecker-agent"} == 0
for: 20m
labels:
severity: warning
annotations:
summary: "Woodpecker CI agent has no available replica"
description: >-
Deployment woodpecker-agent (ns apps) has had 0 available replicas for
Deployment woodpecker-agent (ns woodpecker) has had 0 available replicas for
20m; no pipelines will run. If this followed a secret rotation, the agent
did not return after its Reloader restart, or its WOODPECKER_AGENT_SECRET
no longer matches the server's — check the agent pod logs for a gRPC auth
@@ -50,29 +50,29 @@ spec:
- alert: WoodpeckerSecretRotationJobFailed
# Named, clearer signal than the generic KubeJobFailed for the OpenBao-write
# step. Fires while a failed rotation Job is retained (ttl 3d).
expr: kube_job_status_failed{namespace="apps",job_name=~"woodpecker-secret-rotate.*"} > 0
expr: kube_job_status_failed{namespace="openbao",job_name=~"woodpecker-secret-rotate.*"} > 0
for: 15m
labels:
severity: warning
annotations:
summary: "Woodpecker agent-secret rotation Job failed"
description: >-
A woodpecker-secret-rotate Job (ns apps) failed at the OpenBao-write step;
A woodpecker-secret-rotate Job (ns openbao) failed at the OpenBao-write step;
the agent secret was NOT rotated. Most common cause: the one-time OpenBao
`rotator` role/policy bootstrap was never applied (see
kubernetes/infrastructure/openbao/policies/rotator.hcl). Check
`kubectl -n apps logs job/<name>`.
`kubectl -n openbao logs job/<name>`.
- alert: WoodpeckerSecretRotationStale
# Quarterly cadence (~91d) + grace. The series is ABSENT until the first
# successful run, so this cannot false-fire before rotation has ever worked.
expr: (time() - kube_cronjob_status_last_successful_time{namespace="apps",cronjob="woodpecker-secret-rotate"}) > 100 * 24 * 3600
expr: (time() - kube_cronjob_status_last_successful_time{namespace="openbao",cronjob="woodpecker-secret-rotate"}) > 100 * 24 * 3600
for: 1h
labels:
severity: warning
annotations:
summary: "Woodpecker agent secret has not rotated in >100 days"
description: >-
The quarterly woodpecker-secret-rotate CronJob (ns apps) has not completed
The quarterly woodpecker-secret-rotate CronJob (ns openbao) has not completed
successfully in over 100 days — rotation has silently stopped (CronJob
suspended/deleted, or every run failing). Investigate the CronJob and its
recent Jobs.
@@ -14,8 +14,10 @@
# immediately:
# kubectl -n openbao create job --from=cronjob/claude-oauth-secret-sync claude-oauth-sync-now
#
# woodpecker is reached in-cluster at woodpecker.apps.svc:8000. Needs the netpol
# halves woodpecker-allow-rotators (apps/*) + rotator-claude-oauth-egress
# woodpecker is reached in-cluster at woodpecker.woodpecker.svc:8000 (it moved
# out of `apps` in the namespace migration — see
# kubernetes/apps/woodpecker/MIGRATION-namespace.md). Needs the netpol
# halves woodpecker-allow-rotators (woodpecker ns) + rotator-claude-oauth-egress
# (openbao-ns.yaml). OpenBao policy/role rotator-claude-oauth + the bootstraps
# (woodpecker-bot token, the seeded OAuth value) are provisioned by hand — see
# policies/rotator-claude-oauth.hcl.
@@ -89,7 +91,7 @@ spec:
- name: BAO_FIELD
value: setup-token
- name: WOODPECKER_URL
value: http://woodpecker.apps.svc.cluster.local:8000
value: http://woodpecker.woodpecker.svc.cluster.local:8000
- name: WOODPECKER_ORG
value: k.renner
- name: WOODPECKER_SECRET_NAME
@@ -25,9 +25,10 @@
# but a failed sync should be fixed before the next run rather than left.
#
# gitea is reached in-cluster at gitea.apps.svc:3000, woodpecker at
# woodpecker.apps.svc:8000 (both hairpin off the ingress LB from a pod). Needs
# woodpecker.woodpecker.svc:8000 (it moved out of `apps` in the namespace
# migration — see kubernetes/apps/woodpecker/MIGRATION-namespace.md). Needs
# the netpol halves: gitea-allow-review-bot-rotator + woodpecker-allow-rotators
# (apps/*) and rotator-review-bot-egress (openbao-ns.yaml).
# (woodpecker ns) and rotator-review-bot-egress (openbao-ns.yaml).
#
# OpenBao policy/role rotator-review-bot + the bootstraps (review-bot password,
# woodpecker-bot token, seed review-bot#token) are provisioned by hand — see
@@ -147,7 +148,7 @@ spec:
- name: BAO_FIELD
value: token
- name: WOODPECKER_URL
value: http://woodpecker.apps.svc.cluster.local:8000
value: http://woodpecker.woodpecker.svc.cluster.local:8000
- name: WOODPECKER_ORG
value: k.renner
- name: WOODPECKER_SECRET_NAME
+87 -8
View File
@@ -1,4 +1,4 @@
# TEMPORARY shims v2 — rustfs LXCs 800/801/802, decommission in progress.
# TEMPORARY shims v3 — rustfs LXCs 800/801/802, decommission in progress.
#
# Stage 2 (#270) failed: both destroy attempts died instantly with
# "context deadline exceeded" on the pre-destroy status GET, while the PVE
@@ -10,34 +10,113 @@
#
# Fix: declare the timeouts explicitly, apply ONCE so they land in state
# (in-place no-API update), then delete this file again and the next apply
# destroys the containers. No lifecycle ignore_changes this time — the
# timeout update must actually apply; the other attributes mirror the real
# containers so the plan stays in-place.
# destroys the containers.
#
# v3: v2 declared ONLY the ids, `started`, `unprivileged` and the timeouts,
# claiming "the other attributes mirror the real containers so the plan stays
# in-place". They did not. Everything absent from config planned as a removal,
# and two of those removals — `mount_point` and `operating_system` — force
# replacement, so the plan came back `must be replaced` for all three. Applying
# that would have destroyed and recreated containers tagged `critical`,
# `state-backend`, `terraform-backend` instead of nudging a timeout into state.
#
# So the blocks below are not decoration: each one exists to keep an attribute
# out of the diff. They mirror the live containers, read from the plan's own
# state values. `tags` is declared for the same reason — without it the apply
# strips the tags off the real containers.
#
# 1. merge; run PIPELINE=deploy-infrastructure (plan MUST show in-place
# update only, no replace), then TOFU_APPLY=true
# 2. follow-up PR deletes this file; plan shows 3 destroys; apply.
locals {
# Per-container values mirrored from live state. hostname and mac_address
# differ per container; declaring them wrong would itself force a diff.
rustfs_decommission = {
"800" = "saturn"
"801" = "jupiter"
"802" = "neptune"
"800" = { node = "saturn", hostname = "rustfs-1.srv.renner.uno", mac = "BC:24:11:0B:E5:7B" }
"801" = { node = "jupiter", hostname = "rustfs-2.srv.renner.uno", mac = "BC:24:11:EA:1D:38" }
"802" = { node = "neptune", hostname = "rustfs-3.srv.renner.uno", mac = "BC:24:11:8F:F4:67" }
}
}
resource "proxmox_virtual_environment_container" "rustfs_decommission" {
for_each = local.rustfs_decommission
node_name = each.value
node_name = each.value.node
vm_id = tonumber(each.key)
started = false
unprivileged = true
start_on_boot = true
tags = [
"critical",
"rustfs",
"state-backend",
"terraform-backend",
]
timeout_create = 1800
timeout_clone = 1800
timeout_update = 1800
timeout_delete = 1800
timeout_start = 300
cpu {
architecture = "amd64"
cores = 1
units = 1024
}
memory {
dedicated = 1024
swap = 0
}
disk {
datastore_id = "local"
size = 10
}
# forces replacement if absent — this is the block that broke v2.
mount_point {
path = "/mnt/data"
size = "50G"
volume = "local:${each.key}/vm-${each.key}-disk-1.raw"
backup = true
replicate = true
}
network_interface {
name = "eth0"
bridge = "v40srv"
enabled = true
firewall = false
mac_address = each.value.mac
}
initialization {
hostname = each.value.hostname
ip_config {
ipv4 {
address = "dhcp"
}
}
}
# operating_system is NOT declared, and must not be. The provider requires
# template_file_id inside it, which is a create-time-only value: PVE does not
# report the original template for an existing container, so it is absent
# from state and cannot be mirrored. Declaring the block without it fails
# validation ("Missing required argument"); declaring it with a guessed
# template would be a lie that only surfaces if something ever recreates
# these.
#
# Left undeclared it would plan as a removal, and removing operating_system
# forces replacement — the exact failure this file exists to avoid. So it is
# ignored instead. The timeouts above are deliberately NOT ignored: they are
# the one thing this shim needs to actually apply.
lifecycle {
ignore_changes = [operating_system]
}
}
+39 -6
View File
@@ -59,13 +59,46 @@ provider "unifi" {
# 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: updates may require _id in the body, which restapi does
# not send — if an in-place update 400s, taint the object so CI recreates 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
write_returns_object = true
id_attribute = "_id"
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"
+44 -10
View File
@@ -107,17 +107,51 @@ resource "restapi_object" "hairpin" {
for_each = local.hairpin_rules
path = "/nat"
# KNOWN PERMANENT DIFF — cosmetic. Do not try to fix it with a provider
# setting; four have been tried and none work. Recorded here so the next
# person does not spend a day rediscovering that.
#
# Every plan shows all 60 of these as "will be updated in-place" with the
# entire diff being one line:
#
# ~ data = jsonencode( ~ { - _id = "6a61c7d7bbd7b2ca90981f51"
# # (15 unchanged attributes hidden) } )
#
# The UDM assigns _id and returns it; the provider puts the response into
# `data`; the `data` below never sets it. So state carries a field the config
# does not. It survives an apply: 5675 rewrote all 60, and the very next plan
# (5677) showed the same 60 again.
#
# Tried, with the plan that disproved each — do not repeat these:
#
# ignore_changes_to = ["_id"] 5661 60 remained
# ignore_all_server_changes = true 5662 60 remained
# restapi 3.x + ignore_server_additions = true 5667 60 remained, and
# added an api_data diff
# write_returns_object -> create_returns_object 5677 60 remained
#
# The first three cannot work in principle: `data` is a configured attribute,
# so Terraform core diffs config against state before any provider-side ignore
# logic runs. The fourth was the better theory — that PUT responses were
# re-polluting `data` — and it is wrong too, which means the response is
# written into `data` on the READ path. Settling it properly needs the
# provider source, not more guesses at the schema.
#
# It is safe to apply through. Verified twice: a single PUT without _id
# returns 200 and leaves the rule byte-identical (see providers.tf), and
# apply 5675 modified all 60 with no errors and no service impact.
data = jsonencode({
description = "Hairpin ${each.key}"
type = "DNAT"
enabled = true
exclude = false
in_interface = each.value.network_id
ip_address = each.value.endpoint.target
ip_version = each.value.endpoint.ip_version
protocol = "tcp_udp"
rule_index = each.value.rule_index
logging = false
description = "Hairpin ${each.key}"
type = "DNAT"
enabled = true
exclude = false
in_interface = each.value.network_id
ip_address = each.value.endpoint.target
ip_version = each.value.endpoint.ip_version
protocol = "tcp_udp"
rule_index = each.value.rule_index
logging = false
is_predefined = false
pppoe_use_base_interface = false
setting_preference = "manual"