Makes tofu apply safe to run again. Two independent defects, both found while trying to create the renner-uno-k8s-mariadb bucket that WordPress backups need.
1. The rustfs shim planned as a replacement
lxc_800_rustfs_decommission.tf states its own gate: "plan MUST show in-place update only, no replace". It did not — plan 5656 showed all three containers as must be replaced, which would destroy and recreate LXCs tagged critical, state-backend, terraform-backend.
Cause: v2 declared only ids, started, unprivileged and the timeouts, asserting "the other attributes mirror the real containers". They were not declared at all, so every absent attribute planned as a removal — and two of those removals force replacement:
Now declares the full attribute set, mirrored from the values the plan reported out of state, with hostname/mac_address per container rather than guessed. tags is declared too — it was planning to null, which would have stripped critical/state-backend off the live containers.
operating_system is deliberately not declared: the provider requires template_file_id inside it, which is create-time-only and absent from state, so it is ignore_changes'd rather than given an invented value that would only surface if something recreated these.
Verified — plan 5659: 0 to destroy, containers as in-place timeout updates, tags intact.
2. Sixty NAT rules diffed forever
Every plan showed all 60 restapi_object.hairpin rules as "will be updated in-place", the entire diff being - _id.
The provider was set write_returns_object = true, which stores the API response — _id included — into data after POST and PUT. The config's data never sets _id, so state permanently carried a field config did not, and it re-dirtied itself after every write.
No provider-side ignore can suppress this: data is a configured attribute, compared by Terraform core before provider logic runs. Three were tried and disproven against real plans:
attempt
plan
result
ignore_changes_to = ["_id"]
5661
60 remained
ignore_all_server_changes = true
5662
60 remained
restapi 3.x + ignore_server_additions
5667
60 remained, plus a new api_data diff
The 3.x bump was reverted — a major provider upgrade that fails its purpose and adds churn is worse than not doing it.
create_returns_object is the fix: it keeps what the provider genuinely needs — the POST response is how it learns _id for id_attribute — without letting updates rewrite data.
The 400 warning was wrong
providers.tf warned that a UniFi PUT "may require _id in the body, which restapi does not send — if an in-place update 400s, taint the object so CI recreates it". That comment has been deterring applies on a hazard that does not exist here.
Tested directly against a live rule, sending exactly what tofu sends:
PUT /proxy/network/v2/api/site/default/nat/6a61c7d7bbd7b2ca90981f51 (body without _id)
-> HTTP 200, object returned intact
check
result
rule diffed before/after
byte-identical
NAT rule count
60 → 60
What the apply will do
Plan is 3 to add, 63 to change, 0 to destroy:
creates renner-uno-k8s-mariadb + versioning + lifecycle — unblocks the WordPress backup, which has failed nightly for three days with The specified bucket does not exist
60 semantically no-op PUTs that rewrite data in state, proven safe above — and with create_returns_object they now stick instead of being undone by the next write
3 rustfs containers get their timeouts in-place
No targeting, no state surgery, no split state.
Caveats
The hairpin file notes rules take 60–90s to reach the dataplane after a write, so post-apply verification should not be immediate.
One rule of 60 was tested. The other 59 are structurally identical — same endpoint, same shape, same 15 fields — but that is an inference, not 60 measurements.
Makes `tofu apply` safe to run again. Two independent defects, both found while trying to create the `renner-uno-k8s-mariadb` bucket that WordPress backups need.
## 1. The rustfs shim planned as a replacement
`lxc_800_rustfs_decommission.tf` states its own gate: *"plan MUST show in-place update only, no replace"*. It did not — plan **5656** showed all three containers as `must be replaced`, which would destroy and recreate LXCs tagged `critical`, `state-backend`, `terraform-backend`.
Cause: v2 declared only ids, `started`, `unprivileged` and the timeouts, asserting "the other attributes mirror the real containers". They were not declared at all, so every absent attribute planned as a removal — and two of those removals force replacement:
```
- mount_point { size = "50G", volume = ... } # forces replacement
- operating_system { type = "debian" } # forces replacement
```
Now declares the full attribute set, mirrored from the values the plan reported out of state, with `hostname`/`mac_address` per container rather than guessed. `tags` is declared too — it was planning to `null`, which would have stripped `critical`/`state-backend` off the live containers.
`operating_system` is deliberately **not** declared: the provider requires `template_file_id` inside it, which is create-time-only and absent from state, so it is `ignore_changes`'d rather than given an invented value that would only surface if something recreated these.
**Verified — plan 5659: `0 to destroy`**, containers as in-place timeout updates, tags intact.
## 2. Sixty NAT rules diffed forever
Every plan showed all 60 `restapi_object.hairpin` rules as "will be updated in-place", the entire diff being `- _id`.
The provider was set `write_returns_object = true`, which stores the API response — `_id` included — into `data` after POST **and** PUT. The config's `data` never sets `_id`, so state permanently carried a field config did not, and it re-dirtied itself after every write.
No provider-side ignore can suppress this: `data` is a configured attribute, compared by Terraform core before provider logic runs. Three were tried and disproven against real plans:
| attempt | plan | result |
|---|---|---|
| `ignore_changes_to = ["_id"]` | 5661 | 60 remained |
| `ignore_all_server_changes = true` | 5662 | 60 remained |
| restapi 3.x + `ignore_server_additions` | 5667 | 60 remained, plus a new `api_data` diff |
The 3.x bump was reverted — a major provider upgrade that fails its purpose and adds churn is worse than not doing it.
`create_returns_object` is the fix: it keeps what the provider genuinely needs — the POST response is how it learns `_id` for `id_attribute` — without letting updates rewrite `data`.
## The 400 warning was wrong
`providers.tf` warned that a UniFi PUT *"may require `_id` in the body, which restapi does not send — if an in-place update 400s, taint the object so CI recreates it"*. That comment has been deterring applies on a hazard that does not exist here.
Tested directly against a live rule, sending exactly what tofu sends:
```
PUT /proxy/network/v2/api/site/default/nat/6a61c7d7bbd7b2ca90981f51 (body without _id)
-> HTTP 200, object returned intact
```
| check | result |
|---|---|
| rule diffed before/after | **byte-identical** |
| NAT rule count | **60 → 60** |
## What the apply will do
Plan is `3 to add, 63 to change, 0 to destroy`:
- creates `renner-uno-k8s-mariadb` + versioning + lifecycle — **unblocks the WordPress backup**, which has failed nightly for three days with `The specified bucket does not exist`
- 60 semantically no-op PUTs that rewrite `data` in state, proven safe above — and with `create_returns_object` they now **stick** instead of being undone by the next write
- 3 rustfs containers get their timeouts in-place
No targeting, no state surgery, no split state.
## Caveats
The hairpin file notes rules take **60–90s** to reach the dataplane after a write, so post-apply verification should not be immediate.
One rule of 60 was tested. The other 59 are structurally identical — same endpoint, same shape, same 15 fields — but that is an inference, not 60 measurements.
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>
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>
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>
🤖Automated review — model CLI default (complexity: complex), commit 49cc8801696c0fc12f669a19e6455e71a424455a.
Advisory review by the review-bot pipeline. It never approves; a human merges. Push new commits to get a fresh review.
Summary
Two independent OpenTofu fixes: (1) lxc_800_rustfs_decommission.tf now declares the full attribute set of the three rustfs LXCs (cpu/memory/disk/mount_point/network_interface/initialization/tags) so the plan stays in-place instead of forcing a replace of critical/state-backend containers; (2) providers.tf swaps the restapi provider's write_returns_object for create_returns_object so the 60 unifi_nat_hairpin rules stop re-dirtying themselves on every apply. Both changes are internally consistent with the surrounding code and with the detailed rationale in the PR body (which cites specific plan IDs as evidence). No blocking issues found.
🟡 Suggestions
opentofu/providers.tf:62-63 — the pre-existing comment "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" is left completely unchanged by this diff, even though the PR body's own "The 400 warning was wrong" section states this was tested live and found false (PUT without _id returned 200 with a byte-identical object). Since the PR's stated goal is to stop this comment from "deterring applies on a hazard that does not exist," it should actually be updated or removed here — as written, it still tells the next person touching this file to taint-and-recreate on a 400 that, per this PR's own testing, won't happen.
The correctness of both fixes hinges on details this review can't verify from the checked-out repo alone: whether create_returns_object is a real, supported argument on the pinned Mastercard/restapi ~> 2.0 provider schema, and whether the newly-declared disk/mount_point/cpu/memory/network_interface/initialization blocks in lxc_800_rustfs_decommission.tf exactly mirror the live containers' state (this repo doesn't vendor the provider source, and this sandbox has no network access to check the registry docs). The PR body says both were validated against real tofu plan runs (5659 for the LXC fix, 5661/5662/5667 for the NAT fix), which is meaningful evidence — but note the previous attempt at this same LXC shim (v2) also claimed "the other attributes mirror the real containers" and was wrong, so it's worth confirming the automatic plan step in .woodpecker/deploy-infrastructure.yml (triggered on push to master for opentofu/**) actually shows the claimed in-place-only diff on this exact commit before running TOFU_APPLY=true, rather than relying solely on the PR narrative.
Tests
There's no unit-test layer for these .tf files; correctness is established by tofu plan/apply, which .woodpecker/deploy-infrastructure.yml runs automatically (plan) on merge to master and only applies on a separate manual TOFU_APPLY=true trigger — so there's a real safety gate between merge and any destructive action. The PR body documents plan output for each iteration of both fixes (5656/5659 for the LXC shim, 5661/5662/5667 for the NAT provider setting), which is the appropriate form of evidence here. Recommend confirming the post-merge automatic plan output matches what's described (0 to destroy; the 60 hairpin rules as in-place updates) before triggering apply.
Recommendation
Looks reasonable — no blocking issues; comments above are optional (though the stale 400-taint comment is worth fixing while touching this file).
🤖 **Automated review** — model `CLI default` (complexity: complex), commit `49cc8801696c0fc12f669a19e6455e71a424455a`.
> Advisory review by the `review-bot` pipeline. It never approves; a human merges. Push new commits to get a fresh review.
---
## Summary
Two independent OpenTofu fixes: (1) `lxc_800_rustfs_decommission.tf` now declares the full attribute set of the three rustfs LXCs (cpu/memory/disk/mount_point/network_interface/initialization/tags) so the plan stays in-place instead of forcing a replace of `critical`/`state-backend` containers; (2) `providers.tf` swaps the `restapi` provider's `write_returns_object` for `create_returns_object` so the 60 `unifi_nat_hairpin` rules stop re-dirtying themselves on every apply. Both changes are internally consistent with the surrounding code and with the detailed rationale in the PR body (which cites specific plan IDs as evidence). No blocking issues found.
## 🟡 Suggestions
- `opentofu/providers.tf:62-63` — the pre-existing comment *"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"* is left completely unchanged by this diff, even though the PR body's own "The 400 warning was wrong" section states this was tested live and found false (PUT without `_id` returned `200` with a byte-identical object). Since the PR's stated goal is to stop this comment from "deterring applies on a hazard that does not exist," it should actually be updated or removed here — as written, it still tells the next person touching this file to taint-and-recreate on a 400 that, per this PR's own testing, won't happen.
- The correctness of both fixes hinges on details this review can't verify from the checked-out repo alone: whether `create_returns_object` is a real, supported argument on the pinned `Mastercard/restapi ~> 2.0` provider schema, and whether the newly-declared `disk`/`mount_point`/`cpu`/`memory`/`network_interface`/`initialization` blocks in `lxc_800_rustfs_decommission.tf` exactly mirror the live containers' state (this repo doesn't vendor the provider source, and this sandbox has no network access to check the registry docs). The PR body says both were validated against real `tofu plan` runs (5659 for the LXC fix, 5661/5662/5667 for the NAT fix), which is meaningful evidence — but note the previous attempt at this same LXC shim (v2) also claimed "the other attributes mirror the real containers" and was wrong, so it's worth confirming the automatic `plan` step in `.woodpecker/deploy-infrastructure.yml` (triggered on push to `master` for `opentofu/**`) actually shows the claimed in-place-only diff on this exact commit before running `TOFU_APPLY=true`, rather than relying solely on the PR narrative.
## Tests
There's no unit-test layer for these `.tf` files; correctness is established by `tofu plan`/`apply`, which `.woodpecker/deploy-infrastructure.yml` runs automatically (plan) on merge to `master` and only applies on a separate manual `TOFU_APPLY=true` trigger — so there's a real safety gate between merge and any destructive action. The PR body documents plan output for each iteration of both fixes (5656/5659 for the LXC shim, 5661/5662/5667 for the NAT provider setting), which is the appropriate form of evidence here. Recommend confirming the post-merge automatic plan output matches what's described (0 to destroy; the 60 hairpin rules as in-place updates) before triggering apply.
## Recommendation
Looks reasonable — no blocking issues; comments above are optional (though the stale 400-taint comment is worth fixing while touching this file).
<!-- pr-review:sha=49cc8801696c0fc12f669a19e6455e71a424455a -->
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Makes
tofu applysafe to run again. Two independent defects, both found while trying to create therenner-uno-k8s-mariadbbucket that WordPress backups need.1. The rustfs shim planned as a replacement
lxc_800_rustfs_decommission.tfstates its own gate: "plan MUST show in-place update only, no replace". It did not — plan 5656 showed all three containers asmust be replaced, which would destroy and recreate LXCs taggedcritical,state-backend,terraform-backend.Cause: v2 declared only ids,
started,unprivilegedand the timeouts, asserting "the other attributes mirror the real containers". They were not declared at all, so every absent attribute planned as a removal — and two of those removals force replacement:Now declares the full attribute set, mirrored from the values the plan reported out of state, with
hostname/mac_addressper container rather than guessed.tagsis declared too — it was planning tonull, which would have strippedcritical/state-backendoff the live containers.operating_systemis deliberately not declared: the provider requirestemplate_file_idinside it, which is create-time-only and absent from state, so it isignore_changes'd rather than given an invented value that would only surface if something recreated these.Verified — plan 5659:
0 to destroy, containers as in-place timeout updates, tags intact.2. Sixty NAT rules diffed forever
Every plan showed all 60
restapi_object.hairpinrules as "will be updated in-place", the entire diff being- _id.The provider was set
write_returns_object = true, which stores the API response —_idincluded — intodataafter POST and PUT. The config'sdatanever sets_id, so state permanently carried a field config did not, and it re-dirtied itself after every write.No provider-side ignore can suppress this:
datais a configured attribute, compared by Terraform core before provider logic runs. Three were tried and disproven against real plans:ignore_changes_to = ["_id"]ignore_all_server_changes = trueignore_server_additionsapi_datadiffThe 3.x bump was reverted — a major provider upgrade that fails its purpose and adds churn is worse than not doing it.
create_returns_objectis the fix: it keeps what the provider genuinely needs — the POST response is how it learns_idforid_attribute— without letting updates rewritedata.The 400 warning was wrong
providers.tfwarned that a UniFi PUT "may require_idin the body, which restapi does not send — if an in-place update 400s, taint the object so CI recreates it". That comment has been deterring applies on a hazard that does not exist here.Tested directly against a live rule, sending exactly what tofu sends:
What the apply will do
Plan is
3 to add, 63 to change, 0 to destroy:renner-uno-k8s-mariadb+ versioning + lifecycle — unblocks the WordPress backup, which has failed nightly for three days withThe specified bucket does not existdatain state, proven safe above — and withcreate_returns_objectthey now stick instead of being undone by the next writeNo targeting, no state surgery, no split state.
Caveats
The hairpin file notes rules take 60–90s to reach the dataplane after a write, so post-apply verification should not be immediate.
One rule of 60 was tested. The other 59 are structurally identical — same endpoint, same shape, same 15 fields — but that is an inference, not 60 measurements.
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>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>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>🤖 Automated review — model
CLI default(complexity: complex), commit49cc8801696c0fc12f669a19e6455e71a424455a.Summary
Two independent OpenTofu fixes: (1)
lxc_800_rustfs_decommission.tfnow declares the full attribute set of the three rustfs LXCs (cpu/memory/disk/mount_point/network_interface/initialization/tags) so the plan stays in-place instead of forcing a replace ofcritical/state-backendcontainers; (2)providers.tfswaps therestapiprovider'swrite_returns_objectforcreate_returns_objectso the 60unifi_nat_hairpinrules stop re-dirtying themselves on every apply. Both changes are internally consistent with the surrounding code and with the detailed rationale in the PR body (which cites specific plan IDs as evidence). No blocking issues found.🟡 Suggestions
opentofu/providers.tf:62-63— the pre-existing comment "UniFi PUT quirk: updates may require_idin the body, which restapi does not send — if an in-place update 400s, taint the object so CI recreates it" is left completely unchanged by this diff, even though the PR body's own "The 400 warning was wrong" section states this was tested live and found false (PUT without_idreturned200with a byte-identical object). Since the PR's stated goal is to stop this comment from "deterring applies on a hazard that does not exist," it should actually be updated or removed here — as written, it still tells the next person touching this file to taint-and-recreate on a 400 that, per this PR's own testing, won't happen.create_returns_objectis a real, supported argument on the pinnedMastercard/restapi ~> 2.0provider schema, and whether the newly-declareddisk/mount_point/cpu/memory/network_interface/initializationblocks inlxc_800_rustfs_decommission.tfexactly mirror the live containers' state (this repo doesn't vendor the provider source, and this sandbox has no network access to check the registry docs). The PR body says both were validated against realtofu planruns (5659 for the LXC fix, 5661/5662/5667 for the NAT fix), which is meaningful evidence — but note the previous attempt at this same LXC shim (v2) also claimed "the other attributes mirror the real containers" and was wrong, so it's worth confirming the automaticplanstep in.woodpecker/deploy-infrastructure.yml(triggered on push tomasterforopentofu/**) actually shows the claimed in-place-only diff on this exact commit before runningTOFU_APPLY=true, rather than relying solely on the PR narrative.Tests
There's no unit-test layer for these
.tffiles; correctness is established bytofu plan/apply, which.woodpecker/deploy-infrastructure.ymlruns automatically (plan) on merge tomasterand only applies on a separate manualTOFU_APPLY=truetrigger — so there's a real safety gate between merge and any destructive action. The PR body documents plan output for each iteration of both fixes (5656/5659 for the LXC shim, 5661/5662/5667 for the NAT provider setting), which is the appropriate form of evidence here. Recommend confirming the post-merge automatic plan output matches what's described (0 to destroy; the 60 hairpin rules as in-place updates) before triggering apply.Recommendation
Looks reasonable — no blocking issues; comments above are optional (though the stale 400-taint comment is worth fixing while touching this file).