feat: Automate new NixOS installations
This commit is contained in:
@@ -1,6 +1,12 @@
|
|||||||
|
set positional-arguments
|
||||||
|
|
||||||
host := `hostname`
|
host := `hostname`
|
||||||
key_services := "github gradescope"
|
key_services := "github gradescope"
|
||||||
|
|
||||||
|
# Default: list available recipes.
|
||||||
|
default:
|
||||||
|
@just --list
|
||||||
|
|
||||||
# Rebuild the system and switch immediately
|
# Rebuild the system and switch immediately
|
||||||
rebuild:
|
rebuild:
|
||||||
sudo nixos-rebuild switch --flake .#{{host}}
|
sudo nixos-rebuild switch --flake .#{{host}}
|
||||||
@@ -33,3 +39,101 @@ gen-keys:
|
|||||||
ssh-keygen -t ed25519 -f "$file" -N "" -C "$service@$(hostname -s)"
|
ssh-keygen -t ed25519 -f "$file" -N "" -C "$service@$(hostname -s)"
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# Build a bootable NixOS installer USB (writes ISO + optional extras partition).
|
||||||
|
# Pass arguments through to scripts/make-usb.sh, e.g.:
|
||||||
|
# just make-usb --iso ~/Downloads/nixos.iso
|
||||||
|
# Re-executes itself under sudo if not already root. Quoting is preserved
|
||||||
|
# via `set positional-arguments` + "$@".
|
||||||
|
make-usb *args:
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
if [[ ! -x scripts/make-usb.sh ]]; then
|
||||||
|
chmod +x scripts/make-usb.sh
|
||||||
|
fi
|
||||||
|
if (( EUID == 0 )); then
|
||||||
|
exec ./scripts/make-usb.sh "$@"
|
||||||
|
else
|
||||||
|
exec sudo --preserve-env=PATH ./scripts/make-usb.sh "$@"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Register a new host: scaffold hosts/<name>/ and add a flake.nix entry.
|
||||||
|
# Example: just register-host blackbear
|
||||||
|
register-host name:
|
||||||
|
@python3 scripts/register-host.py --repo-root . "{{name}}"
|
||||||
|
|
||||||
|
# Capture the current machine's hardware config into hosts/<name>/hardware.nix.
|
||||||
|
# Run this on the *new* machine. Two contexts work:
|
||||||
|
# 1. Already-installed NixOS: just runs nixos-generate-config against /.
|
||||||
|
# 2. Booted from installer with target mounted at /mnt: detected
|
||||||
|
# automatically and --root /mnt is used.
|
||||||
|
# Example: just capture-hardware blackbear
|
||||||
|
capture-hardware name:
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
name="{{name}}"
|
||||||
|
target="hosts/$name/hardware.nix"
|
||||||
|
|
||||||
|
if [[ ! -d "hosts/$name" ]]; then
|
||||||
|
echo "[-] hosts/$name does not exist. Run \`just register-host $name\` first." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v nixos-generate-config >/dev/null 2>&1; then
|
||||||
|
echo "[-] nixos-generate-config not found." >&2
|
||||||
|
echo " Run this command on a NixOS machine (or inside the installer)." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Detect whether we're in the installer with a target mounted at /mnt.
|
||||||
|
extra_args=()
|
||||||
|
if mountpoint -q /mnt 2>/dev/null && [[ -d /mnt/etc ]]; then
|
||||||
|
echo "[+] Detected mounted target at /mnt — using --root /mnt"
|
||||||
|
extra_args+=(--root /mnt)
|
||||||
|
elif mountpoint -q /mnt 2>/dev/null; then
|
||||||
|
echo "[!] /mnt is mounted but doesn't look like a NixOS target (no /mnt/etc)."
|
||||||
|
echo " Continuing as if on a running system. Pass --no-filesystems by editing"
|
||||||
|
echo " hardware.nix afterwards if disks aren't permanent."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Confirm before overwriting a non-placeholder hardware.nix.
|
||||||
|
if [[ -f "$target" ]] && ! grep -q 'PLACEHOLDER hardware.nix' "$target"; then
|
||||||
|
echo "[!] $target already exists and doesn't look like a placeholder." >&2
|
||||||
|
read -r -p "Overwrite? (yes/[no]) " confirm
|
||||||
|
[[ "$confirm" == "yes" ]] || { echo "aborted."; exit 1; }
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[+] Generating hardware config..."
|
||||||
|
# The redirect runs in the user's shell BEFORE sudo, so the file is
|
||||||
|
# owned by you, not root. sudo is needed because nixos-generate-config
|
||||||
|
# reads /proc, /sys, and may probe mounted filesystems.
|
||||||
|
sudo nixos-generate-config --show-hardware-config "${extra_args[@]}" > "$target"
|
||||||
|
echo "[+] Wrote $target"
|
||||||
|
if command -v git >/dev/null && git -C . rev-parse >/dev/null 2>&1; then
|
||||||
|
echo "[+] git diff:"
|
||||||
|
git --no-pager diff -- "$target" || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Show which hosts are currently registered in flake.nix (parses the file directly).
|
||||||
|
hosts:
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
import re, sys
|
||||||
|
src = open('flake.nix').read()
|
||||||
|
m = re.search(r'nixosConfigurations\s*=\s*\{', src)
|
||||||
|
if not m:
|
||||||
|
sys.exit(0)
|
||||||
|
# Brace-match to find the closing }.
|
||||||
|
depth, i, close = 0, m.end() - 1, None
|
||||||
|
while i < len(src):
|
||||||
|
c = src[i]
|
||||||
|
if c == '{': depth += 1
|
||||||
|
elif c == '}':
|
||||||
|
depth -= 1
|
||||||
|
if depth == 0:
|
||||||
|
close = i
|
||||||
|
break
|
||||||
|
i += 1
|
||||||
|
block = src[m.end():close] if close else ''
|
||||||
|
for name in re.findall(r'^[ \t]*([A-Za-z_][\w-]*)\s*=\s*nixpkgs\.lib\.nixosSystem', block, re.M):
|
||||||
|
print(name)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,405 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# make-usb.sh
|
||||||
|
#
|
||||||
|
# Write a NixOS installer ISO to a USB stick (raw dd)
|
||||||
|
# and optionally append a writable "extras" partition with files
|
||||||
|
# (this repo, SSH keys, etc.) that you want available during install.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### Styling ###
|
||||||
|
|
||||||
|
if [[ -t 1 ]]; then
|
||||||
|
GREEN=$'\e[32m'; YELLOW=$'\e[33m'; RED=$'\e[31m'; BLUE=$'\e[34m'
|
||||||
|
BOLD=$'\e[1m'; RESET=$'\e[0m'
|
||||||
|
else
|
||||||
|
GREEN=""; YELLOW=""; RED=""; BLUE=""; BOLD=""; RESET=""
|
||||||
|
fi
|
||||||
|
log() { printf '%s[+]%s %s\n' "$GREEN" "$RESET" "$*"; }
|
||||||
|
warn() { printf '%s[!]%s %s\n' "$YELLOW" "$RESET" "$*" >&2; }
|
||||||
|
err() { printf '%s[-]%s %s\n' "$RED" "$RESET" "$*" >&2; }
|
||||||
|
info() { printf '%s[i]%s %s\n' "$BLUE" "$RESET" "$*"; }
|
||||||
|
die() { err "$1"; exit "${2:-1}"; }
|
||||||
|
|
||||||
|
ISO_PATH=""
|
||||||
|
DEVICE=""
|
||||||
|
SKIP_EXTRAS=0
|
||||||
|
ALLOW_INTERNAL=0
|
||||||
|
ASSUME_YES=0
|
||||||
|
REPO_ROOT=""
|
||||||
|
|
||||||
|
while (($#)); do
|
||||||
|
case "$1" in
|
||||||
|
--iso) ISO_PATH="${2:?missing path}"; shift 2 ;;
|
||||||
|
--device|--dev) DEVICE="${2:?missing path}"; shift 2 ;;
|
||||||
|
--no-extras) SKIP_EXTRAS=1; shift ;;
|
||||||
|
--allow-internal) ALLOW_INTERNAL=1; shift ;;
|
||||||
|
--yes|-y) ASSUME_YES=1; shift ;;
|
||||||
|
--repo-root) REPO_ROOT="${2:?missing path}"; shift 2 ;;
|
||||||
|
-h|--help)
|
||||||
|
sed -n '2,16p' "$0" | sed 's/^# \{0,1\}//'
|
||||||
|
exit 0 ;;
|
||||||
|
*) die "unknown arg: $1" 2 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### Preflight checks ###
|
||||||
|
|
||||||
|
(( EUID == 0 )) || die "run as root: sudo $0 $*" 1
|
||||||
|
|
||||||
|
need_tty=0
|
||||||
|
[[ -z "$DEVICE" || -z "$ISO_PATH" ]] && need_tty=1
|
||||||
|
(( ASSUME_YES )) || need_tty=1
|
||||||
|
if (( need_tty )) && ! [[ -t 0 ]]; then
|
||||||
|
die "stdin is not a tty; pass --iso, --device, and --yes for non-interactive use" 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Required commands. We try to be helpful about missing ones on NixOS.
|
||||||
|
REQUIRED=(lsblk dd sync wipefs sgdisk partprobe blockdev mount umount mountpoint mkfs.exfat awk)
|
||||||
|
missing=()
|
||||||
|
for cmd in "${REQUIRED[@]}"; do
|
||||||
|
command -v "$cmd" >/dev/null 2>&1 || missing+=("$cmd")
|
||||||
|
done
|
||||||
|
if ((${#missing[@]})); then
|
||||||
|
err "missing tools: ${missing[*]}"
|
||||||
|
cat >&2 <<'EOF'
|
||||||
|
|
||||||
|
On NixOS / with Nix installed, re-run inside a shell that has them:
|
||||||
|
nix shell nixpkgs#util-linux nixpkgs#coreutils nixpkgs#gptfdisk \
|
||||||
|
nixpkgs#exfatprogs nixpkgs#parted -c sudo ./scripts/make-usb.sh
|
||||||
|
|
||||||
|
On Debian/Ubuntu:
|
||||||
|
sudo apt install util-linux coreutils gdisk exfatprogs parted
|
||||||
|
EOF
|
||||||
|
exit 3
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Locate repo root if not provided (used as a copy candidate for extras).
|
||||||
|
if [[ -z "$REPO_ROOT" ]]; then
|
||||||
|
if REPO_ROOT="$(git -C "$(dirname "$0")" rev-parse --show-toplevel 2>/dev/null)"; then
|
||||||
|
:
|
||||||
|
else
|
||||||
|
REPO_ROOT=""
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### Cleanup trap ###
|
||||||
|
|
||||||
|
TMP_MNT=""
|
||||||
|
cleanup() {
|
||||||
|
set +e
|
||||||
|
if [[ -n "$TMP_MNT" && -d "$TMP_MNT" ]]; then
|
||||||
|
mountpoint -q "$TMP_MNT" && umount "$TMP_MNT"
|
||||||
|
rmdir "$TMP_MNT" 2>/dev/null
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
trap cleanup EXIT INT TERM
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### Helpers ###
|
||||||
|
|
||||||
|
human() { numfmt --to=iec --suffix=B "$1" 2>/dev/null || echo "$1"; }
|
||||||
|
|
||||||
|
is_removable() {
|
||||||
|
# /sys/block/<name>/removable: 1 = removable (USB stick, SD via USB reader),
|
||||||
|
# 0 = fixed (internal disk, many "USB" enclosures lie though).
|
||||||
|
local name; name="$(basename "$1")"
|
||||||
|
[[ "$(cat "/sys/block/$name/removable" 2>/dev/null || echo 0)" == "1" ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
contains_root_or_boot() {
|
||||||
|
# Refuse if any partition on this disk holds /, /boot, or active swap.
|
||||||
|
local dev="$1"
|
||||||
|
lsblk -lnpo MOUNTPOINT,NAME "$dev" | awk -v d="$dev" '
|
||||||
|
$1=="/" || $1=="/boot" || $1=="[SWAP]" { found=1 }
|
||||||
|
END { exit !found }
|
||||||
|
'
|
||||||
|
}
|
||||||
|
|
||||||
|
select_device() {
|
||||||
|
log "Available whole disks:"
|
||||||
|
printf ' %-3s %-14s %-8s %-6s %-16s %s\n' "Idx" "Device" "Size" "Bus" "Removable" "Model"
|
||||||
|
|
||||||
|
# Use lsblk's pairs mode for robust parsing (handles spaces in MODEL).
|
||||||
|
# lsblk -P emits lines of KEY="value" KEY="value" ... we sanity-check
|
||||||
|
# the line matches that strict shape before eval'ing it.
|
||||||
|
local -a CANDS=()
|
||||||
|
local line NAME SIZE MODEL TRAN RM TYPE
|
||||||
|
while IFS= read -r line; do
|
||||||
|
[[ "$line" =~ ^([A-Z]+=\"[^\"]*\"[[:space:]]*)+$ ]] || continue
|
||||||
|
NAME=""; SIZE=""; MODEL=""; TRAN=""; RM=""; TYPE=""
|
||||||
|
eval "$line"
|
||||||
|
[[ "${TYPE:-}" == "disk" ]] || continue
|
||||||
|
# Skip optical, loop, ram, zram, dm.
|
||||||
|
case "$NAME" in
|
||||||
|
/dev/sr*|/dev/loop*|/dev/ram*|/dev/zram*|/dev/dm-*) continue ;;
|
||||||
|
esac
|
||||||
|
CANDS+=("$NAME"$'\t'"${SIZE:-?}"$'\t'"${TRAN:-?}"$'\t'"${RM:-0}"$'\t'"${MODEL:-?}")
|
||||||
|
done < <(lsblk -dpP -o NAME,SIZE,MODEL,TRAN,RM,TYPE)
|
||||||
|
|
||||||
|
((${#CANDS[@]})) || die "no candidate disks found" 4
|
||||||
|
|
||||||
|
local i=1
|
||||||
|
for c in "${CANDS[@]}"; do
|
||||||
|
IFS=$'\t' read -r n s t r m <<<"$c"
|
||||||
|
local marker=""
|
||||||
|
if [[ "$r" == "1" ]]; then marker="${GREEN}yes${RESET}"; else marker="${YELLOW}no${RESET}"; fi
|
||||||
|
if contains_root_or_boot "$n"; then
|
||||||
|
marker="${RED}SYSTEM DISK${RESET}"
|
||||||
|
fi
|
||||||
|
printf ' %-3s %-14s %-8s %-6s %-16b %s\n' "$i" "$n" "$s" "$t" "$marker" "$m"
|
||||||
|
((i++))
|
||||||
|
done
|
||||||
|
echo
|
||||||
|
echo "Enter 0 to abort."
|
||||||
|
echo
|
||||||
|
|
||||||
|
local idx
|
||||||
|
while :; do
|
||||||
|
read -r -p "Select device [1-${#CANDS[@]}]: " idx
|
||||||
|
[[ "$idx" =~ ^[0-9]+$ ]] || { warn "not a number"; continue; }
|
||||||
|
(( idx == 0 )) && die "aborted by user" 1
|
||||||
|
(( idx >= 1 && idx <= ${#CANDS[@]} )) || { warn "out of range"; continue; }
|
||||||
|
IFS=$'\t' read -r n _ _ r _ <<<"${CANDS[idx-1]}"
|
||||||
|
if contains_root_or_boot "$n"; then
|
||||||
|
err "$n is the running system disk — refusing"; continue
|
||||||
|
fi
|
||||||
|
if [[ "$r" != "1" ]] && (( ! ALLOW_INTERNAL )); then
|
||||||
|
err "$n is not removable; pass --allow-internal if you really mean it"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
DEVICE="$n"
|
||||||
|
return 0
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### Pick device ###
|
||||||
|
|
||||||
|
if [[ -z "$DEVICE" ]]; then
|
||||||
|
select_device
|
||||||
|
else
|
||||||
|
[[ -b "$DEVICE" ]] || die "$DEVICE is not a block device" 2
|
||||||
|
# Reject partitions. We want whole disks only.
|
||||||
|
if [[ "$(lsblk -dno TYPE "$DEVICE" 2>/dev/null)" != "disk" ]]; then
|
||||||
|
die "$DEVICE is not a whole disk (partition or other type)" 4
|
||||||
|
fi
|
||||||
|
if contains_root_or_boot "$DEVICE"; then
|
||||||
|
die "$DEVICE holds /, /boot, or active swap — refusing" 4
|
||||||
|
fi
|
||||||
|
if ! is_removable "$DEVICE" && (( ! ALLOW_INTERNAL )); then
|
||||||
|
die "$DEVICE is not removable; pass --allow-internal to override" 4
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
DEV_BYTES="$(blockdev --getsize64 "$DEVICE")"
|
||||||
|
log "Target: $DEVICE ($(human "$DEV_BYTES"))"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### Pick ISO ###
|
||||||
|
|
||||||
|
if [[ -z "$ISO_PATH" ]]; then
|
||||||
|
while :; do
|
||||||
|
read -r -e -p "Path to NixOS ISO: " ISO_PATH
|
||||||
|
ISO_PATH="${ISO_PATH/#\~/$HOME}"
|
||||||
|
[[ -f "$ISO_PATH" ]] && break
|
||||||
|
warn "not a file: $ISO_PATH"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
[[ -f "$ISO_PATH" ]] || die "ISO not found: $ISO_PATH" 2
|
||||||
|
|
||||||
|
ISO_BYTES="$(stat -c '%s' "$ISO_PATH")"
|
||||||
|
log "ISO: $ISO_PATH ($(human "$ISO_BYTES"))"
|
||||||
|
|
||||||
|
# Sanity: file should look like an ISO 9660 image.
|
||||||
|
if command -v file >/dev/null 2>&1; then
|
||||||
|
if ! file -b "$ISO_PATH" | grep -qiE 'iso 9660|udf'; then
|
||||||
|
warn "$(file -b "$ISO_PATH")"
|
||||||
|
warn "file(1) doesn't recognise this as ISO 9660. Continuing anyway."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Sanity: ISO must fit on disk with room for an extras partition.
|
||||||
|
MIN_EXTRAS_MB=64
|
||||||
|
if (( ISO_BYTES + MIN_EXTRAS_MB*1024*1024 > DEV_BYTES )); then
|
||||||
|
if (( ISO_BYTES > DEV_BYTES )); then
|
||||||
|
die "ISO is larger than the disk" 4
|
||||||
|
fi
|
||||||
|
warn "Less than ${MIN_EXTRAS_MB}MiB free after ISO; extras partition will be skipped."
|
||||||
|
SKIP_EXTRAS=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Optional sha256 sidecar verification.
|
||||||
|
if [[ -f "$ISO_PATH.sha256" ]]; then
|
||||||
|
log "Verifying $ISO_PATH.sha256..."
|
||||||
|
( cd "$(dirname "$ISO_PATH")" && sha256sum -c "$(basename "$ISO_PATH").sha256" ) \
|
||||||
|
|| die "sha256 verification failed" 5
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### Final confirmation ###
|
||||||
|
|
||||||
|
cat <<EOF
|
||||||
|
|
||||||
|
${BOLD}About to:${RESET}
|
||||||
|
${RED}WIPE${RESET} $DEVICE ($(human "$DEV_BYTES"))
|
||||||
|
Write ISO $ISO_PATH
|
||||||
|
Add extras $( ((SKIP_EXTRAS)) && echo "no" || echo "yes (exFAT, in trailing free space)" )
|
||||||
|
|
||||||
|
${YELLOW}All data on $DEVICE will be destroyed.${RESET}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
if (( ! ASSUME_YES )); then
|
||||||
|
read -r -p "Type the device path to confirm (e.g. $DEVICE): " typed
|
||||||
|
[[ "$typed" == "$DEVICE" ]] || die "confirmation didn't match — aborting" 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### Unmount anything on the target ###
|
||||||
|
|
||||||
|
log "Unmounting any partitions on $DEVICE..."
|
||||||
|
while read -r part mnt; do
|
||||||
|
[[ -n "$mnt" ]] || continue
|
||||||
|
warn "umount $part ($mnt)"
|
||||||
|
umount "$part" 2>/dev/null || umount -l "$part" 2>/dev/null || warn "could not umount $part"
|
||||||
|
done < <(lsblk -lnpo NAME,MOUNTPOINT "$DEVICE" | awk '$2!=""')
|
||||||
|
|
||||||
|
### Wipe & write ###
|
||||||
|
log "Wiping signatures..."
|
||||||
|
wipefs -a "$DEVICE" >/dev/null
|
||||||
|
|
||||||
|
log "Writing ISO with dd (this can take a few minutes)..."
|
||||||
|
dd if="$ISO_PATH" of="$DEVICE" bs=4M status=progress conv=fsync oflag=direct \
|
||||||
|
|| die "dd failed" 5
|
||||||
|
sync
|
||||||
|
log "ISO written."
|
||||||
|
|
||||||
|
partprobe "$DEVICE" 2>/dev/null || true
|
||||||
|
sleep 1
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### Extras partition ###
|
||||||
|
|
||||||
|
if (( SKIP_EXTRAS )); then
|
||||||
|
log "Skipping extras partition."
|
||||||
|
else
|
||||||
|
log "Preparing extras partition in trailing free space..."
|
||||||
|
|
||||||
|
# The NixOS ISO uses an isohybrid GPT. The backup GPT header still sits at
|
||||||
|
# the *original ISO end*, not the actual disk end. `sgdisk -e` relocates it
|
||||||
|
# so we can add a partition in the freed trailing region.
|
||||||
|
# `sgdisk -n` with 0:0:0 creates a new partition starting at the first free
|
||||||
|
# sector and ending at the last free sector (i.e., filling the rest).
|
||||||
|
sgdisk -e "$DEVICE" >/dev/null
|
||||||
|
sgdisk -n 0:0:0 -t 0:0700 -c 0:"EXTRAS" "$DEVICE" >/dev/null
|
||||||
|
partprobe "$DEVICE" 2>/dev/null || true
|
||||||
|
sleep 1
|
||||||
|
|
||||||
|
# Find the new partition (highest-numbered one on the disk).
|
||||||
|
EXTRAS_PART="$(lsblk -lnpo NAME,TYPE "$DEVICE" \
|
||||||
|
| awk '$2=="part"{print $1}' | tail -n1)"
|
||||||
|
[[ -b "$EXTRAS_PART" ]] || die "extras partition didn't appear" 5
|
||||||
|
log "Extras partition: $EXTRAS_PART"
|
||||||
|
|
||||||
|
log "Formatting $EXTRAS_PART as exFAT..."
|
||||||
|
mkfs.exfat -L EXTRAS "$EXTRAS_PART" >/dev/null 2>&1 \
|
||||||
|
|| die "mkfs.exfat failed" 5
|
||||||
|
|
||||||
|
TMP_MNT="$(mktemp -d)"
|
||||||
|
mount "$EXTRAS_PART" "$TMP_MNT"
|
||||||
|
|
||||||
|
echo
|
||||||
|
log "What to copy onto the extras partition? (space-separated numbers; Enter for none)"
|
||||||
|
echo
|
||||||
|
echo " 1) This repo ${REPO_ROOT:-<not detected>}"
|
||||||
|
echo " 2) Your public SSH keys ~/.ssh/*.pub"
|
||||||
|
echo " 3) An arbitrary file/dir (you'll be prompted for the path)"
|
||||||
|
echo " 4) A custom message/README (you type it inline)"
|
||||||
|
echo
|
||||||
|
|
||||||
|
read -r -p "Selection: " sel || sel=""
|
||||||
|
for tok in $sel; do
|
||||||
|
case "$tok" in
|
||||||
|
1)
|
||||||
|
if [[ -z "$REPO_ROOT" ]]; then
|
||||||
|
warn "no repo detected; pass --repo-root or run from a git checkout"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
log "Copying repo -> /repo (git tracked files only)..."
|
||||||
|
# Use `git archive` if possible, it respects .gitignore and skips .git.
|
||||||
|
if git -C "$REPO_ROOT" rev-parse >/dev/null 2>&1; then
|
||||||
|
mkdir -p "$TMP_MNT/repo"
|
||||||
|
git -C "$REPO_ROOT" archive --format=tar HEAD \
|
||||||
|
| tar -x -C "$TMP_MNT/repo"
|
||||||
|
else
|
||||||
|
mkdir -p "$TMP_MNT/repo"
|
||||||
|
cp -aT "$REPO_ROOT" "$TMP_MNT/repo"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
2)
|
||||||
|
# Run as the invoking user, not root, so ~ resolves correctly.
|
||||||
|
user_home="$(getent passwd "${SUDO_USER:-$USER}" | cut -d: -f6)"
|
||||||
|
if compgen -G "$user_home/.ssh/*.pub" >/dev/null; then
|
||||||
|
mkdir -p "$TMP_MNT/ssh-keys"
|
||||||
|
cp "$user_home"/.ssh/*.pub "$TMP_MNT/ssh-keys/"
|
||||||
|
log "Copied $(ls "$user_home"/.ssh/*.pub | wc -l) public key(s)."
|
||||||
|
else
|
||||||
|
warn "no .pub keys found in $user_home/.ssh"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
3)
|
||||||
|
read -r -e -p "Path to copy: " extra_path
|
||||||
|
extra_path="${extra_path/#\~/$HOME}"
|
||||||
|
if [[ -e "$extra_path" ]]; then
|
||||||
|
cp -a "$extra_path" "$TMP_MNT/"
|
||||||
|
log "Copied $extra_path"
|
||||||
|
else
|
||||||
|
warn "not found: $extra_path"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
4)
|
||||||
|
info "Type your note. End with a line containing only EOF:"
|
||||||
|
: >"$TMP_MNT/NOTE.txt"
|
||||||
|
while IFS= read -r line; do
|
||||||
|
[[ "$line" == "EOF" ]] && break
|
||||||
|
printf '%s\n' "$line" >>"$TMP_MNT/NOTE.txt"
|
||||||
|
done
|
||||||
|
;;
|
||||||
|
*) warn "unknown option: $tok" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
sync
|
||||||
|
umount "$TMP_MNT"
|
||||||
|
rmdir "$TMP_MNT"
|
||||||
|
TMP_MNT=""
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### Done ###
|
||||||
|
|
||||||
|
log "Final sync..."
|
||||||
|
sync
|
||||||
|
blockdev --flushbufs "$DEVICE" 2>/dev/null || true
|
||||||
|
|
||||||
|
echo
|
||||||
|
log "${BOLD}Done.${RESET} You can unplug $DEVICE now."
|
||||||
|
echo
|
||||||
|
info "Boot the new machine from this USB. Once at the installer shell:"
|
||||||
|
info " - If you copied the repo, it's on the EXTRAS partition (mountable as exFAT)."
|
||||||
|
info " - To install: partition the target disk, mount at /mnt, then"
|
||||||
|
info " nixos-install --flake /mnt/extras/repo#<hostname>"
|
||||||
|
echo
|
||||||
@@ -0,0 +1,325 @@
|
|||||||
|
#!/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
|
||||||
|
|
||||||
|
|
||||||
|
### Color helpers ###
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### Validation ###
|
||||||
|
|
||||||
|
# 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))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### Templates ###
|
||||||
|
|
||||||
|
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.
|
||||||
|
}}
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### Helpers ###
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
# Find `nixosConfigurations = {`; be flexible about whitespace.
|
||||||
|
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())
|
||||||
Reference in New Issue
Block a user