Interim bridge to stop Longhorn replica-rebuild I/O storms on the shared HDD
Storage pool from starving etcd WAL fsync (crash-looping k3s / flapping the
kube-vip VIP). Adds a small dedicated SSD disk per node for etcd only; Longhorn
stays on HDD. See docs/plans/ssd-etcd-isolation.md.
tofu (infra/tofu):
- New vars etcd_ssd_datastore (default "local-zfs" = rpool/data, Micron 5100
SATA SSD mirror w/ PLP) and etcd_disk_gib (default 16).
- New virtio2 disk block on the SSD datastore per VM: discard=on, cache=none
(honor guest fsync straight to the ZFS ZIL — safe on PLP SSD), iothread=false.
Kept last (ascending interface) so bpg does not reorder virtio0/virtio1.
- Revert the debian_cloud_image resource to the long type name
proxmox_virtual_environment_download_file to match the R2 state: bpg >= 0.111
dropped the cross-type moved{} linkage to the short proxmox_download_file, so
the stale moved block failed EVERY plan with "Resource type mismatch" (broken
on main too). Aligning code to state yields a clean plan with no image
re-download. Short-name migration is deferred to a deliberate `tofu state mv`.
ansible (node_baseline):
- Format /dev/vdc (label k3s-etcd) and mount it at
/var/lib/rancher/k3s/server/db (defaults,noatime; no nofail so a missing etcd
disk fails loudly rather than silently landing etcd on the root HDD) before
k3s starts, so a rebuilt node comes up with etcd isolated on SSD. Data-safe:
filesystem module never reformats an existing fs.
Phase 1 only: non-disruptive. No tofu apply, no node/VM/etcd/Longhorn changes.
tofu plan verified: 0 to add, 3 to change, 0 to destroy — every VM an in-place
disk ADD (virtio2), dev-3 also an in-place memory 16384->24576; no VM replace.
Merge gate green (render==golden included).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
175 lines
7.4 KiB
Terraform
175 lines
7.4 KiB
Terraform
# main.tf — 3 cloud-init'd Proxmox VE VMs (k3s-dev-1/2/3), each with an imported
|
|
# OS disk (from the Debian cloud image) and a blank Longhorn data disk (R-ENV-01).
|
|
#
|
|
# Node lifecycle stage 1 ("Create") per docs/02. cloud-init (infra/cloud-init/)
|
|
# does stage 2 ("Baseline"); Ansible (infra/ansible/) does stage 3 ("Cluster-join").
|
|
#
|
|
# Provider: bpg/proxmox. VMs land on the Proxmox node `var.proxmox_node`, disks on
|
|
# `var.datastore` (ZFS "Storage"), NIC on `var.bridge` (vmbr0), static IPs via the
|
|
# native cloud-init `initialization` block. Native cloud-init (login user + SSH keys,
|
|
# static IP/gateway/DNS) is set via the Proxmox 8006 API. The rich first-boot baseline
|
|
# (packages, kernel modules, Longhorn mount, swap-off) is delivered by the Ansible
|
|
# `node_baseline` role (infra/ansible), run before k3s — not as a cloud-init snippet.
|
|
|
|
locals {
|
|
# Per-node identity/addressing, KEYED BY NODE NAME (map, not list).
|
|
# for_each over this map gives every node a stable, name-based state address
|
|
# (…vm.node["k3s-dev-2"]) instead of a positional one. Positional count indexes
|
|
# are brittle under -target/-replace: a single-node destroy on a count-indexed
|
|
# resource can cascade to siblings (F1 finding). Name keys are individually
|
|
# addressable, so one node can be replaced/destroyed without disturbing the others.
|
|
nodes = {
|
|
for i in range(var.node_count) : "${var.node_name_prefix}${i + 1}" => {
|
|
name = "${var.node_name_prefix}${i + 1}"
|
|
ip = var.node_ips[i]
|
|
fqdn = "${var.node_name_prefix}${i + 1}.${var.node_domain}"
|
|
is_first = i == 0
|
|
}
|
|
}
|
|
|
|
# Ordered (node1..nodeN) list + primary view for outputs that need positional
|
|
# access — the resource for_each uses the map above; these preserve ordering for
|
|
# the Ansible inventory (primary = cluster-init server, rest = secondaries).
|
|
node_list = [for i in range(var.node_count) : local.nodes["${var.node_name_prefix}${i + 1}"]]
|
|
primary = local.nodes["${var.node_name_prefix}1"]
|
|
}
|
|
|
|
# --- base OS cloud image (downloaded once to `local`, imported per node) -----
|
|
# content_type "import" (PVE 8.2+): the image must be on `import`-content storage to
|
|
# be usable as a VM disk `import_from` (uncompressed qcow2 only — ours qualifies).
|
|
# OPERATOR ACTION: enable the "Import" content type on var.image_datastore (Datacenter
|
|
# -> Storage -> local -> Content -> check "Import"), else the disk import fails with
|
|
# "wrong type 'iso' - needs to be 'images' or 'import'".
|
|
#
|
|
# The Debian cloud image is a bpg `download_file` resource. This intentionally uses the
|
|
# LONG (deprecated) type name `proxmox_virtual_environment_download_file`: the R2 backend
|
|
# state still holds this resource under the long name, and bpg >= 0.111 dropped the
|
|
# cross-type `moved{}` linkage to the short `proxmox_download_file` (ADR-007) — a `moved`
|
|
# block between the two now fails plan with "Resource type mismatch" (this broke plan on
|
|
# main entirely). Keeping the long name makes config match state, so the plan is clean and
|
|
# the ~450 MB image is NOT re-downloaded. Migrating to the short name is a SEPARATE,
|
|
# deliberate step (`tofu state mv proxmox_virtual_environment_download_file.debian_cloud_image
|
|
# proxmox_download_file.debian_cloud_image`, then rename here) — not bundled with the
|
|
# etcd-disk change so this bounce stays focused and low-risk.
|
|
resource "proxmox_virtual_environment_download_file" "debian_cloud_image" {
|
|
content_type = "import"
|
|
datastore_id = var.image_datastore
|
|
node_name = var.proxmox_node
|
|
url = var.image_source
|
|
file_name = var.image_file_name
|
|
overwrite = false
|
|
}
|
|
|
|
# --- cloud-init: NATIVE Proxmox cloud-init only (API-driven) ------------------
|
|
# The rich first-boot baseline (packages, kernel modules, Longhorn disk, swap-off)
|
|
# is NOT delivered as a custom snippet any more — custom snippets require SSH/SFTP
|
|
# to the Proxmox host, which is not reachable here. Instead the VM's `initialization`
|
|
# block below uses bpg's native user_account/ip_config/dns, which Proxmox sets via
|
|
# the built-in cloud-init API options (ciuser/sshkeys/ipconfig0/nameserver). The
|
|
# baseline moved to the Ansible `node_baseline` role (infra/ansible), run before k3s.
|
|
|
|
# --- the VMs ----------------------------------------------------------------
|
|
resource "proxmox_virtual_environment_vm" "node" {
|
|
for_each = local.nodes
|
|
name = each.value.name
|
|
node_name = var.proxmox_node
|
|
tags = ["k3s", "k3s-dev", "opentofu"]
|
|
|
|
description = "k3s dev node ${each.value.name} (${each.value.fqdn}) — provisioned by OpenTofu."
|
|
on_boot = true
|
|
stop_on_destroy = true
|
|
|
|
# `host` CPU type passes through the physical CPU so nested virt is available for
|
|
# KubeVirt in Phase 5.
|
|
cpu {
|
|
cores = var.vcpu
|
|
type = var.cpu_type
|
|
}
|
|
|
|
memory {
|
|
dedicated = var.memory_mib
|
|
}
|
|
|
|
# qemu-guest-agent is installed by the Ansible node_baseline role now (not cloud-init),
|
|
# so don't block VM create on the agent — static IPs are assigned, not discovered.
|
|
agent {
|
|
enabled = false
|
|
}
|
|
|
|
operating_system {
|
|
type = "l26"
|
|
}
|
|
|
|
# Serial console (socket): Debian cloud images boot with console=ttyS0 and can panic
|
|
# on import+resize when no serial device is present (bpg #1639). Standard + harmless
|
|
# for cloud images; the default VGA display is kept so the Proxmox UI console works too.
|
|
serial_device {
|
|
device = "socket"
|
|
}
|
|
|
|
# OS disk (virtio0 -> /dev/vda, boot): a copy of the imported cloud image, grown
|
|
# to os_disk_gib. Longhorn data disk (virtio1 -> /dev/vdb): blank, data_disk_gib.
|
|
# virtio* keeps the guest device names (/dev/vda, /dev/vdb) the cloud-init
|
|
# fs_setup/mount for /var/lib/longhorn already expects.
|
|
disk {
|
|
datastore_id = var.datastore
|
|
import_from = proxmox_virtual_environment_download_file.debian_cloud_image.id
|
|
interface = "virtio0"
|
|
size = var.os_disk_gib
|
|
discard = "on"
|
|
}
|
|
|
|
disk {
|
|
datastore_id = var.datastore
|
|
interface = "virtio1"
|
|
size = var.data_disk_gib
|
|
discard = "on"
|
|
}
|
|
|
|
# Dedicated SSD etcd disk (virtio2 -> /dev/vdc). Isolates etcd WAL fsync from the HDD
|
|
# `Storage` pool where the OS + Longhorn disks live — the node_baseline role formats
|
|
# it and mounts it at /var/lib/rancher/k3s/server/db before k3s starts. On the SSD
|
|
# `local-zfs` (rpool/data) datastore: an enterprise Micron SATA SSD mirror WITH
|
|
# power-loss protection, so cache=none (honor guest fsync straight through to the ZFS
|
|
# ZIL) is both durable and fast. Kept last (ascending interface) so bpg won't reorder
|
|
# the existing virtio0/virtio1 disks. See docs/plans/ssd-etcd-isolation.md.
|
|
disk {
|
|
datastore_id = var.etcd_ssd_datastore
|
|
interface = "virtio2"
|
|
size = var.etcd_disk_gib
|
|
discard = "on"
|
|
cache = "none"
|
|
iothread = false
|
|
}
|
|
|
|
network_device {
|
|
bridge = var.bridge
|
|
}
|
|
|
|
# Static addressing + DNS come from cloud-init (bpg generates the network-config),
|
|
# so the libvirt netplan template is gone. Native cloud-init (login user + SSH keys)
|
|
# comes from user_account below; the OS baseline is the Ansible node_baseline role.
|
|
initialization {
|
|
datastore_id = var.datastore
|
|
|
|
ip_config {
|
|
ipv4 {
|
|
address = "${each.value.ip}/${var.network_prefix}"
|
|
gateway = var.network_gateway
|
|
}
|
|
}
|
|
|
|
dns {
|
|
servers = [var.dns_server]
|
|
}
|
|
|
|
user_account {
|
|
username = var.login_user
|
|
keys = [trimspace(var.ssh_authorized_key)]
|
|
}
|
|
}
|
|
|
|
# Boot from the OS disk only.
|
|
boot_order = ["virtio0"]
|
|
}
|