From 2599c2810428adb13e13a2c800e222ebffd7aab2 Mon Sep 17 00:00:00 2001 From: root Date: Wed, 20 May 2026 20:14:37 +0200 Subject: [PATCH] homelab client add/remove: surgical inventory edits (preserve comments) yaml.safe_load + safe_dump stripped every comment from inventory.yaml on each enrollment, eroding the file's documentation value. New _inventory_set_age_pubkey / _inventory_remove_host / _inventory_append_host helpers do line-based edits so comments outside the modified region survive. inventory.yaml gets its top-of-file conventions block back. build_host_files.py round-trips cleanly (--check returns 0). Co-Authored-By: Claude Opus 4.7 (1M context) --- bin/homelab | 132 +++++++++++++++++++++++++++++++++++++++++++------ inventory.yaml | 21 ++++++++ 2 files changed, 139 insertions(+), 14 deletions(-) diff --git a/bin/homelab b/bin/homelab index 09ada69..9ac9a8a 100755 --- a/bin/homelab +++ b/bin/homelab @@ -126,6 +126,117 @@ SHARED_SECRETS = [ ] +# -------- comment-preserving inventory.yaml edits -------- +# yaml.safe_load + safe_dump round-trips strip every comment, which is fine +# for hosts/*.yaml (generated anyway) but unfriendly for inventory.yaml where +# we want the doc comments at the top + per-section to survive. These helpers +# do line-based surgical edits instead. + +import re as _re + + +def _find_host_block(lines: list[str], name: str) -> tuple[int, int] | None: + """Return (start, end_exclusive) line range for ` :` in inventory.yaml. + Range covers the host's body up to the next 2-space-indented key (next + host) or the next 0-indented top-level key, whichever comes first. + """ + target_re = _re.compile(rf"^ {_re.escape(name)}:\s*$") + host_at_2sp = _re.compile(r"^ [A-Za-z][A-Za-z0-9_-]*:\s*$") + toplevel = _re.compile(r"^[A-Za-z]") + start = None + for i, line in enumerate(lines): + if start is None: + if target_re.match(line): + start = i + continue + # We're inside the target block; look for the next sibling host + # or a top-level key to mark the end. + if host_at_2sp.match(line): + return (start, i) + if toplevel.match(line): + return (start, i) + if start is not None: + return (start, len(lines)) + return None + + +def _inventory_set_age_pubkey(name: str, pubkey: str) -> bool: + """Surgically set hosts..age_pubkey in inventory.yaml. Preserves + every comment outside the modified line. Returns True if updated, False + if the host block or age_pubkey line wasn't found. + """ + lines = INVENTORY.read_text().splitlines(keepends=True) + block = _find_host_block(lines, name) + if block is None: + return False + start, end = block + pubkey_re = _re.compile(r"^(\s+age_pubkey:\s*).*$") + for i in range(start, end): + m = pubkey_re.match(lines[i]) + if m: + lines[i] = f"{m.group(1)}{pubkey}\n" + INVENTORY.write_text("".join(lines)) + return True + # No existing age_pubkey line; insert one at the end of the block. + # Use 4-space indent (matching the other host fields). + insert_at = end + while insert_at > start and lines[insert_at - 1].strip() == "": + insert_at -= 1 + lines.insert(insert_at, f" age_pubkey: {pubkey}\n") + INVENTORY.write_text("".join(lines)) + return True + + +def _inventory_remove_host(name: str) -> bool: + """Delete the hosts.: block. Preserves comments outside the block.""" + lines = INVENTORY.read_text().splitlines(keepends=True) + block = _find_host_block(lines, name) + if block is None: + return False + start, end = block + del lines[start:end] + INVENTORY.write_text("".join(lines)) + return True + + +def _inventory_append_host(name: str, kind: str, os_name: str, role: str, + netbird_fqdn: str) -> bool: + """Append a new hosts.: block at the end of the hosts: section. + Returns True on success, False if no hosts: section was found. + """ + lines = INVENTORY.read_text().splitlines(keepends=True) + # Find the line "hosts:" at column 0. + hosts_idx = None + for i, line in enumerate(lines): + if line.startswith("hosts:"): + hosts_idx = i + break + if hosts_idx is None: + return False + # Find the end of the hosts: section (next top-level key or EOF). + insert_at = len(lines) + for i in range(hosts_idx + 1, len(lines)): + if _re.match(r"^[A-Za-z]", lines[i]): + insert_at = i + break + # Trim trailing blanks before insertion point so we don't double-space. + while insert_at > hosts_idx + 1 and lines[insert_at - 1].strip() == "": + insert_at -= 1 + block = ( + f" {name}:\n" + f" kind: {kind}\n" + f" os: {os_name}\n" + f" role: {role}\n" + f" mesh:\n" + f" netbird:\n" + f" fqdn: {netbird_fqdn}\n" + f" age_pubkey: \"\"\n" + ) + lines.insert(insert_at, block) + INVENTORY.write_text("".join(lines)) + return True + + def _add_recipient_to_sops_policy(sops_path: Path, path_regex_pattern: str, pubkey: str) -> bool: """Append `pubkey` to the `age:` list of the .sops.yaml rule whose `path_regex:` line contains `path_regex_pattern`. Preserves comments. @@ -527,15 +638,8 @@ def cmd_client_add(args: argparse.Namespace) -> int: netbird_fqdn = input(f" netbird FQDN (default: {name}.netbird.selfhosted): ").strip() \ or f"{name}.netbird.selfhosted" role = input(" role (e.g. primary-dev, dev): ").strip() or "dev" - entry = { - "kind": kind, - "os": os_name, - "role": role, - "mesh": {"netbird": {"fqdn": netbird_fqdn}}, - "age_pubkey": "", - } - inv["hosts"][name] = entry - INVENTORY.write_text(yaml.safe_dump(inv, sort_keys=False)) + if not _inventory_append_host(name, kind, os_name, role, netbird_fqdn): + die("could not locate 'hosts:' section in inventory.yaml") push_inventory(f"client-add: {name}") print() print("Next steps:") @@ -549,8 +653,8 @@ def cmd_client_add(args: argparse.Namespace) -> int: if name not in inv["hosts"]: die(f"{name} not in inventory — run 'homelab client add {name}' first (no --finalize-pubkey)") pubkey = args.finalize_pubkey - inv["hosts"][name]["age_pubkey"] = pubkey - INVENTORY.write_text(yaml.safe_dump(inv, sort_keys=False)) + if not _inventory_set_age_pubkey(name, pubkey): + die(f"could not find hosts.{name} block to update age_pubkey") print(f"set age_pubkey for {name}") print("granting shared secrets...") _grant_shared_secrets(pubkey) @@ -584,9 +688,9 @@ def cmd_client_remove(args: argparse.Namespace) -> int: if not confirm(f"proceed removing {name}?"): return 1 - # 1. Inventory - del inv["hosts"][name] - INVENTORY.write_text(yaml.safe_dump(inv, sort_keys=False)) + # 1. Inventory — surgical block delete (preserves comments) + if not _inventory_remove_host(name): + die(f"could not locate hosts.{name} block to delete") # 2. SOPS — remove the pubkey from shared-secret rules and re-key. if pubkey: diff --git a/inventory.yaml b/inventory.yaml index cc9f0fd..b5f551b 100644 --- a/inventory.yaml +++ b/inventory.yaml @@ -1,3 +1,24 @@ +# Homelab inventory — canonical structured topology. +# +# Single source of truth. hosts/*.yaml is generated from this file by +# mcp/build_host_files.py; do NOT edit those by hand. +# +# Conventions: +# - hostname keys MUST match the actual `hostname` of the machine (on +# macOS: `scutil --get LocalHostName` if set). +# - `os:` one of: linux, macos +# - `kind:` one of: proxmox-host, lxc, vm, workstation +# - `mesh:` lists addresses the host is reachable at. Both `netbird` and +# `tailscale` are accepted during the migration (see infrastructure/mesh.md). +# Prefer netbird FQDNs over raw IPs. +# - `age_pubkey:` provisioned by secrets-issuance on first bootstrap and +# committed back via `homelab client add --finalize-pubkey `. +# - When a service moves hosts, update only the `services:` section here; +# never duplicate addresses elsewhere. +# +# `homelab client add/remove` does surgical line-edits — comments survive. +# Avoid round-tripping the file through yaml.safe_dump (it strips comments). + mesh: primary: netbird accepted: