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:
|
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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user