diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..f6423a4 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake "github:JacobPEvans/nix-devenv#nix-bare" diff --git a/.github/scripts/render-mermaid.sh b/.github/scripts/render-mermaid.sh new file mode 100644 index 0000000..621bbac --- /dev/null +++ b/.github/scripts/render-mermaid.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# Re-render every docs/architecture/*.mmd to a sibling .svg using the +# minlag/mermaid-cli docker image. Same image is used in CI; same image is +# used locally; SVG output stays byte-identical so the diff gate is trustable. +set -euo pipefail + +IMAGE="${MERMAID_CLI_IMAGE:-minlag/mermaid-cli:latest}" + +for f in docs/architecture/*.mmd; do + [ -f "$f" ] || continue + echo "rendering $f" + docker run --rm -u "$(id -u):$(id -g)" -v "$PWD:/data" "$IMAGE" \ + -i "/data/$f" -o "/data/${f%.mmd}.svg" --quiet +done diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..75d4ff4 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,40 @@ +# CI — runs on every PR and on pushes to main. +# Validates the flake (nixfmt-rfc-style/statix/deadnix/flake-check). +name: CI + +on: + pull_request: + types: [opened, synchronize, reopened] + push: + branches: [main] + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + nix-check: + name: Nix flake check + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Install Nix + uses: DeterminateSystems/determinate-nix-action@v3 + + - name: Cache Nix store + uses: nix-community/cache-nix-action@v7 + with: + primary-key: nix-${{ runner.os }}-${{ hashFiles('flake.lock') }} + restore-prefixes-first-match: nix-${{ runner.os }}- + gc-max-store-size: 5000000000 + save: ${{ github.ref == 'refs/heads/main' }} + + - name: nix flake check + run: nix flake check --no-build --show-trace diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..5ed5b9e --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,48 @@ +# CodeQL static analysis. +# Skeleton: Nix is not a CodeQL-supported language, but we scan our GitHub +# Actions workflows. When scripts/code in supported languages land later, +# add the language to the matrix below. +name: CodeQL + +on: + pull_request: + branches: [main] + push: + branches: [main] + schedule: + - cron: "13 5 * * 1" + +permissions: + contents: read + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + runs-on: ubuntu-latest + permissions: + security-events: write + packages: read + actions: read + contents: read + + strategy: + fail-fast: false + matrix: + include: + - language: actions + build-mode: none + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + with: + category: "/language:${{ matrix.language }}" diff --git a/.github/workflows/deps-update-flake.yml b/.github/workflows/deps-update-flake.yml new file mode 100644 index 0000000..4361d88 --- /dev/null +++ b/.github/workflows/deps-update-flake.yml @@ -0,0 +1,47 @@ +# Weekly flake input refresh. +# Renovate handles most updates; this workflow exists for the occasional +# `nix flake update` of all inputs at once. +name: Update flake dependencies + +on: + schedule: + - cron: "0 12 * * 1" + workflow_dispatch: {} + +permissions: + contents: read + +jobs: + update: + name: nix flake update + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Install Nix + uses: DeterminateSystems/determinate-nix-action@v3 + + - name: Update flake inputs + run: nix flake update + + - name: Validate flake + run: nix flake check --no-build --show-trace + + - name: Create Pull Request + uses: peter-evans/create-pull-request@v8 + env: + EVENT_NAME: ${{ github.event_name }} + with: + branch: chore/flake-update + delete-branch: true + commit-message: "chore(deps): update flake inputs" + title: "chore(deps): update flake inputs" + body: | + Automated weekly `nix flake update`. + + Triggered by ${{ env.EVENT_NAME }}. + labels: dependencies diff --git a/.github/workflows/mermaid-render-check.yml b/.github/workflows/mermaid-render-check.yml new file mode 100644 index 0000000..19b585b --- /dev/null +++ b/.github/workflows/mermaid-render-check.yml @@ -0,0 +1,35 @@ +# Mermaid render-diff gate. +# Re-renders every docs/architecture/*.mmd to .svg and fails the PR if the +# committed .svg does not match the freshly-rendered output. Per dryvist +# convention (PR #620 — Mermaid sources committed alongside rendered SVGs). +# +# Both local renders and CI renders use the same minlag/mermaid-cli docker +# image, so the byte-level diff is meaningful (no nix-vs-npm version skew). +name: Mermaid render check + +on: + pull_request: + paths: + - "docs/architecture/**.mmd" + - "docs/architecture/**.svg" + - ".github/scripts/render-mermaid.sh" + - ".github/workflows/mermaid-render-check.yml" + +permissions: + contents: read + +jobs: + render: + name: Re-render mermaid sources + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Re-render every .mmd source + run: bash .github/scripts/render-mermaid.sh + + - name: Fail on diff + run: git diff --exit-code -- 'docs/architecture/*.svg' diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml new file mode 100644 index 0000000..ff1e69d --- /dev/null +++ b/.github/workflows/release-please.yml @@ -0,0 +1,19 @@ +name: Release Please + +on: + push: + branches: [main] + +permissions: {} + +jobs: + release-please: + permissions: + contents: write + pull-requests: write + runs-on: ubuntu-latest + steps: + - uses: googleapis/release-please-action@v4 + with: + config-file: release-please-config.json + manifest-file: .release-please-manifest.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dd29f24 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.direnv/ +result +result-* +*.qcow2 +*.img diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 0000000..e18ee07 --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "0.0.0" +} diff --git a/.sops.yaml b/.sops.yaml new file mode 100644 index 0000000..083eb42 --- /dev/null +++ b/.sops.yaml @@ -0,0 +1,24 @@ +# SOPS rules for nix-pxe-bootstrap. +# +# Maintainer + Bitwarden recipients are PLACEHOLDERS. Real public keys land +# when the host age key is materialized via ssh-to-age (after first boot via +# nixos-anywhere). +# +# To generate the host age key from a host SSH key: +# ssh-keyscan | ssh-to-age +keys: + # Maintainer (operator) age key. Real value lives in ~/.config/sops/age/keys.txt; + # public form below is a placeholder until it is exported and committed. + - &operator age1placeholderoperatorkeyaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + # Bitwarden-backed escrow recipient (offsite recovery copy). + - &bitwarden_escrow age1placeholderbitwardenkeyaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + # Host age key (derived from the pxe-host SSH host key after first boot). + - &host_pxe age1placeholderhostkeyaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + +creation_rules: + - path_regex: ^secrets/.*\.(enc\.)?yaml$ + key_groups: + - age: + - *operator + - *bitwarden_escrow + - *host_pxe diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..a9ce1af --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,39 @@ +# nix-pxe-bootstrap — AI Agent Instructions + +@github:JacobPEvans/ai-assistant-instructions + +## Repo-specific notes + +- **Status: skeleton only.** Module bodies are intentionally `mkEnableOption` + stubs — do not fill them in unless the implementation epic in the dryvist + Project explicitly asks for it. +- **Target hardware is unbound.** `hosts/pxe-host/disko.nix`, + `hosts/pxe-host/networking.nix`, and `hardware-configuration.nix.example` + carry placeholders. Real `by-id` disk paths and static IPs land in a later + PR after the device exists. +- **First boot uses `nixos-anywhere`** from an operator Mac to materialize + `hardware-configuration.nix`. Do not check that file in until after the + initial bootstrap. +- **Answer files in `answer-files/proxmox-{b,c,d}.toml`** use placeholder + hashes / NICs / hostnames. Real values come from the dryvist secrets store + (SOPS-encrypted) and never get committed in plaintext. +- **No custom shell scripts.** Declarative NixOS modules only. If a problem + feels like it needs a script, restate it as a NixOS module option. +- **Answer-file format:** Proxmox auto-installer TOML. ADR-0003 documents + why TOML over JSON or YAML. +- **CI:** mirrors the nix-darwin pattern — `nixfmt-rfc-style`, `statix`, + `deadnix`, `nix flake check`, plus mermaid render-diff gate. + +## Related repos + +See `README.md` ecosystem table. + +## Bring-up workflow (operator) + +1. Acquire hardware (Pi 4/5 or N100/N305 mini-PC). +2. Fill `hosts/pxe-host/{disko,networking}.nix` with real values via PR. +3. Boot the device into a NixOS installer or any Linux live env with SSH. +4. From operator Mac: + `nix run github:nix-community/nixos-anywhere -- --flake .#pxe-host root@` +5. Commit the generated `hardware-configuration.nix` (drop `.example` suffix). +6. Subsequent updates: `nixos-rebuild switch --flake .#pxe-host --target-host root@`. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..63c4f83 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,8 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +This file is managed automatically by +[release-please](https://github.com/googleapis/release-please). +Do not edit manually — make conventional-commit PRs and let the bot update +this file on the release PR. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..5a1f43a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,3 @@ +# AI Agents Configuration + +@AGENTS.md diff --git a/LICENSE b/LICENSE index 37969a2..a64c5b4 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,7 @@ MIT License Copyright (c) 2026 dryvist +Copyright (c) 2026 Jacob P. Evans Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md new file mode 100644 index 0000000..3997e11 --- /dev/null +++ b/README.md @@ -0,0 +1,119 @@ +# nix-pxe-bootstrap + +NixOS flake for a small Pi/MiniPC that provides PXE boot infrastructure for the +dryvist homelab — netboot.xyz boot menu plus an nginx-static server hosting +Proxmox auto-installer ISOs and answer files. + +> **Status: skeleton — implementation TBD.** This repo currently contains only +> module scaffolds (`mkEnableOption` stubs), placeholder hardware/network +> config, and CI plumbing. The functional implementation is tracked as the +> `netboot.xyz on Pi: implementation` epic in the [dryvist Server Infrastructure +> Project](https://github.com/orgs/dryvist/projects). + +## Why this repo exists + +The dryvist Proxmox cluster (B+C+D) is installed unattended via PXE + +[Proxmox auto-installer](https://pve.proxmox.com/wiki/Automated_Installation). +That requires a small always-on host on the management network serving: + +1. **netboot.xyz** — boot menu / iPXE chainload entry for cluster nodes. +2. **Proxmox auto-installer ISOs + answer files** (`proxmox-{b,c,d}.toml`) + over HTTP via nginx. +3. **DHCP `next-server` pointer** — so freshly-racked nodes find the boot + menu without manual intervention. + +This repo MUST exist before server B is physically installed; PXE blocks the B +install per plan decision Q15. + +## Target hardware + +Configurable. Initial design supports either: + +- Raspberry Pi 4/5 (using `nixos-hardware.nixosModules.raspberry-pi-4`) +- Small N100 / N305 mini-PC (generic x86_64 NixOS) + +The actual hardware binding is deferred until the device exists. First boot +runs `nixos-anywhere` from an operator Mac to materialize +`hardware-configuration.nix`. + +## Installation + +The repo currently ships a skeleton only — there is no working installation +flow yet. When implementation lands, install will follow this shape: + +```sh +# Clone +git clone https://github.com/dryvist/nix-pxe-bootstrap.git +cd nix-pxe-bootstrap + +# Fill placeholders +# hosts/pxe-host/disko.nix (real by-id paths) +# hosts/pxe-host/networking.nix (static IP, gateway, DNS) +# hosts/pxe-host/hardware-configuration.nix (generated by nixos-anywhere) + +# Bootstrap the box (Pi or MiniPC) via nixos-anywhere from operator Mac: +nix run github:nix-community/nixos-anywhere -- \ + --flake .#pxe-host root@ +``` + +Operator dev shell (for `nix flake check`, `nix fmt`, etc.): + +```sh +direnv allow # one-time per worktree; loads .envrc +``` + +## Usage + +Once installed, the PXE host runs as an unattended service. Operator-facing +usage is limited to rebuilds and answer-file edits: + +```sh +# Apply config changes from operator Mac: +nixos-rebuild switch --flake .#pxe-host --target-host root@ + +# Edit a Proxmox auto-installer answer file (per-node): +$EDITOR answer-files/proxmox-b.toml +git commit -am "fix(answer-files): adjust proxmox-b NIC mapping" +# Push, merge, then rebuild as above. +``` + +Module flags (all currently stubs) live under `services.dryvist.pxe.*` — +enable them in `hosts/pxe-host/default.nix` once the modules are filled in. + +## Repo layout + +```text +flake.nix # Inputs: nixpkgs/nixos-25.11, nixos-hardware, sops-nix, disko +hosts/pxe-host/ + default.nix # Composes all modules with mkEnableOption + disko.nix # SD card / NVMe placeholder + networking.nix # Static IP / gateway / DNS placeholder + hardware-configuration.nix.example # Real config materialized by nixos-anywhere +modules/ + netbootxyz.nix # netboot.xyz menu service stub + proxmox-auto-installer.nix # Proxmox answer-file server stub + nginx-static.nix # ISO / answer-file static hosting stub +answer-files/ + proxmox-{b,c,d}.toml # Proxmox auto-installer placeholders +secrets/system.enc.yaml # SOPS-encrypted (empty) placeholder +lib/checks.nix # Quality checks (nixfmt-rfc-style/statix/deadnix) +docs/architecture/*.mmd + *.svg # Mermaid sources + rendered SVGs +docs/adr/*.md # Architecture Decision Records +``` + +## Ecosystem + +Part of the dryvist homelab: + +| Repo | Role | +| --- | --- | +| [`dryvist/nix-pxe-bootstrap`](https://github.com/dryvist/nix-pxe-bootstrap) (this) | PXE host (Pi/MiniPC) — netboot.xyz + Proxmox auto-installer | +| [`dryvist/ansible-proxmox-cluster`](https://github.com/dryvist/ansible-proxmox-cluster) | Host config for B+C+D | +| [`dryvist/tofu-proxmox-cluster`](https://github.com/dryvist/tofu-proxmox-cluster) | OpenTofu IaC for VMs/LXCs | +| [`dryvist/ansible-server-apps`](https://github.com/dryvist/ansible-server-apps) | App deployments inside cluster | +| [`dryvist/nix-ai-server`](https://github.com/dryvist/nix-ai-server) | NixOS bare-metal config for AI host A | +| [`dryvist/homelab-schemas`](https://github.com/dryvist/homelab-schemas) | Inventory contract / port constants | + +## License + +MIT — see [LICENSE](./LICENSE). diff --git a/answer-files/proxmox-b.toml b/answer-files/proxmox-b.toml new file mode 100644 index 0000000..fe51d35 --- /dev/null +++ b/answer-files/proxmox-b.toml @@ -0,0 +1,35 @@ +# Proxmox VE auto-installer answer file — node B (PLACEHOLDER). +# +# Real values land when B is physically assembled and the IP plan is +# finalized. See https://pve.proxmox.com/wiki/Automated_Installation for the +# canonical schema. +# +# Anything that looks like a secret here (root hash, ssh keys) is a +# placeholder. Real values must be SOPS-encrypted and templated in at +# render time — never committed in plaintext. + +[global] +keyboard = "en-us" +country = "us" +fqdn = "proxmox-b.example.local" +mailto = "ops@example.com" +timezone = "America/Indiana/Indianapolis" +# Placeholder sha512-crypt ($6$) hash — Proxmox accepts crypt(3) formats. +# Replace via SOPS-templated render at install time. +root_password_hashed = "$6$placeholderplaceholder$placeholderplaceholderplaceholderplaceholderplaceholderplaceholderplaceholderplaceholderplaceholder" +# Placeholder operator pubkey. +root_ssh_keys = ["ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPlaceholderOperatorKeyForProxmoxBPlaceholder operator@example"] + +[network] +source = "from-answer" + +[network.from-answer] +filter.MAC = "AA:BB:CC:DD:EE:01" # placeholder — real MAC from B's NIC +cidr = "192.168.0.21/24" +gateway = "192.168.0.1" +dns = "192.168.0.1" + +[disk-setup] +filesystem = "zfs" +zfs.raid = "raid1" +disk_list = ["sda", "sdb"] # placeholder — replace with by-id paths diff --git a/answer-files/proxmox-c.toml b/answer-files/proxmox-c.toml new file mode 100644 index 0000000..c274918 --- /dev/null +++ b/answer-files/proxmox-c.toml @@ -0,0 +1,25 @@ +# Proxmox VE auto-installer answer file — node C (PLACEHOLDER). +# Same caveats as proxmox-b.toml. C is a Dell R410. + +[global] +keyboard = "en-us" +country = "us" +fqdn = "proxmox-c.example.local" +mailto = "ops@example.com" +timezone = "America/Indiana/Indianapolis" +root_password_hashed = "$6$placeholderplaceholder$placeholderplaceholderplaceholderplaceholderplaceholderplaceholderplaceholderplaceholderplaceholder" +root_ssh_keys = ["ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPlaceholderOperatorKeyForProxmoxCPlaceholder operator@example"] + +[network] +source = "from-answer" + +[network.from-answer] +filter.MAC = "AA:BB:CC:DD:EE:02" # placeholder — real MAC from R410 NIC +cidr = "192.168.0.22/24" +gateway = "192.168.0.1" +dns = "192.168.0.1" + +[disk-setup] +filesystem = "zfs" +zfs.raid = "raid1" +disk_list = ["sda", "sdb"] # placeholder — replace with by-id paths diff --git a/answer-files/proxmox-d.toml b/answer-files/proxmox-d.toml new file mode 100644 index 0000000..e69f02a --- /dev/null +++ b/answer-files/proxmox-d.toml @@ -0,0 +1,25 @@ +# Proxmox VE auto-installer answer file — node D (PLACEHOLDER). +# Same caveats as proxmox-b.toml. D is a Dell R710. + +[global] +keyboard = "en-us" +country = "us" +fqdn = "proxmox-d.example.local" +mailto = "ops@example.com" +timezone = "America/Indiana/Indianapolis" +root_password_hashed = "$6$placeholderplaceholder$placeholderplaceholderplaceholderplaceholderplaceholderplaceholderplaceholderplaceholderplaceholder" +root_ssh_keys = ["ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPlaceholderOperatorKeyForProxmoxDPlaceholder operator@example"] + +[network] +source = "from-answer" + +[network.from-answer] +filter.MAC = "AA:BB:CC:DD:EE:03" # placeholder — real MAC from R710 NIC +cidr = "192.168.0.23/24" +gateway = "192.168.0.1" +dns = "192.168.0.1" + +[disk-setup] +filesystem = "zfs" +zfs.raid = "raid1" +disk_list = ["sda", "sdb"] # placeholder — replace with by-id paths diff --git a/docs/adr/0001-netbootxyz-not-maas.md b/docs/adr/0001-netbootxyz-not-maas.md new file mode 100644 index 0000000..4f8e81c --- /dev/null +++ b/docs/adr/0001-netbootxyz-not-maas.md @@ -0,0 +1,39 @@ +# ADR-0001: netboot.xyz on NixOS, not MAAS or Foreman + +- Status: Accepted +- Date: 2026-05-10 + +## Context + +The dryvist cluster (B+C+D) needs unattended bare-metal install. Three +candidates were considered: + +1. **netboot.xyz** on a small NixOS host — single-purpose iPXE menu. +2. **Canonical MAAS** — full IPAM + DHCP + commissioning + lifecycle. +3. **Foreman / Katello** — full provisioning + config-mgmt host. + +## Decision + +Use **netboot.xyz on NixOS** (this repo). + +## Rationale + +- Scope: PXE is a one-time-per-node activity. We need a boot menu and an + HTTP host for ISOs + answer files — not a full lifecycle / inventory tool. + Tofu + Ansible already own VM/LXC lifecycle and inventory. +- Operational surface: a Pi or N100 mini-PC running NixOS is a single + declarative file we can rebuild from scratch in minutes. MAAS / Foreman + are heavyweight services with their own DBs, upgrades, and failure modes. +- DHCP authority stays on the UDW gateway. We only set `next-server` to the + pxe-host; we are not replacing the LAN DHCP server. +- NixOS gives us reproducible config, sops-encrypted secrets, and a clean + reset path — the same primitives we use for the AI host (server A). + +## Consequences + +- We do NOT get commissioning / hardware enrollment workflows. That's fine; + the cluster is 3 fixed nodes plus rare reformat events. +- Adding a 4th node = 1 PR in this repo (new answer file + DHCP + reservation) + 1 PR in `ansible-proxmox-cluster` + 1 PR in + `tofu-proxmox-cluster`. No MAAS UI dance. +- If we ever outgrow this (10+ heterogeneous nodes), revisit MAAS. diff --git a/docs/adr/0002-nixos-on-pi-rationale.md b/docs/adr/0002-nixos-on-pi-rationale.md new file mode 100644 index 0000000..c2dae13 --- /dev/null +++ b/docs/adr/0002-nixos-on-pi-rationale.md @@ -0,0 +1,39 @@ +# ADR-0002: NixOS on Raspberry Pi (or N100/N305 mini-PC) + +- Status: Accepted +- Date: 2026-05-10 + +## Context + +We need a tiny always-on host on the management VLAN to serve PXE. Options: + +1. **Raspberry Pi 4/5 + NixOS** — quiet, ~5 W idle, off-the-shelf hardware. +2. **N100/N305 mini-PC + NixOS** — slightly more horsepower, x86_64 + (broader nixpkgs / hardware module coverage), still <15 W idle. +3. **LXC on the cluster** — circular dependency (cluster doesn't exist yet + when we need PXE), so rejected outright. +4. **Spare desktop running Linux** — too much idle power, too much + surface area for a tiny role. + +## Decision + +Build the flake to support **both Pi and N100/N305**, default the +`hosts/pxe-host` example to the Pi 4 hardware module, and let the operator +swap modules when the device is bound. + +## Rationale + +- Standalone NixOS deploy via `nixos-anywhere` is identical for either + target — the only diff is the hardware module and the disko layout. +- aarch64-linux is `nixpkgs` first-class; binary cache hits are fine. +- Pi gives us a clean reset story (re-flash an SD card or NVMe HAT). + +## Consequences + +- `nixos-hardware.nixosModules.raspberry-pi-4` is in the flake by default; + swap to a different module (or drop entirely for x86_64) when the + hardware is bound. +- The disko layout is a placeholder until the device exists. +- The flake's default system is `aarch64-linux`. If we choose x86_64, add + `x86_64-linux` to the supported systems list and update + `hardware-configuration.nix` accordingly. diff --git a/docs/adr/0003-answer-file-format.md b/docs/adr/0003-answer-file-format.md new file mode 100644 index 0000000..5a5df83 --- /dev/null +++ b/docs/adr/0003-answer-file-format.md @@ -0,0 +1,32 @@ +# ADR-0003: Proxmox auto-installer answer files in TOML + +- Status: Accepted +- Date: 2026-05-10 + +## Context + +[Proxmox auto-installer](https://pve.proxmox.com/wiki/Automated_Installation) +accepts answer files in TOML, JSON, or YAML. + +## Decision + +Use **TOML** (`answer-files/proxmox-{b,c,d}.toml`). + +## Rationale + +- TOML is the format used in the upstream Proxmox documentation examples, + so copy/paste from official docs works without translation. +- TOML's section-based syntax maps cleanly to the auto-installer's + `[global]` / `[network]` / `[disk-setup]` structure. +- TOML is friendlier than YAML for this use case — no significant + whitespace, no anchor/alias surprises. +- JSON works too, but is harder to skim and lacks comments. + +## Consequences + +- Per-node files live under `answer-files/`, one per cluster node. +- Secrets (root password hashes, operator SSH keys) are SOPS-templated in + at render time — the committed TOML files contain placeholder values + only. See ADR-0004 (future) for the SOPS templating mechanism. +- If Proxmox upstream changes the answer-file schema, this is a single + point of update for all cluster nodes. diff --git a/docs/architecture/ecosystem-context.mmd b/docs/architecture/ecosystem-context.mmd new file mode 100644 index 0000000..e7e21d6 --- /dev/null +++ b/docs/architecture/ecosystem-context.mmd @@ -0,0 +1,20 @@ +--- +title: nix-pxe-bootstrap in the dryvist ecosystem +--- +flowchart TB + subgraph dryvist["dryvist org repos"] + PXE["nix-pxe-bootstrap
(this repo)"] + APC["ansible-proxmox-cluster
host config B+C+D"] + TPC["tofu-proxmox-cluster
VMs/LXCs IaC"] + APP["ansible-server-apps
app deploys"] + AI["nix-ai-server
standalone NixOS host A"] + SCH["homelab-schemas
inventory contract"] + end + + PXE -- "PXE boot + auto-install" --> APC + APC -- "consumes inventory schema" --> SCH + APP -- "consumes inventory schema" --> SCH + TPC -- "emits ansible_inventory.json" --> APC + TPC -- "emits ansible_inventory.json" --> APP + + AI -. "standalone, never joins cluster" .- APC diff --git a/docs/architecture/ecosystem-context.svg b/docs/architecture/ecosystem-context.svg new file mode 100644 index 0000000..5108f01 --- /dev/null +++ b/docs/architecture/ecosystem-context.svg @@ -0,0 +1 @@ +

dryvist org repos

PXE boot + auto-install

consumes inventory schema

consumes inventory schema

emits ansible_inventory.json

emits ansible_inventory.json

standalone, never joins cluster

nix-pxe-bootstrap
(this repo)

ansible-proxmox-cluster
host config B+C+D

homelab-schemas
inventory contract

ansible-server-apps
app deploys

tofu-proxmox-cluster
VMs/LXCs IaC

nix-ai-server
standalone NixOS host A

nix-pxe-bootstrap in the dryvist ecosystem
\ No newline at end of file diff --git a/docs/architecture/network-topology.mmd b/docs/architecture/network-topology.mmd new file mode 100644 index 0000000..b80d6ac --- /dev/null +++ b/docs/architecture/network-topology.mmd @@ -0,0 +1,22 @@ +--- +title: PXE host network topology +--- +flowchart LR + UDW["UDW gateway/controller"] + US16["US-16-150W (1 GbE mgmt)"] + FLEX["Switch Flex 2.5G PoE (cluster fabric)"] + PXE["pxe-host (Pi/MiniPC)
NixOS · netboot.xyz · nginx-static"] + B["proxmox-b"] + C["proxmox-c (R410)"] + D["proxmox-d (R710)"] + + UDW -- "DHCP/DNS, mgmt VLAN" --> US16 + US16 -- "1 GbE mgmt" --> PXE + US16 -- "1 GbE iDRAC/IPMI" --> C + US16 -- "1 GbE iDRAC/IPMI" --> D + PXE -- "TFTP/HTTP boot menu + answer files" --> B + PXE -- "TFTP/HTTP boot menu + answer files" --> C + PXE -- "TFTP/HTTP boot menu + answer files" --> D + FLEX -- "2.5 GbE cluster fabric (post-install)" --> B + FLEX -- "2.5 GbE cluster fabric (post-install)" --> C + FLEX -- "2.5 GbE cluster fabric (post-install)" --> D diff --git a/docs/architecture/network-topology.svg b/docs/architecture/network-topology.svg new file mode 100644 index 0000000..2224e67 --- /dev/null +++ b/docs/architecture/network-topology.svg @@ -0,0 +1 @@ +

DHCP/DNS, mgmt VLAN

1 GbE mgmt

1 GbE iDRAC/IPMI

1 GbE iDRAC/IPMI

TFTP/HTTP boot menu + answer files

TFTP/HTTP boot menu + answer files

TFTP/HTTP boot menu + answer files

2.5 GbE cluster fabric (post-install)

2.5 GbE cluster fabric (post-install)

2.5 GbE cluster fabric (post-install)

UDW gateway/controller

US-16-150W (1 GbE mgmt)

Switch Flex 2.5G PoE (cluster fabric)

pxe-host (Pi/MiniPC)
NixOS · netboot.xyz · nginx-static

proxmox-b

proxmox-c (R410)

proxmox-d (R710)

PXE host network topology
\ No newline at end of file diff --git a/docs/architecture/pxe-boot-flow.mmd b/docs/architecture/pxe-boot-flow.mmd new file mode 100644 index 0000000..6f42c90 --- /dev/null +++ b/docs/architecture/pxe-boot-flow.mmd @@ -0,0 +1,22 @@ +--- +title: PXE boot + Proxmox auto-installer flow +--- +sequenceDiagram + autonumber + participant Node as Cluster node (B/C/D) + participant DHCP as UDW DHCP + participant PXE as pxe-host (NixOS) + participant Repo as nginx-static (ISOs + answer files) + + Node->>DHCP: DHCPDISCOVER (PXE) + DHCP-->>Node: DHCPOFFER + next-server=pxe-host + Node->>PXE: TFTP request iPXE binary + PXE-->>Node: iPXE chainload payload + Node->>PXE: HTTP GET netboot.xyz menu + PXE-->>Node: netboot.xyz menu + Node->>Repo: HTTP GET proxmox-ve.iso + Repo-->>Node: ISO stream + Node->>Repo: HTTP GET proxmox-{b,c,d}.toml (per MAC) + Repo-->>Node: Answer file + Node->>Node: Unattended Proxmox install + Node->>Node: Reboot into installed OS diff --git a/docs/architecture/pxe-boot-flow.svg b/docs/architecture/pxe-boot-flow.svg new file mode 100644 index 0000000..052d5b9 --- /dev/null +++ b/docs/architecture/pxe-boot-flow.svg @@ -0,0 +1 @@ +nginx-static (ISOs + answer files)pxe-host (NixOS)UDW DHCPCluster node (B/C/D)nginx-static (ISOs + answer files)pxe-host (NixOS)UDW DHCPCluster node (B/C/D)DHCPDISCOVER (PXE)1DHCPOFFER + next-server=pxe-host2TFTP request iPXE binary3iPXE chainload payload4HTTP GET netboot.xyz menu5netboot.xyz menu6HTTP GET proxmox-ve.iso7ISO stream8HTTP GET proxmox-{b,c,d}.toml (per MAC)9Answer file10Unattended Proxmox install11Reboot into installed OS12PXE boot + Proxmox auto-installer flow \ No newline at end of file diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..e28f6d3 --- /dev/null +++ b/flake.lock @@ -0,0 +1,86 @@ +{ + "nodes": { + "disko": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1777713215, + "narHash": "sha256-8GzXDOXckDWwST8TY5DbwYFjdvQLlP7K9CLSVx6iTTo=", + "owner": "nix-community", + "repo": "disko", + "rev": "63b4e7e6cf75307c1d26ac3762b886b5b0247267", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "disko", + "type": "github" + } + }, + "nixos-hardware": { + "locked": { + "lastModified": 1778143761, + "narHash": "sha256-lkesY6x2X2qxlqLM7CT2iM/0rP2JB7fruPN3h8POXmI=", + "owner": "NixOS", + "repo": "nixos-hardware", + "rev": "3bcaa367d4c550d687a17ac792fd5cda214ee871", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "master", + "repo": "nixos-hardware", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1778003029, + "narHash": "sha256-q/nkKLDtHIyLjZpKhWk3cSK5IYsFqtMd6UtXF3ddjgA=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "0c88e1f2bdb93d5999019e99cb0e61e1fe2af4c5", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-25.11", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "disko": "disko", + "nixos-hardware": "nixos-hardware", + "nixpkgs": "nixpkgs", + "sops-nix": "sops-nix" + } + }, + "sops-nix": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1777944972, + "narHash": "sha256-VfGRo1qTBKOe3s2gOv8LSoA6Fk19PvBlwQ1ECN0Evn8=", + "owner": "Mic92", + "repo": "sops-nix", + "rev": "c591bf665727040c6cc5cb409079acb22dcce33c", + "type": "github" + }, + "original": { + "owner": "Mic92", + "repo": "sops-nix", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..20a7ca9 --- /dev/null +++ b/flake.nix @@ -0,0 +1,78 @@ +{ + description = "dryvist PXE bootstrap host — netboot.xyz + Proxmox auto-installer (skeleton)"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11"; + + nixos-hardware.url = "github:NixOS/nixos-hardware/master"; + + sops-nix = { + url = "github:Mic92/sops-nix"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + + disko = { + url = "github:nix-community/disko"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + }; + + outputs = + { + self, + nixpkgs, + nixos-hardware, + sops-nix, + disko, + ... + }: + let + # PXE host is small / single-arch; default to aarch64 for Pi but + # leave the door open for x86_64 (N100 / N305 mini-PC). + system = "aarch64-linux"; + pkgs = nixpkgs.legacyPackages.${system}; + + # Quality checks shared with CI (mirrors nix-darwin pattern). + checksFor = + sys: + import ./lib/checks.nix { + pkgs = nixpkgs.legacyPackages.${sys}; + src = ./.; + }; + in + { + nixosConfigurations.pxe-host = nixpkgs.lib.nixosSystem { + inherit system; + specialArgs = { inherit nixos-hardware; }; + modules = [ + # Hardware module — swap raspberry-pi-4 for a different model + # (or drop entirely for x86_64) when target hardware is bound. + nixos-hardware.nixosModules.raspberry-pi-4 + + disko.nixosModules.disko + sops-nix.nixosModules.sops + + ./hosts/pxe-host + ]; + }; + + formatter.${system} = pkgs.nixfmt-rfc-style; + + checks = nixpkgs.lib.genAttrs [ + "aarch64-linux" + "x86_64-linux" + ] checksFor; + + devShells.${system}.default = pkgs.mkShell { + packages = with pkgs; [ + nixfmt-rfc-style + statix + deadnix + sops + age + ssh-to-age + mermaid-cli + ]; + }; + }; +} diff --git a/hosts/pxe-host/default.nix b/hosts/pxe-host/default.nix new file mode 100644 index 0000000..639f728 --- /dev/null +++ b/hosts/pxe-host/default.nix @@ -0,0 +1,37 @@ +# pxe-host — composes all PXE modules with mkEnableOption stubs. +# +# Skeleton: every module flag defaults to false. Real values land when the +# implementation epic in the dryvist Project picks this up. +{ config, lib, ... }: +{ + imports = [ + ./disko.nix + ./networking.nix + # hardware-configuration.nix is generated by nixos-anywhere on first boot. + # Until it exists, we ship hardware-configuration.nix.example as documentation. + # ./hardware-configuration.nix + + ../../modules/netbootxyz.nix + ../../modules/proxmox-auto-installer.nix + ../../modules/nginx-static.nix + ]; + + # All PXE-related options live under services.dryvist.pxe.* (skeleton stubs). + # Flip these to true once the corresponding module bodies are implemented. + services.dryvist.pxe = { + netbootxyz.enable = lib.mkDefault false; + proxmoxAutoInstaller.enable = lib.mkDefault false; + nginxStatic.enable = lib.mkDefault false; + }; + + # System identity placeholders — overridden once hardware is bound. + networking.hostName = lib.mkDefault "pxe-host"; + + # Conservative state version pinned to the nixpkgs branch we follow. + system.stateVersion = "25.11"; + + # Reference config to avoid an unused-arg warning before module bodies land. + _module.args.dryvistPxeHost = { + inherit (config.networking) hostName; + }; +} diff --git a/hosts/pxe-host/disko.nix b/hosts/pxe-host/disko.nix new file mode 100644 index 0000000..cd929eb --- /dev/null +++ b/hosts/pxe-host/disko.nix @@ -0,0 +1,45 @@ +# Disk layout — PLACEHOLDER. +# +# The real layout depends on the chosen target hardware: +# - Raspberry Pi 4/5: SD card or USB-attached SSD/NVMe (single device). +# - N100/N305 mini-PC: NVMe (single device, GPT, ESP + ext4 root or btrfs). +# +# Real /dev/disk/by-id/* paths land here in a follow-up PR after the device +# exists. Do NOT hard-code /dev/sda or /dev/nvme0n1 — by-id paths only. +# +# This file currently sets `disko.devices = {}` so `nix flake check` evaluates +# without trying to assemble a fake disk layout. The implementation epic in +# the dryvist Project will replace this with a real layout. +_: +{ + disko.devices = { + # Example layout to fill in later — kept here as documentation: + # + # disk.main = { + # type = "disk"; + # device = "/dev/disk/by-id/"; + # content = { + # type = "gpt"; + # partitions = { + # ESP = { + # size = "512M"; + # type = "EF00"; + # content = { + # type = "filesystem"; + # format = "vfat"; + # mountpoint = "/boot"; + # }; + # }; + # root = { + # size = "100%"; + # content = { + # type = "filesystem"; + # format = "ext4"; + # mountpoint = "/"; + # }; + # }; + # }; + # }; + # }; + }; +} diff --git a/hosts/pxe-host/hardware-configuration.nix.example b/hosts/pxe-host/hardware-configuration.nix.example new file mode 100644 index 0000000..7dc50d7 --- /dev/null +++ b/hosts/pxe-host/hardware-configuration.nix.example @@ -0,0 +1,31 @@ +# EXAMPLE ONLY — do not import as-is. +# +# The real hardware-configuration.nix is generated by `nixos-anywhere` on +# first boot against the actual target hardware (Pi 4/5 SoC or N100/N305 +# mini-PC). After bootstrap, copy the generated file into this directory +# (drop the `.example` suffix) and add the import to `hosts/pxe-host/default.nix`. +# +# Generate from the device: +# +# nix run github:nix-community/nixos-anywhere -- \ +# --generate-hardware-config nixos-generate-config \ +# hosts/pxe-host/hardware-configuration.nix \ +# root@ +# +# Until then, this file is documentation. It MUST NOT be imported because +# the device-specific kernel modules / fileSystems entries do not yet exist. +{ + # Placeholder skeleton — the real generated file is much larger. + imports = [ ]; + boot.initrd.availableKernelModules = [ ]; + boot.initrd.kernelModules = [ ]; + boot.kernelModules = [ ]; + boot.extraModulePackages = [ ]; + + fileSystems = { }; + swapDevices = [ ]; + + # nixos-anywhere fills these in based on actual hardware. + nixpkgs.hostPlatform = "aarch64-linux"; + hardware.enableRedistributableFirmware = true; +} diff --git a/hosts/pxe-host/networking.nix b/hosts/pxe-host/networking.nix new file mode 100644 index 0000000..3ddf371 --- /dev/null +++ b/hosts/pxe-host/networking.nix @@ -0,0 +1,31 @@ +# Networking — PLACEHOLDER. +# +# Real values land in a follow-up PR after the IP plan for the cluster mgmt +# VLAN is finalized. Today this file only declares the SHAPE of the config so +# evaluators can see what knobs exist. +{ lib, ... }: +{ + networking = { + # Placeholder static IP on the management VLAN — replace before deploy. + interfaces.eth0.ipv4.addresses = lib.mkDefault [ + { + address = "192.168.0.10"; + prefixLength = 24; + } + ]; + + defaultGateway = lib.mkDefault { + address = "192.168.0.1"; + interface = "eth0"; + }; + + nameservers = lib.mkDefault [ + "192.168.0.1" + ]; + + # PXE host serves on UDP/69 (TFTP) + TCP/80 (HTTP, nginx-static). + # Firewall rules will be enabled by the netbootxyz / nginx-static modules + # once their bodies are implemented. + firewall.enable = lib.mkDefault true; + }; +} diff --git a/lib/checks.nix b/lib/checks.nix new file mode 100644 index 0000000..6f85f3d --- /dev/null +++ b/lib/checks.nix @@ -0,0 +1,36 @@ +# Quality checks - mirrors the nix-darwin pattern. +# Single source of truth for pre-commit hooks and GitHub Actions. +{ + pkgs, + src, +}: +{ + # Format check via nixfmt-rfc-style. + formatting = + pkgs.runCommand "check-formatting" + { + nativeBuildInputs = [ pkgs.nixfmt-rfc-style ]; + } + '' + cp -r ${src} $TMPDIR/src + chmod -R u+w $TMPDIR/src + cd $TMPDIR/src + ${pkgs.lib.getExe pkgs.nixfmt-rfc-style} --check $(find . -name '*.nix' -not -path './.direnv/*') + touch $out + ''; + + # Lint Nix files for anti-patterns. + statix = pkgs.runCommand "check-statix" { } '' + cd ${src} + ${pkgs.lib.getExe pkgs.statix} check . + touch $out + ''; + + # Detect dead Nix bindings. + # -L ignores common lambda parameter names (config, lib, pkgs). + deadnix = pkgs.runCommand "check-deadnix" { } '' + cd ${src} + ${pkgs.lib.getExe pkgs.deadnix} -L --fail . + touch $out + ''; +} diff --git a/modules/netbootxyz.nix b/modules/netbootxyz.nix new file mode 100644 index 0000000..833cc73 --- /dev/null +++ b/modules/netbootxyz.nix @@ -0,0 +1,23 @@ +# netboot.xyz — boot menu / iPXE chainload entry for cluster nodes. +# +# Skeleton: only declares the option surface. Implementation lands in the +# `netboot.xyz on Pi: implementation` epic in the dryvist Project. +{ config, lib, ... }: +let + cfg = config.services.dryvist.pxe.netbootxyz; +in +{ + options.services.dryvist.pxe.netbootxyz = { + enable = lib.mkEnableOption "netboot.xyz boot menu / iPXE chainload"; + + # Future option surface (unused while enable = false): + # tftpRoot = lib.mkOption { type = lib.types.path; default = "/srv/tftp"; }; + # menuUrl = lib.mkOption { type = lib.types.str; default = "https://boot.netboot.xyz"; }; + # bindInterface = lib.mkOption { type = lib.types.str; default = "eth0"; }; + }; + + config = lib.mkIf cfg.enable { + # Implementation TBD. This block is intentionally empty so the module + # evaluates as a no-op until the epic picks it up. + }; +} diff --git a/modules/nginx-static.nix b/modules/nginx-static.nix new file mode 100644 index 0000000..c9164d8 --- /dev/null +++ b/modules/nginx-static.nix @@ -0,0 +1,23 @@ +# nginx-static — static HTTP host for Proxmox auto-installer ISOs and other +# PXE-time payloads. +# +# Skeleton: only declares the option surface. Implementation lands in the +# `netboot.xyz on Pi: implementation` epic in the dryvist Project. +{ config, lib, ... }: +let + cfg = config.services.dryvist.pxe.nginxStatic; +in +{ + options.services.dryvist.pxe.nginxStatic = { + enable = lib.mkEnableOption "nginx static file host for PXE-time payloads"; + + # Future option surface (unused while enable = false): + # isoPath = lib.mkOption { type = lib.types.path; default = "/srv/pxe/iso"; }; + # listenPort = lib.mkOption { type = lib.types.port; default = 80; }; + # hostName = lib.mkOption { type = lib.types.str; default = "pxe.example.local"; }; + }; + + config = lib.mkIf cfg.enable { + # Implementation TBD. + }; +} diff --git a/modules/proxmox-auto-installer.nix b/modules/proxmox-auto-installer.nix new file mode 100644 index 0000000..e289b32 --- /dev/null +++ b/modules/proxmox-auto-installer.nix @@ -0,0 +1,28 @@ +# Proxmox auto-installer answer-file server. +# +# Skeleton: only declares the option surface. Implementation lands in the +# `netboot.xyz on Pi: implementation` epic in the dryvist Project. +# +# Behavior (when implemented): +# - Serve the per-node answer files from `answer-files/proxmox-{b,c,d}.toml` +# over HTTP at a stable URL the Proxmox auto-installer ISO can fetch. +# - Fingerprint URL by node MAC or DHCP option so each node gets the right +# answer file without manual intervention. +{ config, lib, ... }: +let + cfg = config.services.dryvist.pxe.proxmoxAutoInstaller; +in +{ + options.services.dryvist.pxe.proxmoxAutoInstaller = { + enable = lib.mkEnableOption "Proxmox auto-installer answer-file HTTP server"; + + # Future option surface (unused while enable = false): + # answerFilesPath = lib.mkOption { type = lib.types.path; default = ../../answer-files; }; + # nodes = lib.mkOption { type = lib.types.listOf lib.types.str; default = [ "b" "c" "d" ]; }; + # listenPort = lib.mkOption { type = lib.types.port; default = 8000; }; + }; + + config = lib.mkIf cfg.enable { + # Implementation TBD. + }; +} diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 0000000..083d489 --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "release-type": "simple", + "include-component-in-tag": false, + "include-v-in-tag": true, + "bump-minor-pre-major": true, + "bump-patch-for-minor-pre-major": true, + "packages": { + ".": { + "package-name": "nix-pxe-bootstrap", + "changelog-path": "CHANGELOG.md" + } + } +} diff --git a/renovate.json5 b/renovate.json5 new file mode 100644 index 0000000..4ef2951 --- /dev/null +++ b/renovate.json5 @@ -0,0 +1,16 @@ +{ + $schema: "https://docs.renovatebot.com/renovate-schema.json", + extends: [ + "github>JacobPEvans/.github//renovate-presets.json", + ], + // Daily nix flake input updates handled by deps-update-flake.yml workflow. + // Renovate covers GitHub Actions pins and other ecosystem deps here. + packageRules: [ + { + // Group GitHub Actions weekly so we don't get flooded. + matchManagers: ["github-actions"], + groupName: "github-actions", + schedule: ["before 9am on monday"], + }, + ], +} diff --git a/secrets/system.enc.yaml b/secrets/system.enc.yaml new file mode 100644 index 0000000..057287f --- /dev/null +++ b/secrets/system.enc.yaml @@ -0,0 +1,15 @@ +# SOPS-encrypted secrets for the pxe-host system. +# +# PLACEHOLDER. This file is intentionally an empty (unencrypted) YAML stub +# until real age public keys exist in `.sops.yaml`. Once those land, run: +# +# sops --encrypt --in-place secrets/system.enc.yaml +# +# Add real key/value pairs above before the encrypt step. Keys to expect: +# - root_password_hashed (used by templated answer-files) +# - operator_ssh_pubkey +# +# DO NOT commit plaintext secrets here. The placeholder values below are +# explicitly fake. + +placeholder: "replace-with-real-encrypted-content"