319 lines
11 KiB
Python
Executable File
319 lines
11 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
|
|
"""
|
|
Scaffold a new NixOS host and add it to flake.nix.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
from textwrap import dedent
|
|
|
|
|
|
|
|
def _supports_color() -> bool:
|
|
return sys.stderr.isatty()
|
|
|
|
|
|
_C = {
|
|
"green": "\033[32m" if _supports_color() else "",
|
|
"yellow": "\033[33m" if _supports_color() else "",
|
|
"red": "\033[31m" if _supports_color() else "",
|
|
"bold": "\033[1m" if _supports_color() else "",
|
|
"reset": "\033[0m" if _supports_color() else "",
|
|
}
|
|
|
|
|
|
def log(msg: str) -> None:
|
|
print(f"{_C['green']}[+]{_C['reset']} {msg}", file=sys.stderr)
|
|
|
|
|
|
def warn(msg: str) -> None:
|
|
print(f"{_C['yellow']}[!]{_C['reset']} {msg}", file=sys.stderr)
|
|
|
|
|
|
def die(msg: str, code: int = 1) -> None:
|
|
print(f"{_C['red']}[-]{_C['reset']} {msg}", file=sys.stderr)
|
|
sys.exit(code)
|
|
|
|
|
|
|
|
# RFC 1123 hostname: 1-63 chars, [a-z0-9-], no leading/trailing dash.
|
|
HOST_RE = re.compile(r"^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$")
|
|
|
|
|
|
def valid_hostname(name: str) -> bool:
|
|
return bool(HOST_RE.match(name))
|
|
|
|
|
|
|
|
DEFAULT_NIX_TEMPLATE = dedent("""\
|
|
{{ pkgs, ... }}: {{
|
|
imports = [
|
|
./hardware.nix
|
|
../../modules/core.nix
|
|
../../modules/plasma.nix
|
|
../../modules/security.nix
|
|
];
|
|
|
|
networking.hostName = "{name}";
|
|
|
|
boot.loader.systemd-boot.enable = true;
|
|
boot.loader.efi.canTouchEfiVariables = true;
|
|
boot.kernelPackages = pkgs.linuxPackages_latest;
|
|
|
|
networking.networkmanager.enable = true;
|
|
|
|
programs.zsh.enable = true;
|
|
|
|
users.users.alex = {{
|
|
isNormalUser = true;
|
|
shell = pkgs.zsh;
|
|
extraGroups = [ "wheel" "networkmanager" ];
|
|
}};
|
|
|
|
system.stateVersion = "25.11";
|
|
}}
|
|
""")
|
|
|
|
|
|
PLACEHOLDER_HARDWARE = dedent("""\
|
|
# PLACEHOLDER hardware.nix for host "{name}".
|
|
#
|
|
# Replace this file with the output of `nixos-generate-config --show-hardware-config`
|
|
# run on the target machine. The justfile has a helper for this:
|
|
#
|
|
# just capture-hardware {name}
|
|
#
|
|
# Until then, this file intentionally does nothing useful and the host
|
|
# will not build.
|
|
{{ lib, ... }}: {{
|
|
# nixpkgs hostPlatform — change if not x86_64-linux.
|
|
nixpkgs.hostPlatform = lib.mkDefault "x86_64-linux";
|
|
|
|
# Intentionally empty. See note above.
|
|
}}
|
|
""")
|
|
|
|
|
|
|
|
|
|
def add_to_flake(flake_path: Path, name: str, *, template_host: str | None = None) -> None:
|
|
"""
|
|
Insert a new `<name> = nixpkgs.lib.nixosSystem { ... };` entry inside
|
|
the `nixosConfigurations = { ... };` block of flake.nix.
|
|
|
|
Strategy: locate the literal `nixosConfigurations = {` (allowing inline
|
|
or next-line `{`), find the matching closing `}` by brace counting,
|
|
then insert the new entry just before it. If `template_host` is set,
|
|
clone that host's block; otherwise emit a sensible default.
|
|
"""
|
|
src = flake_path.read_text()
|
|
|
|
m = re.search(r"nixosConfigurations\s*=\s*\{", src)
|
|
if not m:
|
|
die(f"could not find `nixosConfigurations = {{` in {flake_path}")
|
|
|
|
open_pos = m.end() - 1 # index of the '{'
|
|
# Brace-match to find the closing '}' of this attrset.
|
|
depth = 0
|
|
close_pos = None
|
|
in_string = False
|
|
i = open_pos
|
|
while i < len(src):
|
|
c = src[i]
|
|
# Cheap string skipping. flake.nix shouldn't have weird escapes inside
|
|
# nixosConfigurations, but be polite about double-quoted strings.
|
|
if c == '"' and (i == 0 or src[i - 1] != "\\"):
|
|
in_string = not in_string
|
|
elif not in_string:
|
|
if c == "{":
|
|
depth += 1
|
|
elif c == "}":
|
|
depth -= 1
|
|
if depth == 0:
|
|
close_pos = i
|
|
break
|
|
i += 1
|
|
|
|
if close_pos is None:
|
|
die("could not find the closing `}` of nixosConfigurations")
|
|
|
|
block = src[open_pos + 1 : close_pos]
|
|
|
|
# Already present? Bail.
|
|
if re.search(rf"(^|\W){re.escape(name)}\s*=\s*nixpkgs\.lib\.nixosSystem", block):
|
|
die(f"host `{name}` already exists in {flake_path}")
|
|
|
|
# If we can clone an existing entry, do it (preserves user customizations).
|
|
new_entry: str
|
|
cloned = False
|
|
if template_host:
|
|
# Find `<template_host> = nixpkgs.lib.nixosSystem { ... };` inside block.
|
|
tmpl_re = re.compile(
|
|
rf"(^[ \t]*){re.escape(template_host)}\s*=\s*nixpkgs\.lib\.nixosSystem\s*\{{",
|
|
re.MULTILINE,
|
|
)
|
|
tm = tmpl_re.search(block)
|
|
if tm:
|
|
tmpl_indent = tm.group(1)
|
|
tmpl_start = tm.start()
|
|
# Brace-match the template entry to find its terminating `};`.
|
|
d = 0
|
|
j = tm.end() - 1 # index of opening '{'
|
|
in_str = False
|
|
entry_end = None
|
|
while j < len(block):
|
|
cc = block[j]
|
|
if cc == '"' and (j == 0 or block[j - 1] != "\\"):
|
|
in_str = not in_str
|
|
elif not in_str:
|
|
if cc == "{":
|
|
d += 1
|
|
elif cc == "}":
|
|
d -= 1
|
|
if d == 0:
|
|
# Expect a trailing ';' after the closing '}'.
|
|
semi = block.find(";", j)
|
|
if semi != -1 and block[j + 1 : semi].strip() == "":
|
|
entry_end = semi + 1
|
|
else:
|
|
entry_end = j + 1
|
|
break
|
|
j += 1
|
|
if entry_end is None:
|
|
die(f"could not parse template host `{template_host}` in flake.nix")
|
|
|
|
entry_text = block[tmpl_start:entry_end]
|
|
# Rename the binding and rewrite ./hosts/<template_host> paths.
|
|
new_entry_body = re.sub(
|
|
rf"^([ \t]*){re.escape(template_host)}(\s*=\s*nixpkgs\.lib\.nixosSystem)",
|
|
rf"\1{name}\2",
|
|
entry_text,
|
|
count=1,
|
|
flags=re.MULTILINE,
|
|
)
|
|
new_entry_body = new_entry_body.replace(
|
|
f"./hosts/{template_host}", f"./hosts/{name}"
|
|
)
|
|
new_entry = "\n" + new_entry_body
|
|
cloned = True
|
|
|
|
if not cloned:
|
|
# Best-effort default block. Indent two extra spaces if we can guess
|
|
# the indentation by looking at existing content; otherwise 6 spaces.
|
|
indent_match = re.search(r"^([ \t]+)\S", block, re.MULTILINE)
|
|
indent = indent_match.group(1) if indent_match else " "
|
|
new_entry = (
|
|
f"\n{indent}{name} = nixpkgs.lib.nixosSystem {{\n"
|
|
f"{indent} inherit system;\n"
|
|
f"{indent} specialArgs = {{ inherit inputs; }};\n"
|
|
f"{indent} modules = [\n"
|
|
f"{indent} ./hosts/{name}\n"
|
|
f"{indent} home-manager.nixosModules.home-manager\n"
|
|
f"{indent} {{\n"
|
|
f"{indent} home-manager = {{\n"
|
|
f"{indent} useGlobalPkgs = true;\n"
|
|
f"{indent} useUserPackages = true;\n"
|
|
f"{indent} users.alex = import ./home/alex;\n"
|
|
f"{indent} extraSpecialArgs = {{ inherit inputs; }};\n"
|
|
f"{indent} }};\n"
|
|
f"{indent} }}\n"
|
|
f"{indent} ];\n"
|
|
f"{indent}}};\n"
|
|
)
|
|
|
|
# Insert new_entry just before the line containing the closing brace,
|
|
# so we never produce `};};` on a single line.
|
|
line_start = src.rfind("\n", 0, close_pos)
|
|
if line_start == -1:
|
|
line_start = 0
|
|
if not new_entry.endswith("\n"):
|
|
new_entry += "\n"
|
|
new_src = src[: line_start + 1] + new_entry + src[line_start + 1 :]
|
|
|
|
# Write atomically.
|
|
tmp = flake_path.with_suffix(flake_path.suffix + ".tmp")
|
|
tmp.write_text(new_src)
|
|
tmp.replace(flake_path)
|
|
|
|
|
|
def host_in_flake(flake_path: Path, name: str) -> bool:
|
|
"""Cheap textual check, does flake.nix already define this host?"""
|
|
src = flake_path.read_text()
|
|
return bool(
|
|
re.search(rf"(^|\W){re.escape(name)}\s*=\s*nixpkgs\.lib\.nixosSystem", src)
|
|
)
|
|
|
|
|
|
def find_template_host(flake_path: Path) -> str | None:
|
|
"""Return the name of the first existing nixosConfigurations entry, if any."""
|
|
src = flake_path.read_text()
|
|
m = re.search(r"nixosConfigurations\s*=\s*\{", src)
|
|
if not m:
|
|
return None
|
|
tail = src[m.end():]
|
|
m2 = re.search(r"^[ \t]*([A-Za-z_][A-Za-z0-9_-]*)\s*=\s*nixpkgs\.lib\.nixosSystem",
|
|
tail, re.MULTILINE)
|
|
return m2.group(1) if m2 else None
|
|
|
|
|
|
|
|
def main() -> int:
|
|
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
p.add_argument("name", help="hostname for the new host (lowercase, RFC 1123)")
|
|
p.add_argument("--repo-root", default=".", help="path to the repo root (default: cwd)")
|
|
p.add_argument("--dry-run", action="store_true", help="describe actions without writing")
|
|
args = p.parse_args()
|
|
|
|
if not valid_hostname(args.name):
|
|
die(f"invalid hostname: {args.name!r} (must match {HOST_RE.pattern})")
|
|
|
|
repo = Path(args.repo_root).resolve()
|
|
flake = repo / "flake.nix"
|
|
hosts_dir = repo / "hosts" / args.name
|
|
|
|
if not flake.is_file():
|
|
die(f"flake.nix not found at {flake}")
|
|
if hosts_dir.exists():
|
|
die(f"hosts/{args.name} already exists")
|
|
if host_in_flake(flake, args.name):
|
|
die(f"host `{args.name}` already defined in {flake} — refusing to touch anything")
|
|
|
|
template_host = find_template_host(flake)
|
|
log(f"repo: {repo}")
|
|
log(f"new host: {args.name}")
|
|
log(f"template host: {template_host or '<none, using default block>'}")
|
|
|
|
if args.dry_run:
|
|
warn("dry-run: nothing written")
|
|
return 0
|
|
|
|
# Scaffold filesystem.
|
|
hosts_dir.mkdir(parents=True)
|
|
(hosts_dir / "default.nix").write_text(DEFAULT_NIX_TEMPLATE.format(name=args.name))
|
|
(hosts_dir / "hardware.nix").write_text(PLACEHOLDER_HARDWARE.format(name=args.name))
|
|
log(f"created hosts/{args.name}/default.nix")
|
|
log(f"created hosts/{args.name}/hardware.nix (placeholder)")
|
|
|
|
# Edit flake.nix.
|
|
add_to_flake(flake, args.name, template_host=template_host)
|
|
log(f"added `{args.name}` entry to flake.nix")
|
|
|
|
print()
|
|
log(f"{_C['bold']}Next steps:{_C['reset']}")
|
|
print(f" 1. On the new machine (booted from installer or already running):")
|
|
print(f" just capture-hardware {args.name}")
|
|
print(f" This overwrites hosts/{args.name}/hardware.nix with the real config.")
|
|
print(f" 2. Review hosts/{args.name}/default.nix — tweak modules, users, etc.")
|
|
print(f" 3. git add hosts/{args.name} flake.nix && git commit")
|
|
print(f" 4. Build: just rebuild (when running ON the new host)")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|