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) <noreply@anthropic.com>
This commit is contained in:
root
2026-05-20 20:14:37 +02:00
parent aed977aa56
commit 2599c28104
2 changed files with 139 additions and 14 deletions

View File

@@ -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 ` <name>:` 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.<name>.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.<name>: 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.<name>: 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: 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 """Append `pubkey` to the `age:` list of the .sops.yaml rule whose
`path_regex:` line contains `path_regex_pattern`. Preserves comments. `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() \ netbird_fqdn = input(f" netbird FQDN (default: {name}.netbird.selfhosted): ").strip() \
or f"{name}.netbird.selfhosted" or f"{name}.netbird.selfhosted"
role = input(" role (e.g. primary-dev, dev): ").strip() or "dev" role = input(" role (e.g. primary-dev, dev): ").strip() or "dev"
entry = { if not _inventory_append_host(name, kind, os_name, role, netbird_fqdn):
"kind": kind, die("could not locate 'hosts:' section in inventory.yaml")
"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))
push_inventory(f"client-add: {name}") push_inventory(f"client-add: {name}")
print() print()
print("Next steps:") print("Next steps:")
@@ -549,8 +653,8 @@ def cmd_client_add(args: argparse.Namespace) -> int:
if name not in inv["hosts"]: if name not in inv["hosts"]:
die(f"{name} not in inventory — run 'homelab client add {name}' first (no --finalize-pubkey)") die(f"{name} not in inventory — run 'homelab client add {name}' first (no --finalize-pubkey)")
pubkey = args.finalize_pubkey pubkey = args.finalize_pubkey
inv["hosts"][name]["age_pubkey"] = pubkey if not _inventory_set_age_pubkey(name, pubkey):
INVENTORY.write_text(yaml.safe_dump(inv, sort_keys=False)) die(f"could not find hosts.{name} block to update age_pubkey")
print(f"set age_pubkey for {name}") print(f"set age_pubkey for {name}")
print("granting shared secrets...") print("granting shared secrets...")
_grant_shared_secrets(pubkey) _grant_shared_secrets(pubkey)
@@ -584,9 +688,9 @@ def cmd_client_remove(args: argparse.Namespace) -> int:
if not confirm(f"proceed removing {name}?"): if not confirm(f"proceed removing {name}?"):
return 1 return 1
# 1. Inventory # 1. Inventory — surgical block delete (preserves comments)
del inv["hosts"][name] if not _inventory_remove_host(name):
INVENTORY.write_text(yaml.safe_dump(inv, sort_keys=False)) die(f"could not locate hosts.{name} block to delete")
# 2. SOPS — remove the pubkey from shared-secret rules and re-key. # 2. SOPS — remove the pubkey from shared-secret rules and re-key.
if pubkey: if pubkey:

View File

@@ -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 <key>`.
# - 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: mesh:
primary: netbird primary: netbird
accepted: accepted: