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:
132
bin/homelab
132
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 ` <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:
|
||||
"""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:
|
||||
|
||||
@@ -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:
|
||||
primary: netbird
|
||||
accepted:
|
||||
|
||||
Reference in New Issue
Block a user