Compare commits

...
Author SHA1 Message Date
claude-bot fd35f5f21b feat(opentofu): destroy the rustfs LXCs 800/801/802
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
Stage 2 of the decommission the shim was written for. Deleting the file makes
the next apply destroy the three containers.

The containers were taken out of service months ago — they are stopped
(pve_up = 0 for all three) and rustfs-{1,2,3}.srv.renner.uno no longer resolve
— but the LXC objects and their disks still exist on saturn/jupiter/neptune.
Two earlier destroy attempts (#270) died instantly with "context deadline
exceeded", because the bpg provider reads timeout_delete from STATE and the
imported resources had stored 0.

That is now fixed: apply 5675 landed timeout_delete = 1800 and the rest of the
timeouts in state, in place, with 0 destroyed. The destroy has a real deadline
to work with for the first time.

Checked before removing, since the containers are tagged `state-backend` and
`terraform-backend`:

  - The tofu backend is Hetzner S3 (renner-uno-tfstate at
    fsn1.your-objectstorage.com), not rustfs. Destroying these cannot pull the
    state out from under the apply. The tags are historical, from before that
    migration.
  - No Kubernetes or Ansible resource references rustfs.
  - scripts/restore-authentik-db.sh mentions rustfs only as an `mc` alias name;
    its endpoint is s3.renner.uno:9000, the Ceph RGW `migration` store.
  - DNS for the three names is already gone.

Co-Authored-By: Claude Bot <claude-bot@renner.uno>
2026-08-19 08:51:38 +02:00
k.renner d208c17509 Merge pull request 'docs(opentofu): record the hairpin _id diff, retract the PUT 400 warning' (#514) from document-hairpin-id-diff into master
ci/woodpecker/push/deploy-infrastructure Pipeline was successful
Reviewed-on: k.renner/homelab#514
2026-08-19 06:50:24 +00:00
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
3 changed files with 63 additions and 134 deletions
-122
View File
@@ -1,122 +0,0 @@
# 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
# API answers the same call in <1s. Cause: the bpg provider reads
# `timeout_delete` from STATE at destroy time, and importing a resource
# whose config declares no timeouts stored 0 — a zero-second context.
# (The import/plan read path uses its own timeout, which is why stage 1
# planned clean.)
#
# 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.
#
# 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" = { 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
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]
}
}
+19 -2
View File
@@ -59,8 +59,25 @@ 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
+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"