From 1de34c18697425be2b07ed456f9b50fc9f5512e9 Mon Sep 17 00:00:00 2001 From: eric Date: Wed, 18 Mar 2026 17:41:10 +0100 Subject: [PATCH] feat: move things around --- README.md | 415 +---- flake.lock | 24 +- flake.nix | 114 +- hosts/lab/configuration.nix | 30 - hosts/lab/disko.nix | 47 - hosts/lab/hardware-configuration.nix | 5 - hosts/vps1/configuration.nix | 28 - hosts/vps1/disko.nix | 46 - hosts/vps1/hardware-configuration.nix | 10 - modules/helpers/home.nix | 7 +- modules/home.nix | 1 + modules/nixos/common.nix | 101 -- modules/nixos/tailscale-init.nix | 155 -- pkgs/helpers/__pycache__/cli.cpython-313.pyc | Bin 78473 -> 0 bytes pkgs/helpers/cli.py | 1498 ----------------- pkgs/helpers/default.nix | 32 - pkgs/helpers/templates/configuration.nix.tmpl | 23 - pkgs/helpers/templates/disko-bios-ext4.nix | 41 - pkgs/helpers/templates/disko-uefi-ext4.nix | 47 - .../hardware-configuration.placeholder.nix | 5 - .../helpers/templates/openbao-policy.hcl.tmpl | 3 - pkgs/helpers/tests/test_cli.py | 114 -- 22 files changed, 54 insertions(+), 2692 deletions(-) delete mode 100644 hosts/lab/configuration.nix delete mode 100644 hosts/lab/disko.nix delete mode 100644 hosts/lab/hardware-configuration.nix delete mode 100644 hosts/vps1/configuration.nix delete mode 100644 hosts/vps1/disko.nix delete mode 100644 hosts/vps1/hardware-configuration.nix delete mode 100644 modules/nixos/common.nix delete mode 100644 modules/nixos/tailscale-init.nix delete mode 100644 pkgs/helpers/__pycache__/cli.cpython-313.pyc delete mode 100644 pkgs/helpers/cli.py delete mode 100644 pkgs/helpers/default.nix delete mode 100644 pkgs/helpers/templates/configuration.nix.tmpl delete mode 100644 pkgs/helpers/templates/disko-bios-ext4.nix delete mode 100644 pkgs/helpers/templates/disko-uefi-ext4.nix delete mode 100644 pkgs/helpers/templates/hardware-configuration.placeholder.nix delete mode 100644 pkgs/helpers/templates/openbao-policy.hcl.tmpl delete mode 100644 pkgs/helpers/tests/test_cli.py diff --git a/README.md b/README.md index 6ef7e44..fb4f94e 100644 --- a/README.md +++ b/README.md @@ -1,414 +1,35 @@ # nix-nodeiwest -NixOS flake for NodeiWest VPS provisioning and ongoing deployment. +Employee and workstation flake for NodeiWest. -This repo currently provisions NixOS hosts with: +Server deployment moved to the sibling repo `../nix-deployment`. -- the `nodeiwest` employee helper CLI for safe provisioning -- shared base config in `modules/nixos/common.nix` -- Tailscale bootstrap via OpenBao AppRole in `modules/nixos/tailscale-init.nix` -- Home Manager profile in `modules/home.nix` -- disk partitioning via `disko` -- deployment via `colmena` +This repo now owns: -## Current Model +- shared Home Manager modules +- employee shell packages and environment variables +- workstation-side access to the `nodeiwest` helper by consuming it from `../nix-deployment` -- Employees should use `nodeiwest` as the supported provisioning interface -- New machines are installed with `nixos-anywhere` -- Ongoing changes are deployed with `colmena` -- Hosts authenticate to OpenBao as clients -- Tailscale auth keys are fetched from OpenBao namespace `it`, KV mount `kv`, path `tailscale`, field `CLIENT_SECRET` -- Public SSH must work independently of Tailscale for first access and recovery +This repo no longer owns: -## Repo Layout +- NixOS server host definitions +- Colmena deployment state +- Tailscale server bootstrap +- k3s bootstrap +- OpenBao server or Kubernetes infra manifests -```text -flake.nix -hosts/ - vps[X]/ - configuration.nix - disko.nix - hardware-configuration.nix -modules/ - home.nix - helpers/ - home.nix - nixos/ - common.nix - tailscale-init.nix -pkgs/ - helpers/ - cli.py - templates/ -``` +## Helper Consumption -## Recommended Workflow - -The supported employee path is the `nodeiwest` CLI. - -It is exported from the root flake as `.#nodeiwest-helper` and installed by the shared Home Manager profile. You can also run it ad hoc with: +The helper package is re-exported from the deployment repo: ```bash nix run .#nodeiwest-helper -- --help ``` -Recommended sequence for a new VPS: - -### 1. Probe The Live Host - -```bash -nodeiwest host probe --ip -``` - -This validates SSH reachability and derives the boot mode, root device, primary disk candidate, and swap facts from the live machine. - -### 2. Scaffold The Host Files - -Dry-run first: - -```bash -nodeiwest host init --name --ip -``` - -Write after reviewing the plan: - -```bash -nodeiwest host init --name --ip --apply -``` - -This command: - -- probes the host unless you override disk or boot mode -- creates or updates `hosts//configuration.nix` -- creates or updates `hosts//disko.nix` -- creates `hosts//hardware-configuration.nix` as a placeholder if needed -- prints the exact `flake.nix` snippets still required for `nixosConfigurations` and `colmena` - -### 3. Create The OpenBao Bootstrap Material - -Dry-run first: - -```bash -nodeiwest openbao init-host --name -``` - -Apply after reviewing the policy and AppRole plan: - -```bash -nodeiwest openbao init-host --name --apply -``` - -This verifies your existing `bao` login, creates the host policy and AppRole, and writes: - -- `bootstrap/var/lib/nodeiwest/openbao-approle-role-id` -- `bootstrap/var/lib/nodeiwest/openbao-approle-secret-id` - -### 4. Plan Or Run The Install - -```bash -nodeiwest install plan --name -nodeiwest install run --name --apply -``` - -`install plan` validates the generated host files and bootstrap files, then prints the exact `nixos-anywhere` command. `install run` re-validates, asks for confirmation, and executes that command. - -### 5. Verify First Boot And Colmena Readiness - -```bash -nodeiwest verify host --name --ip -nodeiwest colmena plan --name -``` - -`verify host` summarizes the first-boot OpenBao and Tailscale services over SSH. `colmena plan` confirms the deploy target or prints the exact missing host stanza. - -## Manual Flow (Fallback / Advanced) - -This is the underlying sequence that `nodeiwest` automates. Keep it as the fallback path for unsupported host layouts or when you intentionally want to run the raw commands yourself. - -### 1. Prepare The Host Entry - -Create a new directory under `hosts//` with: - -- `configuration.nix` -- `disko.nix` -- `hardware-configuration.nix` - -`configuration.nix` should import both `disko.nix` and `hardware-configuration.nix`. - -Example: +If you import `modules/helpers/home.nix` directly, pass the deployment flake as a special arg: ```nix -{ lib, ... }: -{ - imports = [ - ./disko.nix - ./hardware-configuration.nix - ]; - - networking.hostName = "vps1"; - networking.useDHCP = lib.mkDefault true; - - time.timeZone = "UTC"; - - boot.loader.efi.canTouchEfiVariables = true; - boot.loader.grub = { - enable = true; - efiSupport = true; - device = "nodev"; - }; - - nodeiwest.ssh.userCAPublicKeys = [ - "ssh-ed25519 AAAA... openbao-user-ca" - ]; - - nodeiwest.tailscale.openbao.enable = true; - - system.stateVersion = "25.05"; -} +extraSpecialArgs = { + deployment = inputs.deployment; +}; ``` - -### 2. Add The Host To `flake.nix` - -Add the host to: - -- `nixosConfigurations` -- `colmena` - -For `colmena`, set: - -- `deployment.targetHost` -- `deployment.targetUser = "root"` -- tags as needed - -## Discover Disk And Boot Facts - -Before writing `disko.nix`, inspect the current VPS over SSH: - -```bash -ssh root@ 'lsblk -o NAME,SIZE,TYPE,MODEL,FSTYPE,PTTYPE,MOUNTPOINTS' -ssh root@ 'test -d /sys/firmware/efi && echo UEFI || echo BIOS' -ssh root@ 'findmnt -no SOURCE /' -ssh root@ 'cat /proc/swaps' -``` - -Use that output to decide: - -- disk device name: `/dev/sda`, `/dev/vda`, `/dev/nvme0n1`, etc. -- boot mode: UEFI or BIOS -- partition layout you want `disko` to create - -`hosts/vps1/disko.nix` currently assumes: - -- GPT -- `/dev/sda` -- UEFI -- ext4 root -- swap partition - -Do not install blindly if those assumptions are wrong. - -## Generate `hardware-configuration.nix` - -`hardware-configuration.nix` is generated during install with `nixos-anywhere`. - -The repo path is passed directly to the install command: - -```bash ---generate-hardware-config nixos-generate-config ./hosts//hardware-configuration.nix -``` - -That generated file should remain tracked in Git after install. - -## OpenBao Setup For Tailscale - -Each host gets its own AppRole. - -The host uses: - -- OpenBao address: `https://secrets.api.nodeiwest.se` -- namespace: `it` -- KV mount: `kv` -- auth mount: `auth/approle` -- secret path: `tailscale` -- field: `CLIENT_SECRET` - -The host stores: - -- `/var/lib/nodeiwest/openbao-approle-role-id` -- `/var/lib/nodeiwest/openbao-approle-secret-id` - -The rendered Tailscale auth key lives at: - -- `/run/nodeiwest/tailscale-auth-key` - -### Create A Policy - -Create a minimal read-only policy for the Tailscale secret. - -If the secret is accessible as: - -```bash -BAO_NAMESPACE=it bao kv get -mount=kv tailscale -``` - -then create the matching read policy for that mount. - -Example shape for the KV v2 mount `kv`: - -```hcl -path "kv/data/tailscale" { - capabilities = ["read"] -} -``` - -Write it from your machine: - -```bash -export BAO_ADDR=https://secrets.api.nodeiwest.se -export BAO_NAMESPACE=it - -bao policy write tailscale-vps1 ./tailscale-vps1-policy.hcl -``` - -Adjust the path to match your actual OpenBao KV mount. - -### Create The AppRole - -Create one AppRole per host. - -Example for `vps1`: - -```bash -bao write auth/approle/role/tailscale-vps1 \ - token_policies=tailscale-vps1 \ - token_ttl=1h \ - token_max_ttl=24h \ - token_num_uses=0 \ - secret_id_num_uses=0 -``` - -### Generate Bootstrap Credentials - -Create a temporary bootstrap directory on your machine: - -```bash -mkdir -p bootstrap/var/lib/nodeiwest -``` - -Write the AppRole credentials into it: - -```bash -bao read -field=role_id auth/approle/role/tailscale-vps1/role-id \ - > bootstrap/var/lib/nodeiwest/openbao-approle-role-id - -bao write -f -field=secret_id auth/approle/role/tailscale-vps1/secret-id \ - > bootstrap/var/lib/nodeiwest/openbao-approle-secret-id - -chmod 0400 bootstrap/var/lib/nodeiwest/openbao-approle-role-id -chmod 0400 bootstrap/var/lib/nodeiwest/openbao-approle-secret-id -``` - -These files are install-time bootstrap material. They are not stored in Git. - -## Install With `nixos-anywhere` - -Install from your machine: - -```bash -nix run github:nix-community/nixos-anywhere -- \ - --extra-files ./bootstrap \ - --copy-host-keys \ - --generate-hardware-config nixos-generate-config ./hosts/vps1/hardware-configuration.nix \ - --flake .#vps1 \ - root@100.101.167.118 -``` - -What this does: - -- wipes the target disk according to `hosts/vps1/disko.nix` -- installs NixOS with `.#vps1` -- copies the AppRole bootstrap files into `/var/lib/nodeiwest` -- generates `hosts/vps1/hardware-configuration.nix` - -Important: - -- this destroys the existing OS on the target -- take provider snapshots and application backups first -- the target SSH host keys may change after install - -## First Boot Behavior - -On first boot: - -1. `vault-agent-tailscale.service` starts using `pkgs.openbao` -2. it authenticates to OpenBao with AppRole -3. it renders `CLIENT_SECRET` from namespace `it`, KV mount `kv`, path `tailscale` to `/run/nodeiwest/tailscale-auth-key` -4. `nodeiwest-tailscale-authkey-ready.service` waits until that file exists -5. `tailscaled-autoconnect.service` uses that file and runs `tailscale up --ssh` - -Public SSH remains the recovery path if OpenBao or Tailscale bootstrap fails. - -## Verify After Install - -SSH to the host over the public IP first. - -Check: - -```bash -systemctl status vault-agent-tailscale -systemctl status nodeiwest-tailscale-authkey-ready -systemctl status tailscaled-autoconnect - -ls -l /var/lib/nodeiwest -ls -l /run/nodeiwest/tailscale-auth-key - -tailscale status -``` - -If Tailscale bootstrap fails, inspect logs: - -```bash -journalctl -u vault-agent-tailscale -b -journalctl -u nodeiwest-tailscale-authkey-ready -b -journalctl -u tailscaled-autoconnect -b -``` - -Typical causes: - -- wrong AppRole credentials -- wrong OpenBao policy -- wrong secret path -- wrong KV mount path -- `CLIENT_SECRET` field missing in the secret - -## Deploy Changes After Install - -Once the host is installed and reachable, use Colmena: - -```bash -nix run .#colmena -- apply --on vps1 -``` - -## Rotating The AppRole SecretID - -To rotate the machine credential: - -1. generate a new `secret_id` from your machine -2. replace `/var/lib/nodeiwest/openbao-approle-secret-id` on the host -3. restart the agent - -Example: - -```bash -bao write -f -field=secret_id auth/approle/role/tailscale-vps1/secret-id > new-secret-id -scp new-secret-id root@100.101.167.118:/var/lib/nodeiwest/openbao-approle-secret-id -ssh root@100.101.167.118 'chmod 0400 /var/lib/nodeiwest/openbao-approle-secret-id && systemctl restart vault-agent-tailscale tailscaled-autoconnect' -rm -f new-secret-id -``` - -## Recovery Notes - -- Tailscale is additive. It should not be your only access path. -- Public SSH on port `22` must remain available for first access and recovery. -- OpenBao SSH CA auth is separate from Tailscale bootstrap. -- If a machine fails to join the tailnet, recover via public SSH or provider console. diff --git a/flake.lock b/flake.lock index b25545f..258b093 100644 --- a/flake.lock +++ b/flake.lock @@ -22,9 +22,29 @@ "type": "github" } }, + "deployment": { + "inputs": { + "colmena": "colmena", + "disko": "disko", + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 0, + "narHash": "sha256-BW+YgPQb2t5davyiQ6gb4sIbBdIL72jCaLGiehkGT9U=", + "type": "git", + "url": "file:../nix-deployment" + }, + "original": { + "type": "git", + "url": "file:../nix-deployment" + } + }, "disko": { "inputs": { "nixpkgs": [ + "deployment", "nixpkgs" ] }, @@ -96,6 +116,7 @@ "nix-github-actions": { "inputs": { "nixpkgs": [ + "deployment", "colmena", "nixpkgs" ] @@ -148,8 +169,7 @@ }, "root": { "inputs": { - "colmena": "colmena", - "disko": "disko", + "deployment": "deployment", "home-manager": "home-manager", "nixpkgs": "nixpkgs_2" } diff --git a/flake.nix b/flake.nix index 068e9db..f0430a4 100644 --- a/flake.nix +++ b/flake.nix @@ -1,26 +1,23 @@ { - description = "NodeiWest company flake"; + description = "NodeiWest employee and workstation flake"; inputs = { nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; - colmena.url = "github:zhaofengli/colmena"; - disko = { - url = "github:nix-community/disko"; - inputs.nixpkgs.follows = "nixpkgs"; - }; home-manager = { url = "github:nix-community/home-manager"; inputs.nixpkgs.follows = "nixpkgs"; }; + deployment = { + url = "git+file:../nix-deployment"; + inputs.nixpkgs.follows = "nixpkgs"; + }; }; outputs = inputs@{ self, nixpkgs, - colmena, - disko, - home-manager, + deployment, ... }: let @@ -31,111 +28,22 @@ "x86_64-linux" ]; forAllSystems = lib.genAttrs supportedSystems; - - mkPkgs = - system: - import nixpkgs { - inherit system; - }; - - mkHost = - name: - nixpkgs.lib.nixosSystem { - system = "x86_64-linux"; - specialArgs = { - inherit inputs self; - }; - modules = [ - disko.nixosModules.disko - home-manager.nixosModules.home-manager - self.nixosModules.common - ./hosts/${name}/configuration.nix - ]; - }; in { homeManagerModules.default = ./modules/home.nix; homeManagerModules.helpers = ./modules/helpers/home.nix; - nixosModules.common = ./modules/nixos/common.nix; - packages = forAllSystems ( - system: - let - pkgs = mkPkgs system; - nodeiwestHelper = pkgs.callPackage ./pkgs/helpers { }; - in - { - colmena = colmena.packages.${system}.colmena; - nodeiwest-helper = nodeiwestHelper; - default = colmena.packages.${system}.colmena; - } - ); + packages = forAllSystems (system: { + nodeiwest-helper = deployment.packages.${system}.nodeiwest-helper; + default = self.packages.${system}.nodeiwest-helper; + }); apps = forAllSystems (system: { - colmena = { - type = "app"; - program = "${colmena.packages.${system}.colmena}/bin/colmena"; - }; nodeiwest-helper = { type = "app"; program = "${self.packages.${system}.nodeiwest-helper}/bin/nodeiwest"; }; - default = self.apps.${system}.colmena; + default = self.apps.${system}.nodeiwest-helper; }); - - nixosConfigurations = { - vps1 = mkHost "vps1"; - lab = mkHost "lab"; - }; - - colmena = { - meta = { - nixpkgs = mkPkgs "x86_64-linux"; - specialArgs = { - inherit inputs self; - }; - }; - - defaults = - { name, ... }: - { - networking.hostName = name; - imports = [ - disko.nixosModules.disko - home-manager.nixosModules.home-manager - self.nixosModules.common - ]; - }; - - vps1 = { - deployment = { - targetHost = "100.101.167.118"; - targetUser = "root"; - tags = [ - "company" - "edge" - ]; - }; - - imports = [ ./hosts/vps1/configuration.nix ]; - }; - - lab = { - deployment = { - targetHost = "100.101.167.118"; - targetUser = "root"; - tags = [ - "company" - "manager" - ]; - - }; - - imports = [ ./hosts/lab/configuration.nix ]; - - }; - }; - - colmenaHive = colmena.lib.makeHive self.outputs.colmena; }; } diff --git a/hosts/lab/configuration.nix b/hosts/lab/configuration.nix deleted file mode 100644 index f0de599..0000000 --- a/hosts/lab/configuration.nix +++ /dev/null @@ -1,30 +0,0 @@ -{ lib, ... }: -{ - # Generated by nodeiwest host init. - imports = [ - ./disko.nix - ./hardware-configuration.nix - ]; - - networking.hostName = "lab"; - networking.useDHCP = lib.mkDefault true; - - time.timeZone = "UTC"; - - boot.loader.efi.canTouchEfiVariables = true; - boot.loader.grub = { - enable = true; - efiSupport = true; - device = "nodev"; - }; - - nodeiwest.ssh.userCAPublicKeys = [ - "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIE6c2oMkM7lLg9qWHVgbrFaFBDrrFyynFlPviiydQdFi openbao-user-ca" - ]; - - nodeiwest.tailscale.openbao = { - enable = true; - }; - - system.stateVersion = "25.05"; -} diff --git a/hosts/lab/disko.nix b/hosts/lab/disko.nix deleted file mode 100644 index d703537..0000000 --- a/hosts/lab/disko.nix +++ /dev/null @@ -1,47 +0,0 @@ -{ - lib, - ... -}: -{ - # Generated by nodeiwest host init. - # Replace the disk only if the provider exposes a different primary device. - disko.devices = { - disk.main = { - type = "disk"; - device = lib.mkDefault "/dev/sda"; - content = { - type = "gpt"; - partitions = { - ESP = { - priority = 1; - name = "ESP"; - start = "1MiB"; - end = "512MiB"; - type = "EF00"; - content = { - type = "filesystem"; - format = "vfat"; - mountpoint = "/boot"; - mountOptions = [ "umask=0077" ]; - }; - }; - swap = { - size = "4G"; - content = { - type = "swap"; - resumeDevice = true; - }; - }; - root = { - size = "100%"; - content = { - type = "filesystem"; - format = "ext4"; - mountpoint = "/"; - }; - }; - }; - }; - }; - }; -} diff --git a/hosts/lab/hardware-configuration.nix b/hosts/lab/hardware-configuration.nix deleted file mode 100644 index 3f6bc7b..0000000 --- a/hosts/lab/hardware-configuration.nix +++ /dev/null @@ -1,5 +0,0 @@ -{ ... }: -{ - # Placeholder generated by nodeiwest host init. - # nixos-anywhere will replace this with the generated hardware config. -} diff --git a/hosts/vps1/configuration.nix b/hosts/vps1/configuration.nix deleted file mode 100644 index d9536c9..0000000 --- a/hosts/vps1/configuration.nix +++ /dev/null @@ -1,28 +0,0 @@ -{ lib, ... }: -{ - imports = [ - ./disko.nix - ./hardware-configuration.nix - ]; - - networking.hostName = "vps1"; - networking.useDHCP = lib.mkDefault true; - - time.timeZone = "UTC"; - - boot.loader.efi.canTouchEfiVariables = true; - boot.loader.grub = { - enable = true; - efiSupport = true; - device = "nodev"; - }; - - nodeiwest.ssh.userCAPublicKeys = [ - "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIE6c2oMkM7lLg9qWHVgbrFaFBDrrFyynFlPviiydQdFi openbao-user-ca" - ]; - nodeiwest.tailscale.openbao = { - enable = true; - }; - - system.stateVersion = "25.05"; -} diff --git a/hosts/vps1/disko.nix b/hosts/vps1/disko.nix deleted file mode 100644 index eee0690..0000000 --- a/hosts/vps1/disko.nix +++ /dev/null @@ -1,46 +0,0 @@ -{ - lib, - ... -}: -{ - # Replace /dev/sda if the VPS exposes a different disk, e.g. /dev/vda or /dev/nvme0n1. - disko.devices = { - disk.main = { - type = "disk"; - device = lib.mkDefault "/dev/sda"; - content = { - type = "gpt"; - partitions = { - ESP = { - priority = 1; - name = "ESP"; - start = "1MiB"; - end = "512MiB"; - type = "EF00"; - content = { - type = "filesystem"; - format = "vfat"; - mountpoint = "/boot"; - mountOptions = [ "umask=0077" ]; - }; - }; - swap = { - size = "4G"; - content = { - type = "swap"; - resumeDevice = true; - }; - }; - root = { - size = "100%"; - content = { - type = "filesystem"; - format = "ext4"; - mountpoint = "/"; - }; - }; - }; - }; - }; - }; -} diff --git a/hosts/vps1/hardware-configuration.nix b/hosts/vps1/hardware-configuration.nix deleted file mode 100644 index 746bccf..0000000 --- a/hosts/vps1/hardware-configuration.nix +++ /dev/null @@ -1,10 +0,0 @@ -{ lib, ... }: -{ - # Replace this file with the generated hardware config from the target host. - fileSystems."/" = lib.mkDefault { - device = "/dev/disk/by-label/nixos"; - fsType = "ext4"; - }; - - swapDevices = [ ]; -} diff --git a/modules/helpers/home.nix b/modules/helpers/home.nix index d946109..2e926b8 100644 --- a/modules/helpers/home.nix +++ b/modules/helpers/home.nix @@ -1,10 +1,7 @@ -{ pkgs, ... }: -let - nodeiwestHelper = pkgs.callPackage ../../pkgs/helpers { }; -in +{ pkgs, deployment, ... }: { home.packages = [ pkgs.python3 - nodeiwestHelper + deployment.packages.${pkgs.system}.nodeiwest-helper ]; } diff --git a/modules/home.nix b/modules/home.nix index 54a4078..d388605 100644 --- a/modules/home.nix +++ b/modules/home.nix @@ -14,5 +14,6 @@ openbao colmena # etc. + sops ]; } diff --git a/modules/nixos/common.nix b/modules/nixos/common.nix deleted file mode 100644 index f44af2f..0000000 --- a/modules/nixos/common.nix +++ /dev/null @@ -1,101 +0,0 @@ -{ - config, - lib, - self, - ... -}: -let - cfg = config.nodeiwest; - trustedUserCAKeysPath = "/etc/ssh/trusted-user-ca-keys.pem"; -in -{ - imports = [ ./tailscale-init.nix ]; - - options.nodeiwest = { - openbao.address = lib.mkOption { - type = lib.types.str; - default = "https://secrets.api.nodeiwest.se"; - description = "Remote OpenBao address that hosts should use as clients."; - example = "https://secrets.api.nodeiwest.se"; - }; - - homeManagerUsers = lib.mkOption { - type = lib.types.listOf lib.types.str; - default = [ - "root" - "deploy" - ]; - description = "Users that should receive the shared Home Manager company profile."; - example = [ - "root" - "deploy" - ]; - }; - - ssh.userCAPublicKeys = lib.mkOption { - type = lib.types.listOf lib.types.singleLineStr; - default = [ ]; - description = "OpenBao SSH user CA public keys trusted by sshd for user certificate authentication."; - example = [ - "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBExampleOpenBaoUserCA openbao-user-ca" - ]; - }; - }; - - config = { - networking.firewall.allowedTCPPorts = [ - 22 - 80 - 443 - ]; - - services.openssh = { - enable = true; - settings = { - PasswordAuthentication = false; - KbdInteractiveAuthentication = false; - PubkeyAuthentication = true; - PermitRootLogin = "prohibit-password"; - } - // lib.optionalAttrs (cfg.ssh.userCAPublicKeys != [ ]) { - TrustedUserCAKeys = trustedUserCAKeysPath; - }; - }; - - users.groups.deploy = { }; - users.users.deploy = { - isNormalUser = true; - group = "deploy"; - createHome = true; - extraGroups = [ "wheel" ]; - }; - - services.traefik = { - enable = true; - staticConfigOptions = { - api.dashboard = true; - entryPoints.web.address = ":80"; - entryPoints.websecure.address = ":443"; - ping = { }; - }; - dynamicConfigOptions = lib.mkMerge [ ]; - }; - - home-manager = { - useGlobalPkgs = true; - useUserPackages = true; - users = lib.genAttrs cfg.homeManagerUsers (_: { - imports = [ self.homeManagerModules.default ]; - home.stateVersion = config.system.stateVersion; - }); - }; - - environment.etc = lib.mkIf (cfg.ssh.userCAPublicKeys != [ ]) { - "ssh/trusted-user-ca-keys.pem".text = lib.concatStringsSep "\n" cfg.ssh.userCAPublicKeys + "\n"; - }; - - environment.variables = { - BAO_ADDR = cfg.openbao.address; - }; - }; -} diff --git a/modules/nixos/tailscale-init.nix b/modules/nixos/tailscale-init.nix deleted file mode 100644 index 374c5af..0000000 --- a/modules/nixos/tailscale-init.nix +++ /dev/null @@ -1,155 +0,0 @@ -{ - config, - lib, - pkgs, - ... -}: -let - cfg = config.nodeiwest; - tailscaleOpenbaoCfg = cfg.tailscale.openbao; -in -{ - options.nodeiwest.tailscale.openbao = { - enable = lib.mkEnableOption "fetching the Tailscale auth key from OpenBao"; - - namespace = lib.mkOption { - type = lib.types.str; - default = "it"; - description = "OpenBao namespace used when fetching the Tailscale auth key."; - }; - - authPath = lib.mkOption { - type = lib.types.str; - default = "auth/approle"; - description = "OpenBao auth mount path used by the AppRole login."; - }; - - secretPath = lib.mkOption { - type = lib.types.str; - default = "tailscale"; - description = "OpenBao secret path containing the Tailscale auth key."; - }; - - field = lib.mkOption { - type = lib.types.str; - default = "CLIENT_SECRET"; - description = "Field in the OpenBao secret that contains the Tailscale auth key."; - }; - - renderedAuthKeyFile = lib.mkOption { - type = lib.types.str; - default = "/run/nodeiwest/tailscale-auth-key"; - description = "Runtime file rendered by OpenBao Agent and consumed by Tailscale autoconnect."; - }; - - approle = { - roleIdFile = lib.mkOption { - type = lib.types.str; - default = "/var/lib/nodeiwest/openbao-approle-role-id"; - description = "Root-only file containing the OpenBao AppRole role_id."; - }; - - secretIdFile = lib.mkOption { - type = lib.types.str; - default = "/var/lib/nodeiwest/openbao-approle-secret-id"; - description = "Root-only file containing the OpenBao AppRole secret_id."; - }; - }; - }; - - config = { - systemd.tmpfiles.rules = [ - "d /var/lib/nodeiwest 0700 root root - -" - "d /run/nodeiwest 0700 root root - -" - ]; - - services.tailscale = { - enable = true; - openFirewall = true; - extraUpFlags = lib.optionals tailscaleOpenbaoCfg.enable [ "--ssh" ]; - authKeyFile = if tailscaleOpenbaoCfg.enable then tailscaleOpenbaoCfg.renderedAuthKeyFile else null; - }; - - services.vault-agent.instances.tailscale = lib.mkIf tailscaleOpenbaoCfg.enable { - package = pkgs.openbao; - settings = { - vault.address = cfg.openbao.address; - auto_auth = { - method = [ - { - type = "approle"; - mount_path = tailscaleOpenbaoCfg.authPath; - namespace = tailscaleOpenbaoCfg.namespace; - config = { - role_id_file_path = tailscaleOpenbaoCfg.approle.roleIdFile; - secret_id_file_path = tailscaleOpenbaoCfg.approle.secretIdFile; - remove_secret_id_file_after_reading = false; - }; - } - ]; - }; - template = [ - { - contents = ''{{- with secret "${tailscaleOpenbaoCfg.secretPath}" -}}{{- if .Data.data -}}{{ index .Data.data "${tailscaleOpenbaoCfg.field}" }}{{- else -}}{{ index .Data "${tailscaleOpenbaoCfg.field}" }}{{- end -}}{{- end -}}''; - destination = tailscaleOpenbaoCfg.renderedAuthKeyFile; - perms = "0400"; - } - ]; - }; - }; - - systemd.services.vault-agent-tailscale = lib.mkIf tailscaleOpenbaoCfg.enable { - wants = [ "network-online.target" ]; - after = [ "network-online.target" ]; - serviceConfig.Environment = [ "BAO_NAMESPACE=${tailscaleOpenbaoCfg.namespace}" ]; - }; - - systemd.services.nodeiwest-tailscale-authkey-ready = lib.mkIf tailscaleOpenbaoCfg.enable { - description = "Wait for the Tailscale auth key rendered by OpenBao Agent"; - after = [ "vault-agent-tailscale.service" ]; - requires = [ "vault-agent-tailscale.service" ]; - before = [ "tailscaled-autoconnect.service" ]; - requiredBy = [ "tailscaled-autoconnect.service" ]; - path = [ pkgs.coreutils ]; - serviceConfig = { - Type = "oneshot"; - }; - script = '' - set -euo pipefail - - for _ in $(seq 1 60); do - if [ -s ${lib.escapeShellArg tailscaleOpenbaoCfg.renderedAuthKeyFile} ]; then - exit 0 - fi - sleep 1 - done - - echo "Timed out waiting for rendered Tailscale auth key at ${tailscaleOpenbaoCfg.renderedAuthKeyFile}" >&2 - exit 1 - ''; - }; - - systemd.services.tailscaled-autoconnect = lib.mkIf tailscaleOpenbaoCfg.enable { - after = [ - "vault-agent-tailscale.service" - "nodeiwest-tailscale-authkey-ready.service" - ]; - requires = [ - "vault-agent-tailscale.service" - "nodeiwest-tailscale-authkey-ready.service" - ]; - serviceConfig.ExecStartPre = [ - "${lib.getExe' pkgs.coreutils "test"} -s ${tailscaleOpenbaoCfg.renderedAuthKeyFile}" - ]; - }; - - assertions = [ - { - assertion = - (!tailscaleOpenbaoCfg.enable) - || (tailscaleOpenbaoCfg.approle.roleIdFile != "" && tailscaleOpenbaoCfg.approle.secretIdFile != ""); - message = "AppRole roleIdFile and secretIdFile must be set when OpenBao-backed Tailscale enrollment is enabled."; - } - ]; - }; -} diff --git a/pkgs/helpers/__pycache__/cli.cpython-313.pyc b/pkgs/helpers/__pycache__/cli.cpython-313.pyc deleted file mode 100644 index 65ea1cb161728d6482272992776d93d80d0c9bb5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 78473 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 AiU0rr diff --git a/pkgs/helpers/cli.py b/pkgs/helpers/cli.py deleted file mode 100644 index 4d2c467..0000000 --- a/pkgs/helpers/cli.py +++ /dev/null @@ -1,1498 +0,0 @@ -#!/usr/bin/env python3 - -from __future__ import annotations - -import argparse -import dataclasses -import datetime as dt -import difflib -import json -import os -import re -import shlex -import shutil -import select -import subprocess -import sys -import tempfile -import time -from pathlib import Path -from typing import Any - - -SUPPORTED_CONFIG_MARKER = "Generated by nodeiwest host init." -SUPPORTED_DISKO_MARKER = "Generated by nodeiwest host init." -DEFAULT_STATE_VERSION = "25.05" -BOOT_MODE_CHOICES = ("uefi", "bios") -ACTIVITY_FRAMES = (0, 1, 2, 3, 2, 1) - - -class NodeiwestError(RuntimeError): - pass - - -@dataclasses.dataclass -class ProbeFacts: - ip: str - user: str - boot_mode: str - primary_disk: str - root_partition: str - root_source: str - disk_family: str - swap_devices: list[str] - disk_rows: list[dict[str, str]] - raw_outputs: dict[str, str] - - def to_json(self) -> dict[str, Any]: - return dataclasses.asdict(self) - - -@dataclasses.dataclass -class ExistingConfiguration: - host_name: str - timezone: str - boot_mode: str - tailscale_openbao: bool - user_ca_public_keys: list[str] - state_version: str - managed: bool - - -@dataclasses.dataclass -class ExistingDisko: - disk_device: str - boot_mode: str - swap_size: str - managed: bool - - -@dataclasses.dataclass -class RepoDefaults: - state_version: str - user_ca_public_keys: list[str] - - -def main() -> int: - parser = build_parser() - args = parser.parse_args() - - if not hasattr(args, "func"): - parser.print_help() - return 1 - - try: - return int(args.func(args) or 0) - except KeyboardInterrupt: - print("Interrupted.", file=sys.stderr) - return 130 - except NodeiwestError as exc: - print(str(exc), file=sys.stderr) - return 1 - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser( - prog="nodeiwest", - description="Safe VPS provisioning helpers for the NodeiWest flake.", - ) - subparsers = parser.add_subparsers(dest="command") - - host_parser = subparsers.add_parser("host", help="Probe and initialize host files.") - host_subparsers = host_parser.add_subparsers(dest="host_command") - - probe_parser = host_subparsers.add_parser("probe", help="Probe a live host over SSH.") - probe_parser.add_argument("--ip", required=True, help="Target host IP or hostname.") - probe_parser.add_argument("--user", default="root", help="SSH user. Default: root.") - probe_parser.add_argument("--json", action="store_true", help="Emit machine-readable JSON.") - probe_parser.set_defaults(func=cmd_host_probe) - - init_parser = host_subparsers.add_parser("init", help="Create or update hosts// files.") - init_parser.add_argument("--name", required=True, help="Host name, e.g. vps2.") - init_parser.add_argument("--ip", required=True, help="Target host IP or hostname.") - init_parser.add_argument("--user", default="root", help="SSH user. Default: root.") - init_parser.add_argument("--disk", help="Override the probed disk device, e.g. /dev/sda.") - init_parser.add_argument("--boot-mode", choices=BOOT_MODE_CHOICES, help="Override the probed boot mode.") - init_parser.add_argument("--swap-size", help="Swap partition size. Default: 4G.") - init_parser.add_argument("--timezone", help="Time zone. Default for new hosts: UTC.") - init_parser.add_argument( - "--tailscale-openbao", - choices=("on", "off"), - help="Enable or disable OpenBao-backed Tailscale bootstrap. Default for new hosts: on.", - ) - init_parser.add_argument("--apply", action="store_true", help="Write files after confirmation.") - init_parser.add_argument("--yes", action="store_true", help="Skip the interactive confirmation prompt.") - init_parser.add_argument("--force", action="store_true", help="Proceed even if target files are dirty.") - init_parser.set_defaults(func=cmd_host_init) - - openbao_parser = subparsers.add_parser("openbao", help="Create host OpenBao bootstrap material.") - openbao_subparsers = openbao_parser.add_subparsers(dest="openbao_command") - - init_host_parser = openbao_subparsers.add_parser("init-host", help="Create policy, AppRole, and bootstrap files.") - init_host_parser.add_argument("--name", required=True, help="Host name, e.g. vps2.") - init_host_parser.add_argument("--namespace", default="it", help="OpenBao namespace. Default: it.") - init_host_parser.add_argument("--kv-mount", default="kv", help="KV v2 mount name. Default: kv.") - init_host_parser.add_argument("--secret-path", default="tailscale", help="Logical secret path. Default: tailscale.") - init_host_parser.add_argument("--field", default="CLIENT_SECRET", help="Secret field. Default: CLIENT_SECRET.") - init_host_parser.add_argument("--auth-path", default="auth/approle", help="AppRole auth mount. Default: auth/approle.") - init_host_parser.add_argument("--policy-name", help="Policy name. Default: tailscale-.") - init_host_parser.add_argument("--role-name", help="AppRole name. Default: tailscale-.") - init_host_parser.add_argument("--out", default="bootstrap", help="Bootstrap output directory. Default: ./bootstrap.") - init_host_parser.add_argument( - "--kv-mount-path", - help="Override the actual HCL policy path, e.g. kv/data/tailscale.", - ) - init_host_parser.add_argument("--cidr", action="append", default=[], help="Optional CIDR restriction. Repeatable.") - init_host_parser.add_argument("--apply", action="store_true", help="Execute the plan after confirmation.") - init_host_parser.add_argument("--yes", action="store_true", help="Skip the interactive confirmation prompt.") - init_host_parser.set_defaults(func=cmd_openbao_init_host) - - install_parser = subparsers.add_parser("install", help="Plan or run nixos-anywhere.") - install_subparsers = install_parser.add_subparsers(dest="install_command") - - install_plan_parser = install_subparsers.add_parser("plan", help="Print the nixos-anywhere command.") - add_install_arguments(install_plan_parser) - install_plan_parser.set_defaults(func=cmd_install_plan) - - install_run_parser = install_subparsers.add_parser("run", help="Execute the nixos-anywhere command.") - add_install_arguments(install_run_parser) - install_run_parser.add_argument("--apply", action="store_true", help="Actually run nixos-anywhere.") - install_run_parser.add_argument("--yes", action="store_true", help="Skip the interactive confirmation prompt.") - install_run_parser.set_defaults(func=cmd_install_run) - - verify_parser = subparsers.add_parser("verify", help="Verify a provisioned host.") - verify_subparsers = verify_parser.add_subparsers(dest="verify_command") - - verify_host_parser = verify_subparsers.add_parser("host", help="Check first-boot service health.") - verify_host_parser.add_argument("--name", required=True, help="Host name.") - verify_host_parser.add_argument("--ip", required=True, help="Target host IP or hostname.") - verify_host_parser.add_argument("--user", default="root", help="SSH user. Default: root.") - verify_host_parser.set_defaults(func=cmd_verify_host) - - colmena_parser = subparsers.add_parser("colmena", help="Check colmena host inventory.") - colmena_subparsers = colmena_parser.add_subparsers(dest="colmena_command") - - colmena_plan_parser = colmena_subparsers.add_parser("plan", help="Print the colmena target block or deploy command.") - colmena_plan_parser.add_argument("--name", required=True, help="Host name.") - colmena_plan_parser.add_argument("--ip", help="Target IP to use in a suggested snippet when missing.") - colmena_plan_parser.set_defaults(func=cmd_colmena_plan) - - return parser - - -def add_install_arguments(parser: argparse.ArgumentParser) -> None: - parser.add_argument("--name", required=True, help="Host name.") - parser.add_argument("--ip", help="Target host IP. Defaults to the colmena inventory if present.") - parser.add_argument("--bootstrap-dir", default="bootstrap", help="Bootstrap directory. Default: ./bootstrap.") - parser.add_argument( - "--copy-host-keys", - choices=("on", "off"), - default="on", - help="Whether to pass --copy-host-keys. Default: on.", - ) - parser.add_argument( - "--generate-hardware-config", - choices=("on", "off"), - default="on", - help="Whether to pass --generate-hardware-config. Default: on.", - ) - - -def cmd_host_probe(args: argparse.Namespace) -> int: - facts = probe_host(args.ip, args.user) - if args.json: - print(json.dumps(facts.to_json(), indent=2, sort_keys=True)) - return 0 - - print(f"Host: {args.user}@{args.ip}") - print(f"Boot mode: {facts.boot_mode.upper()}") - print(f"Primary disk: {facts.primary_disk}") - print(f"Root source: {facts.root_source}") - print(f"Root partition: {facts.root_partition}") - print(f"Disk family: {facts.disk_family}") - print(f"Swap devices: {', '.join(facts.swap_devices) if facts.swap_devices else 'none'}") - print("") - print("Disk inventory:") - for row in facts.disk_rows: - model = row.get("MODEL") or "n/a" - print( - " " - + f"{row.get('NAME', '?')} size={row.get('SIZE', '?')} type={row.get('TYPE', '?')} " - + f"model={model} fstype={row.get('FSTYPE', '') or '-'} pttype={row.get('PTTYPE', '') or '-'}" - ) - return 0 - - -def cmd_host_init(args: argparse.Namespace) -> int: - repo_root = find_repo_root(Path.cwd()) - ensure_expected_repo_root(repo_root) - validate_host_name(args.name) - - host_dir = repo_root / "hosts" / args.name - config_path = host_dir / "configuration.nix" - disko_path = host_dir / "disko.nix" - hardware_path = host_dir / "hardware-configuration.nix" - - if not args.force: - ensure_git_paths_clean(repo_root, [config_path, disko_path, hardware_path]) - - host_dir.mkdir(parents=True, exist_ok=True) - - existing_config = parse_existing_configuration(config_path) if config_path.exists() else None - existing_disko = parse_existing_disko(disko_path) if disko_path.exists() else None - - repo_defaults = infer_repo_defaults(repo_root, skip_host=args.name) - facts = None - if not (args.disk and args.boot_mode): - facts = probe_host(args.ip, args.user) - - disk_device = args.disk or (facts.primary_disk if facts else None) - boot_mode = normalize_boot_mode(args.boot_mode or (facts.boot_mode if facts else None)) - if not disk_device or not boot_mode: - raise NodeiwestError("Unable to determine both disk and boot mode. Supply --disk and --boot-mode explicitly.") - - if existing_config is not None and existing_config.host_name != args.name: - raise NodeiwestError( - f"{config_path.relative_to(repo_root)} already declares hostName={existing_config.host_name!r}, not {args.name!r}." - ) - if existing_config is not None and existing_config.boot_mode != boot_mode: - raise NodeiwestError( - f"{config_path.relative_to(repo_root)} uses {existing_config.boot_mode.upper()} boot settings but the requested boot mode is {boot_mode.upper()}." - ) - if existing_disko is not None and existing_disko.boot_mode != boot_mode: - raise NodeiwestError( - f"{disko_path.relative_to(repo_root)} describes a {existing_disko.boot_mode.upper()} layout but the requested boot mode is {boot_mode.upper()}." - ) - - if existing_disko is not None and existing_disko.disk_device != disk_device and not args.yes: - print( - f"Existing disk device in {disko_path.relative_to(repo_root)} is {existing_disko.disk_device}; requested device is {disk_device}.", - file=sys.stderr, - ) - - swap_size = normalize_swap_size(args.swap_size or (existing_disko.swap_size if existing_disko else "4G")) - timezone = args.timezone or (existing_config.timezone if existing_config else "UTC") - tailscale_openbao = parse_on_off(args.tailscale_openbao, existing_config.tailscale_openbao if existing_config else True) - state_version = existing_config.state_version if existing_config else repo_defaults.state_version - user_ca_public_keys = existing_config.user_ca_public_keys if existing_config else repo_defaults.user_ca_public_keys - - if not user_ca_public_keys: - raise NodeiwestError( - "No SSH user CA public keys could be inferred from the repo. Add them to an existing host config first or create this host manually." - ) - - configuration_text = render_configuration( - host_name=args.name, - timezone=timezone, - boot_mode=boot_mode, - disk_device=disk_device, - tailscale_openbao=tailscale_openbao, - state_version=state_version, - user_ca_public_keys=user_ca_public_keys, - ) - disko_text = render_disko(boot_mode=boot_mode, disk_device=disk_device, swap_size=swap_size) - hardware_text = load_template("hardware-configuration.placeholder.nix") - - plans = [] - plans.extend(plan_file_update(config_path, configuration_text)) - plans.extend(plan_file_update(disko_path, disko_text)) - if hardware_path.exists(): - plans.extend(plan_file_update(hardware_path, hardware_path.read_text())) - else: - plans.extend(plan_file_update(hardware_path, hardware_text)) - - if not plans: - print(f"No changes required under hosts/{args.name}.") - else: - print(f"Planned updates for hosts/{args.name}:") - for plan in plans: - print("") - print(plan["summary"]) - if plan["diff"]: - print(plan["diff"]) - - flake_text = (repo_root / "flake.nix").read_text() - nixos_missing = not flake_has_nixos_configuration(flake_text, args.name) - colmena_missing = not flake_has_colmena_host(flake_text, args.name) - if nixos_missing or colmena_missing: - print("") - print("flake.nix additions required:") - if nixos_missing: - print(build_nixos_configuration_snippet(args.name)) - if colmena_missing: - print(build_colmena_host_snippet(args.name, args.ip)) - - if not args.apply: - print("") - print("Dry run only. Re-run with --apply to write these files.") - return 0 - - if plans and not args.yes: - if not confirm("Write the planned host files? [y/N] "): - raise NodeiwestError("Aborted before writing host files.") - - for plan in plans: - if plan["changed"]: - write_file_with_backup(plan["path"], plan["new_text"]) - rel_path = plan["path"].relative_to(repo_root) - print(f"Wrote {rel_path}") - - if not plans: - print("Nothing to write.") - return 0 - - -def cmd_openbao_init_host(args: argparse.Namespace) -> int: - repo_root = find_repo_root(Path.cwd()) - ensure_expected_repo_root(repo_root) - validate_host_name(args.name) - ensure_command_available("bao") - ensure_bao_authenticated() - - policy_name = args.policy_name or f"tailscale-{args.name}" - role_name = args.role_name or f"tailscale-{args.name}" - output_dir = resolve_path(repo_root, args.out) - role_id_path = output_dir / "var" / "lib" / "nodeiwest" / "openbao-approle-role-id" - secret_id_path = output_dir / "var" / "lib" / "nodeiwest" / "openbao-approle-secret-id" - - secret_data = bao_kv_get(args.namespace, args.kv_mount, args.secret_path) - fields = secret_data.get("data", {}) - if isinstance(fields.get("data"), dict): - fields = fields["data"] - if args.field not in fields: - raise NodeiwestError( - f"OpenBao secret {args.secret_path!r} in namespace {args.namespace!r} does not contain field {args.field!r}." - ) - - if args.kv_mount_path: - policy_content = render_openbao_policy(args.kv_mount_path) - else: - policy_content = derive_openbao_policy(args.namespace, args.kv_mount, args.secret_path) - - role_command = build_approle_write_command(args.auth_path, role_name, policy_name, args.cidr) - - print(f"Namespace: {args.namespace}") - print(f"KV mount: {args.kv_mount}") - print(f"Policy name: {policy_name}") - print(f"Role name: {role_name}") - print(f"Secret path: {args.secret_path}") - print(f"Field: {args.field}") - print(f"Bootstrap output: {output_dir}") - print("") - print("Policy content:") - print(policy_content.rstrip()) - print("") - print("AppRole command:") - print(shlex.join(role_command)) - print("") - print("Bootstrap files:") - print(f" {role_id_path}") - print(f" {secret_id_path}") - - if not args.apply: - print("") - print("Dry run only. Re-run with --apply to create the policy, AppRole, and bootstrap files.") - return 0 - - if not args.yes and not confirm("Create or update the OpenBao policy, AppRole, and bootstrap files? [y/N] "): - raise NodeiwestError("Aborted before OpenBao writes.") - - with tempfile.NamedTemporaryFile("w", delete=False) as handle: - handle.write(policy_content.rstrip() + "\n") - temp_policy_path = Path(handle.name) - - try: - bao_env = {"BAO_NAMESPACE": args.namespace} - run_command( - ["bao", "policy", "write", policy_name, str(temp_policy_path)], - cwd=repo_root, - env=bao_env, - next_fix="Check that your token can write policies in the selected namespace.", - ) - run_command( - role_command, - cwd=repo_root, - env=bao_env, - next_fix="Check that the AppRole auth mount exists and that your token can manage roles.", - ) - role_id = run_command( - ["bao", "read", "-field=role_id", f"{args.auth_path}/role/{role_name}/role-id"], - cwd=repo_root, - env=bao_env, - next_fix="Check that the AppRole was created successfully before fetching role_id.", - ).stdout.strip() - secret_id = run_command( - ["bao", "write", "-f", "-field=secret_id", f"{args.auth_path}/role/{role_name}/secret-id"], - cwd=repo_root, - env=bao_env, - next_fix="Check that the AppRole supports SecretIDs and that your token can generate them.", - ).stdout.strip() - finally: - temp_policy_path.unlink(missing_ok=True) - - role_id_path.parent.mkdir(parents=True, exist_ok=True) - write_secret_file(role_id_path, role_id + "\n") - write_secret_file(secret_id_path, secret_id + "\n") - - print("") - print("OpenBao bootstrap material written.") - print(f"Role ID: {role_id_path}") - print(f"Secret ID: {secret_id_path}") - print("") - print("Next step:") - print(f" nodeiwest install plan --name {args.name} --bootstrap-dir {shlex.quote(str(output_dir))}") - return 0 - - -def cmd_install_plan(args: argparse.Namespace) -> int: - repo_root = find_repo_root(Path.cwd()) - ensure_expected_repo_root(repo_root) - install_context = build_install_context(repo_root, args) - print_install_plan(install_context) - return 0 - - -def cmd_install_run(args: argparse.Namespace) -> int: - if not args.apply: - raise NodeiwestError("install run is destructive. Re-run with --apply to execute nixos-anywhere.") - - repo_root = find_repo_root(Path.cwd()) - ensure_expected_repo_root(repo_root) - install_context = build_install_context(repo_root, args) - ensure_ssh_reachable(install_context["ip"], "root") - print_install_plan(install_context) - if not args.yes and not confirm("Run nixos-anywhere now? [y/N] "): - raise NodeiwestError("Aborted before running nixos-anywhere.") - - print("") - stream_command( - install_context["command"], - cwd=repo_root, - next_fix="Recover via provider console or public SSH, then re-check the generated host files and bootstrap material.", - activity_label="Executing install", - ) - - print("") - print("Install completed. Verify first boot with:") - print(f" nodeiwest verify host --name {args.name} --ip {install_context['ip']}") - return 0 - - -def cmd_verify_host(args: argparse.Namespace) -> int: - validate_host_name(args.name) - services = [ - "vault-agent-tailscale", - "nodeiwest-tailscale-authkey-ready", - "tailscaled-autoconnect", - ] - - service_results: dict[str, subprocess.CompletedProcess[str]] = {} - for service in services: - service_results[service] = ssh_command( - args.user, - args.ip, - f"systemctl status --no-pager --lines=20 {shlex.quote(service)}", - check=False, - next_fix="Check public SSH reachability before retrying verification.", - ) - - tailscale_status = ssh_command( - args.user, - args.ip, - "tailscale status", - check=False, - next_fix="Check public SSH reachability before retrying verification.", - ) - - print(f"Verification target: {args.user}@{args.ip} ({args.name})") - print("") - for service in services: - state = classify_systemd_status(service_results[service]) - print(f"{service}: {state}") - print(f"tailscale status: {'healthy' if tailscale_status.returncode == 0 else 'error'}") - - causes = infer_verify_failures(service_results, tailscale_status) - if causes: - print("") - print("Likely causes:") - for cause in causes: - print(f" - {cause}") - - print("") - print("Service excerpts:") - for service in services: - print(f"[{service}]") - excerpt = summarize_text(service_results[service].stdout or service_results[service].stderr, 12) - print(excerpt or "(no output)") - print("") - print("[tailscale status]") - print(summarize_text(tailscale_status.stdout or tailscale_status.stderr, 12) or "(no output)") - return 0 - - -def cmd_colmena_plan(args: argparse.Namespace) -> int: - repo_root = find_repo_root(Path.cwd()) - ensure_expected_repo_root(repo_root) - validate_host_name(args.name) - - flake_text = (repo_root / "flake.nix").read_text() - target_host = lookup_colmena_target_host(flake_text, args.name) - if target_host: - print(f"colmena targetHost for {args.name}: {target_host}") - else: - if not args.ip: - raise NodeiwestError( - f"flake.nix does not define colmena.{args.name}.deployment.targetHost and no --ip was provided." - ) - print("Missing colmena host block. Add this to flake.nix:") - print(build_colmena_host_snippet(args.name, args.ip)) - print("") - print(f"Deploy command: nix run .#colmena -- apply --on {args.name}") - return 0 - - -def find_repo_root(start: Path) -> Path: - git_result = subprocess.run( - ["git", "rev-parse", "--show-toplevel"], - cwd=start, - text=True, - capture_output=True, - ) - if git_result.returncode == 0: - return Path(git_result.stdout.strip()).resolve() - - current = start.resolve() - for candidate in [current, *current.parents]: - if (candidate / "flake.nix").exists() and (candidate / "modules" / "home.nix").exists(): - return candidate - raise NodeiwestError("Not inside the nix-nodeiwest repository. Run the helper from this flake checkout.") - - -def ensure_expected_repo_root(repo_root: Path) -> None: - required = [ - repo_root / "flake.nix", - repo_root / "modules" / "home.nix", - repo_root / "hosts", - ] - missing = [path for path in required if not path.exists()] - if missing: - formatted = ", ".join(str(path.relative_to(repo_root)) for path in missing) - raise NodeiwestError(f"Repository root is missing expected files: {formatted}") - - -def validate_host_name(name: str) -> None: - if not re.fullmatch(r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?", name): - raise NodeiwestError( - f"Invalid host name {name!r}. Use lowercase letters, digits, and hyphens only, without a trailing hyphen." - ) - - -def probe_host(ip: str, user: str) -> ProbeFacts: - lsblk_cmd = "lsblk -P -o NAME,SIZE,TYPE,MODEL,FSTYPE,PTTYPE,MOUNTPOINTS" - boot_cmd = "test -d /sys/firmware/efi && echo UEFI || echo BIOS" - root_cmd = "findmnt -no SOURCE /" - swap_cmd = "cat /proc/swaps" - - lsblk_output = ssh_command(user, ip, lsblk_cmd, next_fix="Check SSH access and that lsblk exists on the target.").stdout - boot_output = ssh_command(user, ip, boot_cmd, next_fix="Check SSH access and that /sys/firmware is readable.").stdout - root_output = ssh_command(user, ip, root_cmd, next_fix="Check SSH access and that findmnt exists on the target.").stdout - swap_output = ssh_command(user, ip, swap_cmd, next_fix="Check SSH access and that /proc/swaps is readable.").stdout - - disk_rows = parse_lsblk_output(lsblk_output) - disk_devices = [f"/dev/{row['NAME']}" for row in disk_rows if row.get("TYPE") == "disk"] - if not disk_devices: - raise NodeiwestError("No disk devices were found in the remote lsblk output.") - - root_source = root_output.strip() - if not root_source: - raise NodeiwestError("findmnt returned an empty root source; cannot determine the primary disk.") - root_partition = normalize_device(root_source) - primary_disk = disk_from_device(root_partition) - if primary_disk not in disk_devices: - if len(disk_devices) == 1: - primary_disk = disk_devices[0] - else: - raise NodeiwestError( - "Multiple candidate disks were found and the root source did not map cleanly to one of them. Re-run with --disk." - ) - - boot_mode = normalize_boot_mode(boot_output.strip()) - swap_devices = parse_swaps(swap_output) - - return ProbeFacts( - ip=ip, - user=user, - boot_mode=boot_mode, - primary_disk=primary_disk, - root_partition=root_partition, - root_source=root_source, - disk_family=classify_disk_family(primary_disk), - swap_devices=swap_devices, - disk_rows=disk_rows, - raw_outputs={ - "lsblk": lsblk_output, - "boot_mode": boot_output, - "root_source": root_output, - "swaps": swap_output, - }, - ) - - -def parse_lsblk_output(output: str) -> list[dict[str, str]]: - lines = [line.strip() for line in output.splitlines() if line.strip()] - if not lines: - raise NodeiwestError("Unexpected lsblk output: not enough lines to parse.") - - columns = ["NAME", "SIZE", "TYPE", "MODEL", "FSTYPE", "PTTYPE", "MOUNTPOINTS"] - rows: list[dict[str, str]] = [] - for line in lines: - tokens = shlex.split(line) - row = {} - for token in tokens: - if "=" not in token: - continue - key, value = token.split("=", 1) - row[key] = value - missing = [column for column in columns if column not in row] - if missing: - raise NodeiwestError( - f"Unexpected lsblk output: missing columns {', '.join(missing)} in line {line!r}." - ) - rows.append(row) - return rows - - -def normalize_boot_mode(value: str | None) -> str: - if not value: - raise NodeiwestError("Boot mode is missing.") - normalized = value.strip().lower() - if normalized not in BOOT_MODE_CHOICES: - raise NodeiwestError(f"Unsupported boot mode {value!r}. Expected one of: {', '.join(BOOT_MODE_CHOICES)}.") - return normalized - - -def normalize_device(value: str) -> str: - normalized = value.strip() - if not normalized.startswith("/dev/"): - raise NodeiwestError( - f"Unsupported root source {value!r}. Only plain /dev/* block devices are supported by the helper." - ) - return normalized - - -def disk_from_device(device: str) -> str: - name = Path(device).name - if re.fullmatch(r"nvme\d+n\d+p\d+", name) or re.fullmatch(r"mmcblk\d+p\d+", name): - base_name = re.sub(r"p\d+$", "", name) - return f"/dev/{base_name}" - if re.search(r"\d+$", name): - base_name = re.sub(r"\d+$", "", name) - return f"/dev/{base_name}" - return device - - -def classify_disk_family(device: str) -> str: - name = Path(device).name - if name.startswith("nvme"): - return "nvme" - if name.startswith("vd"): - return "vda" - if name.startswith("sd"): - return "sda" - return "other" - - -def parse_swaps(output: str) -> list[str]: - lines = [line.strip() for line in output.splitlines() if line.strip()] - if len(lines) <= 1: - return [] - return [line.split()[0] for line in lines[1:]] - - -def normalize_swap_size(value: str) -> str: - normalized = value.strip() - replacements = { - "KiB": "K", - "MiB": "M", - "GiB": "G", - "TiB": "T", - "PiB": "P", - } - for suffix, replacement in replacements.items(): - if normalized.endswith(suffix): - return normalized[: -len(suffix)] + replacement - return normalized - - -def parse_existing_configuration(path: Path) -> ExistingConfiguration: - text = path.read_text() - if "./disko.nix" not in text or "./hardware-configuration.nix" not in text: - raise NodeiwestError( - f"{path} does not match the supported configuration shape. Manual intervention is required." - ) - - host_name = extract_single_match(text, r'networking\.hostName\s*=\s*"([^"]+)";', path, "hostName") - timezone = extract_single_match(text, r'time\.timeZone\s*=\s*"([^"]+)";', path, "time.timeZone") - state_version = extract_single_match(text, r'system\.stateVersion\s*=\s*"([^"]+)";', path, "system.stateVersion") - user_ca_public_keys = extract_nix_string_list(text, r"nodeiwest\.ssh\.userCAPublicKeys\s*=\s*\[(.*?)\];", path) - tailscale_enable_text = extract_optional_match( - text, - r"nodeiwest\.tailscale\.openbao(?:\.enable\s*=\s*|\s*=\s*\{[^}]*enable\s*=\s*)(true|false);", - ) - if tailscale_enable_text is None: - raise NodeiwestError( - f"{path} does not contain a supported nodeiwest.tailscale.openbao.enable declaration." - ) - if 'boot.loader.efi.canTouchEfiVariables = true;' in text and 'device = "nodev";' in text: - boot_mode = "uefi" - elif re.search(r'boot\.loader\.grub\s*=\s*\{[^}]*device\s*=\s*"/dev/', text, re.S) or 'efiSupport = false;' in text: - boot_mode = "bios" - else: - raise NodeiwestError( - f"{path} has a boot loader configuration outside the helper's supported template shape." - ) - - return ExistingConfiguration( - host_name=host_name, - timezone=timezone, - boot_mode=boot_mode, - tailscale_openbao=(tailscale_enable_text == "true"), - user_ca_public_keys=user_ca_public_keys, - state_version=state_version, - managed=SUPPORTED_CONFIG_MARKER in text, - ) - - -def parse_existing_disko(path: Path) -> ExistingDisko: - text = path.read_text() - if 'type = "gpt";' not in text or 'format = "ext4";' not in text or 'type = "swap";' not in text: - raise NodeiwestError( - f"{path} does not match the supported single-disk ext4+swap disko shape. Manual intervention is required." - ) - disk_device = extract_single_match(text, r'device\s*=\s*lib\.mkDefault\s*"([^"]+)";', path, "disk device") - swap_size = extract_single_match(text, r'swap\s*=\s*\{.*?size\s*=\s*"([^"]+)";', path, "swap size", flags=re.S) - if 'type = "EF00";' in text and 'mountpoint = "/boot";' in text: - boot_mode = "uefi" - elif 'type = "EF02";' in text: - boot_mode = "bios" - else: - raise NodeiwestError( - f"{path} does not match the helper's supported UEFI or BIOS templates." - ) - - return ExistingDisko( - disk_device=disk_device, - boot_mode=boot_mode, - swap_size=swap_size, - managed=SUPPORTED_DISKO_MARKER in text, - ) - - -def infer_repo_defaults(repo_root: Path, skip_host: str | None = None) -> RepoDefaults: - hosts_dir = repo_root / "hosts" - state_versions: list[str] = [] - ca_key_sets: set[tuple[str, ...]] = set() - - for config_path in sorted(hosts_dir.glob("*/configuration.nix")): - if skip_host and config_path.parent.name == skip_host: - continue - try: - existing = parse_existing_configuration(config_path) - except NodeiwestError: - continue - state_versions.append(existing.state_version) - if existing.user_ca_public_keys: - ca_key_sets.add(tuple(existing.user_ca_public_keys)) - - state_version = most_common_value(state_versions) or DEFAULT_STATE_VERSION - if len(ca_key_sets) > 1: - raise NodeiwestError( - "Existing host configs define multiple different SSH user CA key lists. The helper will not guess which set to reuse." - ) - user_ca_public_keys = list(next(iter(ca_key_sets))) if ca_key_sets else [] - return RepoDefaults(state_version=state_version, user_ca_public_keys=user_ca_public_keys) - - -def most_common_value(values: list[str]) -> str | None: - if not values: - return None - counts: dict[str, int] = {} - for value in values: - counts[value] = counts.get(value, 0) + 1 - return sorted(counts.items(), key=lambda item: (-item[1], item[0]))[0][0] - - -def render_configuration( - *, - host_name: str, - timezone: str, - boot_mode: str, - disk_device: str, - tailscale_openbao: bool, - state_version: str, - user_ca_public_keys: list[str], -) -> str: - template = load_template("configuration.nix.tmpl") - boot_loader_block = render_boot_loader_block(boot_mode, disk_device) - rendered = template - rendered = rendered.replace("@@HOST_NAME@@", host_name) - rendered = rendered.replace("@@TIMEZONE@@", timezone) - rendered = rendered.replace("@@BOOT_LOADER_BLOCK@@", indent(boot_loader_block.rstrip(), " ")) - rendered = rendered.replace("@@SSH_CA_KEYS@@", render_nix_string_list(user_ca_public_keys, indent_level=2)) - rendered = rendered.replace("@@TAILSCALE_OPENBAO_ENABLE@@", render_nix_bool(tailscale_openbao)) - rendered = rendered.replace("@@STATE_VERSION@@", state_version) - return ensure_trailing_newline(rendered) - - -def render_boot_loader_block(boot_mode: str, disk_device: str) -> str: - if boot_mode == "uefi": - return """ -boot.loader.efi.canTouchEfiVariables = true; -boot.loader.grub = { - enable = true; - efiSupport = true; - device = "nodev"; -}; -""".strip("\n") - return f""" -boot.loader.grub = {{ - enable = true; - efiSupport = false; - device = "{escape_nix_string(disk_device)}"; -}}; -""".strip("\n") - - -def render_disko(*, boot_mode: str, disk_device: str, swap_size: str) -> str: - template_name = "disko-uefi-ext4.nix" if boot_mode == "uefi" else "disko-bios-ext4.nix" - rendered = load_template(template_name) - rendered = rendered.replace("@@DISK_DEVICE@@", escape_nix_string(disk_device)) - rendered = rendered.replace("@@SWAP_SIZE@@", escape_nix_string(swap_size)) - return ensure_trailing_newline(rendered) - - -def render_openbao_policy(policy_path: str) -> str: - rendered = load_template("openbao-policy.hcl.tmpl").replace("@@POLICY_PATH@@", policy_path) - return ensure_trailing_newline(rendered) - - -def load_template(name: str) -> str: - templates_dir = Path(os.environ.get("NODEIWEST_HELPER_TEMPLATES", Path(__file__).resolve().parent / "templates")) - template_path = templates_dir / name - if not template_path.exists(): - raise NodeiwestError(f"Missing helper template: {template_path}") - return template_path.read_text() - - -def render_nix_string_list(values: list[str], indent_level: int = 0) -> str: - if not values: - return "[ ]" - indent_text = " " * indent_level - lines = ["["] - for value in values: - lines.append(f'{indent_text} "{escape_nix_string(value)}"') - lines.append(f"{indent_text}]") - return "\n".join(lines) - - -def render_nix_bool(value: bool) -> str: - return "true" if value else "false" - - -def escape_nix_string(value: str) -> str: - return value.replace("\\", "\\\\").replace('"', '\\"') - - -def ensure_trailing_newline(text: str) -> str: - return text if text.endswith("\n") else text + "\n" - - -def indent(text: str, prefix: str) -> str: - return "\n".join(prefix + line if line else line for line in text.splitlines()) - - -def plan_file_update(path: Path, new_text: str) -> list[dict[str, Any]]: - if path.exists(): - old_text = path.read_text() - if old_text == new_text: - return [] - diff = unified_diff(path, old_text, new_text) - return [{ - "path": path, - "changed": True, - "new_text": new_text, - "summary": f"Update {path.name}", - "diff": diff, - }] - - diff = unified_diff(path, "", new_text) - return [{ - "path": path, - "changed": True, - "new_text": new_text, - "summary": f"Create {path.name}", - "diff": diff, - }] - - -def unified_diff(path: Path, old_text: str, new_text: str) -> str: - old_lines = old_text.splitlines() - new_lines = new_text.splitlines() - diff = difflib.unified_diff( - old_lines, - new_lines, - fromfile=str(path), - tofile=str(path), - lineterm="", - ) - return "\n".join(diff) - - -def write_file_with_backup(path: Path, text: str) -> None: - if path.exists(): - backup_path = backup_file(path) - print(f"Backed up {path.name} to {backup_path.name}") - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(text) - - -def write_secret_file(path: Path, text: str) -> None: - if path.exists(): - backup_path = backup_file(path) - print(f"Backed up {path.name} to {backup_path.name}") - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(text) - path.chmod(0o400) - - -def backup_file(path: Path) -> Path: - timestamp = dt.datetime.now(dt.timezone.utc).strftime("%Y%m%d%H%M%S") - backup_path = path.with_name(f"{path.name}.bak.{timestamp}") - shutil.copy2(path, backup_path) - return backup_path - - -def ensure_git_paths_clean(repo_root: Path, paths: list[Path]) -> None: - existing_paths = [path for path in paths if path.exists()] - if not existing_paths: - return - relative_paths = [str(path.relative_to(repo_root)) for path in existing_paths] - result = run_command( - ["git", "status", "--porcelain", "--", *relative_paths], - cwd=repo_root, - next_fix="Commit or stash local edits to the target host files, or re-run with --force if you intentionally want to overwrite them.", - ) - if result.stdout.strip(): - raise NodeiwestError( - "Refusing to modify host files with local git changes:\n" - + summarize_text(result.stdout, 20) - + "\nRe-run with --force to override this guard." - ) - - -def flake_has_nixos_configuration(flake_text: str, name: str) -> bool: - pattern = rf'^\s*{re.escape(name)}\s*=\s*mkHost\s+"{re.escape(name)}";' - return re.search(pattern, flake_text, re.M) is not None - - -def flake_has_colmena_host(flake_text: str, name: str) -> bool: - target_host = lookup_colmena_target_host(flake_text, name) - return target_host is not None - - -def lookup_colmena_target_host(flake_text: str, name: str) -> str | None: - pattern = re.compile( - rf'colmena\s*=\s*\{{.*?^\s*{re.escape(name)}\s*=\s*\{{.*?targetHost\s*=\s*"([^"]+)";', - re.S | re.M, - ) - match = pattern.search(flake_text) - return match.group(1) if match else None - - -def build_nixos_configuration_snippet(name: str) -> str: - return f' {name} = mkHost "{name}";' - - -def build_colmena_host_snippet(name: str, ip: str) -> str: - return ( - f" {name} = {{\n" - f" deployment = {{\n" - f' targetHost = "{ip}";\n' - f' targetUser = "root";\n' - f" tags = [\n" - f' "company"\n' - f" ];\n" - f" }};\n\n" - f" imports = [ ./hosts/{name}/configuration.nix ];\n" - f" }};" - ) - - -def ensure_command_available(name: str) -> None: - if shutil.which(name) is None: - raise NodeiwestError(f"Required command {name!r} is not available in PATH.") - - -def ensure_bao_authenticated() -> None: - run_command( - ["bao", "token", "lookup"], - next_fix="Run a bao login flow first and verify that `bao token lookup` succeeds.", - ) - - -def bao_kv_get(namespace: str, kv_mount: str, secret_path: str) -> dict[str, Any]: - result = run_command( - ["bao", "kv", "get", f"-mount={kv_mount}", "-format=json", secret_path], - env={"BAO_NAMESPACE": namespace}, - next_fix=( - "Check BAO_ADDR, BAO_NAMESPACE, the KV mount, and the logical secret path. " - "If the KV mount is not the default, re-run with --kv-mount." - ), - ) - try: - return json.loads(result.stdout) - except json.JSONDecodeError as exc: - raise NodeiwestError(f"Failed to parse `bao kv get` JSON output: {exc}") from exc - - -def derive_openbao_policy(namespace: str, kv_mount: str, secret_path: str) -> str: - result = run_command( - ["bao", "kv", "get", f"-mount={kv_mount}", "-output-policy", secret_path], - env={"BAO_NAMESPACE": namespace}, - next_fix=( - "Check BAO_ADDR, BAO_NAMESPACE, the KV mount, and the logical secret path. " - "If the KV mount is not the default, re-run with --kv-mount. " - "If policy derivation still does not match your mount layout, re-run with --kv-mount-path." - ), - ) - policy = result.stdout.strip() - if not policy: - raise NodeiwestError("`bao kv get -output-policy` returned an empty policy.") - return ensure_trailing_newline(policy) - - -def build_approle_write_command(auth_path: str, role_name: str, policy_name: str, cidrs: list[str]) -> list[str]: - command = [ - "bao", - "write", - f"{auth_path}/role/{role_name}", - f"token_policies={policy_name}", - "token_ttl=1h", - "token_max_ttl=24h", - "token_num_uses=0", - "secret_id_num_uses=0", - ] - if cidrs: - csv = ",".join(cidrs) - command.extend([ - f"token_bound_cidrs={csv}", - f"secret_id_bound_cidrs={csv}", - ]) - return command - - -def build_install_context(repo_root: Path, args: argparse.Namespace) -> dict[str, Any]: - validate_host_name(args.name) - flake_text = (repo_root / "flake.nix").read_text() - if not flake_has_nixos_configuration(flake_text, args.name): - raise NodeiwestError( - f"flake.nix does not define nixosConfigurations.{args.name}.\nAdd this block:\n{build_nixos_configuration_snippet(args.name)}" - ) - - ip = args.ip or lookup_colmena_target_host(flake_text, args.name) - if not ip: - raise NodeiwestError( - f"Could not determine an IP for {args.name}. Pass --ip or add a colmena targetHost.\n" - + build_colmena_host_snippet(args.name, "") - ) - - host_dir = repo_root / "hosts" / args.name - configuration_path = host_dir / "configuration.nix" - disko_path = host_dir / "disko.nix" - hardware_path = host_dir / "hardware-configuration.nix" - bootstrap_dir = resolve_path(repo_root, args.bootstrap_dir) - role_id_path = bootstrap_dir / "var" / "lib" / "nodeiwest" / "openbao-approle-role-id" - secret_id_path = bootstrap_dir / "var" / "lib" / "nodeiwest" / "openbao-approle-secret-id" - - required_paths = [configuration_path, disko_path, role_id_path, secret_id_path] - missing = [path for path in required_paths if not path.exists()] - if missing: - formatted = "\n".join(f" - {path}" for path in missing) - raise NodeiwestError(f"Install prerequisites are missing:\n{formatted}") - - if args.generate_hardware_config == "off" and not hardware_path.exists(): - raise NodeiwestError( - f"{hardware_path.relative_to(repo_root)} is missing and --generate-hardware-config=off was requested." - ) - - command = [ - "nix", - "run", - "github:nix-community/nixos-anywhere", - "--", - "--extra-files", - str(bootstrap_dir), - ] - if args.copy_host_keys == "on": - command.append("--copy-host-keys") - if args.generate_hardware_config == "on": - command.extend([ - "--generate-hardware-config", - "nixos-generate-config", - str(hardware_path), - ]) - command.extend([ - "--flake", - f".#{args.name}", - f"root@{ip}", - ]) - - return { - "ip": ip, - "command": command, - "configuration_path": configuration_path, - "disko_path": disko_path, - "hardware_path": hardware_path, - "role_id_path": role_id_path, - "secret_id_path": secret_id_path, - "colmena_missing": not flake_has_colmena_host(flake_text, args.name), - } - - -def print_install_plan(context: dict[str, Any]) -> None: - print("Install command:") - print(shlex.join(context["command"])) - print("") - print("Preflight checklist:") - print(" - provider snapshot taken") - print(" - application/data backup taken") - print(" - public SSH reachable") - print(" - host keys may change after install") - print("") - print("Validated files:") - print(f" - {context['configuration_path']}") - print(f" - {context['disko_path']}") - if context["hardware_path"].exists(): - print(f" - {context['hardware_path']}") - print(f" - {context['role_id_path']}") - print(f" - {context['secret_id_path']}") - if context["colmena_missing"]: - print("") - print("colmena host block is missing. Add this before the first deploy:") - print(build_colmena_host_snippet(Path(context["configuration_path"]).parent.name, context["ip"])) - - -def ensure_ssh_reachable(ip: str, user: str) -> None: - ssh_command( - user, - ip, - "true", - next_fix="Check public SSH reachability, host keys, and the target user before running nixos-anywhere.", - ) - - -def ssh_command( - user: str, - ip: str, - remote_command: str, - *, - check: bool = True, - next_fix: str | None = None, -) -> subprocess.CompletedProcess[str]: - return run_command( - [ - "ssh", - "-o", - "BatchMode=yes", - "-o", - "ConnectTimeout=10", - f"{user}@{ip}", - remote_command, - ], - check=check, - next_fix=next_fix or "Check SSH reachability and authentication before retrying.", - ) - - -def classify_systemd_status(result: subprocess.CompletedProcess[str]) -> str: - text = f"{result.stdout}\n{result.stderr}".lower() - if "active (running)" in text or "active (exited)" in text: - return "active" - if "failed" in text: - return "failed" - if "inactive" in text: - return "inactive" - return "unknown" - - -def infer_verify_failures( - service_results: dict[str, subprocess.CompletedProcess[str]], - tailscale_status: subprocess.CompletedProcess[str], -) -> list[str]: - messages: list[str] = [] - combined = "\n".join( - (result.stdout or "") + "\n" + (result.stderr or "") - for result in [*service_results.values(), tailscale_status] - ).lower() - - if any(path in combined for path in ["openbao-approle-role-id", "openbao-approle-secret-id", "no such file"]): - messages.append("Missing AppRole files on the host. Check /var/lib/nodeiwest/openbao-approle-role-id and ...secret-id.") - if any(fragment in combined for fragment in ["invalid secret id", "permission denied", "approle", "failed to authenticate"]): - messages.append("OpenBao AppRole authentication failed. Re-check the role, secret_id, namespace, and auth mount.") - if any(fragment in combined for fragment in ["CLIENT_SECRET", "timed out waiting for rendered tailscale auth key", "no data", "secret path"]): - messages.append("OpenBao rendered no Tailscale auth key. Check the secret path, KV mount path, and CLIENT_SECRET field.") - if tailscale_status.returncode != 0 or "logged out" in (tailscale_status.stdout or "").lower(): - messages.append("Tailscale autoconnect is blocked. Check tailscaled-autoconnect, the rendered auth key, and outbound access to Tailscale.") - - deduped: list[str] = [] - for message in messages: - if message not in deduped: - deduped.append(message) - return deduped - - -def summarize_text(text: str, lines: int) -> str: - cleaned = [line.rstrip() for line in text.splitlines() if line.strip()] - return "\n".join(cleaned[:lines]) - - -def resolve_path(repo_root: Path, value: str) -> Path: - path = Path(value) - return path if path.is_absolute() else (repo_root / path) - - -def parse_on_off(value: str | None, default: bool) -> bool: - if value is None: - return default - return value == "on" - - -def confirm(prompt: str) -> bool: - answer = input(prompt).strip().lower() - return answer in {"y", "yes"} - - -def extract_single_match( - text: str, - pattern: str, - path: Path, - label: str, - *, - flags: int = 0, -) -> str: - match = re.search(pattern, text, flags) - if not match: - raise NodeiwestError(f"Could not parse {label} from {path}; manual intervention is required.") - return match.group(1) - - -def extract_optional_match(text: str, pattern: str, *, flags: int = re.S) -> str | None: - match = re.search(pattern, text, flags) - return match.group(1) if match else None - - -def extract_nix_string_list(text: str, pattern: str, path: Path) -> list[str]: - match = re.search(pattern, text, re.S) - if not match: - raise NodeiwestError(f"Could not parse nodeiwest.ssh.userCAPublicKeys from {path}.") - values = re.findall(r'"((?:[^"\\]|\\.)*)"', match.group(1)) - return [value.replace('\\"', '"').replace("\\\\", "\\") for value in values] - - -def run_command( - command: list[str], - *, - cwd: Path | None = None, - env: dict[str, str] | None = None, - check: bool = True, - next_fix: str | None = None, -) -> subprocess.CompletedProcess[str]: - merged_env = os.environ.copy() - if env: - merged_env.update(env) - result = subprocess.run( - command, - cwd=str(cwd) if cwd else None, - env=merged_env, - text=True, - capture_output=True, - ) - if check and result.returncode != 0: - raise NodeiwestError(format_command_failure(command, result, next_fix)) - return result - - -def stream_command( - command: list[str], - *, - cwd: Path | None = None, - env: dict[str, str] | None = None, - next_fix: str | None = None, - activity_label: str | None = None, -) -> None: - merged_env = os.environ.copy() - if env: - merged_env.update(env) - indicator = BottomActivityIndicator(activity_label) if activity_label else None - process = subprocess.Popen( - command, - cwd=str(cwd) if cwd else None, - env=merged_env, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - ) - if process.stdout is None: - raise NodeiwestError(f"Failed to open output stream for command: {shlex.join(command)}") - - if indicator is not None: - indicator.start() - - stdout_fd = process.stdout.fileno() - os.set_blocking(stdout_fd, False) - - try: - while True: - if indicator is not None: - indicator.render() - - ready, _, _ = select.select([stdout_fd], [], [], 0.1) - if ready: - chunk = read_process_chunk(stdout_fd) - if chunk: - write_output_chunk(chunk) - if indicator is not None: - indicator.render(force=True) - continue - break - - if process.poll() is not None: - chunk = read_process_chunk(stdout_fd) - if chunk: - write_output_chunk(chunk) - continue - break - finally: - process.stdout.close() - if indicator is not None: - indicator.stop() - - return_code = process.wait() - if return_code != 0: - raise NodeiwestError( - f"Command failed: {shlex.join(command)}\nExit code: {return_code}\n" - + (f"Next likely fix: {next_fix}" if next_fix else "") - ) - - -class BottomActivityIndicator: - def __init__(self, label: str, stream: Any | None = None) -> None: - self.label = label - self.stream = stream or choose_activity_stream() - self.enabled = bool(self.stream and supports_ansi_status(self.stream)) - self.rows = 0 - self.frame_index = 0 - self.last_render_at = 0.0 - - def start(self) -> None: - if not self.enabled: - return - self.rows = shutil.get_terminal_size(fallback=(80, 24)).lines - if self.rows < 2: - self.enabled = False - return - self.stream.write("\033[?25l") - self.stream.write(f"\033[1;{self.rows - 1}r") - self.stream.flush() - self.render(force=True) - - def render(self, *, force: bool = False) -> None: - if not self.enabled: - return - now = time.monotonic() - if not force and (now - self.last_render_at) < 0.12: - return - - rows = shutil.get_terminal_size(fallback=(80, 24)).lines - if rows != self.rows and rows >= 2: - self.rows = rows - self.stream.write(f"\033[1;{self.rows - 1}r") - - frame = format_activity_frame(self.label, ACTIVITY_FRAMES[self.frame_index]) - self.frame_index = (self.frame_index + 1) % len(ACTIVITY_FRAMES) - self.stream.write("\0337") - self.stream.write(f"\033[{self.rows};1H\033[2K{frame}") - self.stream.write("\0338") - self.stream.flush() - self.last_render_at = now - - def stop(self) -> None: - if not self.enabled: - return - self.stream.write("\0337") - self.stream.write(f"\033[{self.rows};1H\033[2K") - self.stream.write("\0338") - self.stream.write("\033[r") - self.stream.write("\033[?25h") - self.stream.flush() - - -def choose_activity_stream() -> Any | None: - if getattr(sys.stderr, "isatty", lambda: False)(): - return sys.stderr - if getattr(sys.stdout, "isatty", lambda: False)(): - return sys.stdout - return None - - -def supports_ansi_status(stream: Any) -> bool: - return bool(getattr(stream, "isatty", lambda: False)() and os.environ.get("TERM", "") not in {"", "dumb"}) - - -def format_activity_frame(label: str, active_index: int) -> str: - blocks = [] - for index in range(4): - if index == active_index: - blocks.append("\033[38;5;220mâ–ˆ\033[0m") - else: - blocks.append("\033[38;5;208mâ–ˆ\033[0m") - return f"{''.join(blocks)} \033[1;37m{label}\033[0m" - - -def read_process_chunk(fd: int) -> bytes: - try: - return os.read(fd, 4096) - except BlockingIOError: - return b"" - - -def write_output_chunk(chunk: bytes) -> None: - if hasattr(sys.stdout, "buffer"): - sys.stdout.buffer.write(chunk) - sys.stdout.buffer.flush() - return - sys.stdout.write(chunk.decode(errors="replace")) - sys.stdout.flush() - - -def format_command_failure( - command: list[str], - result: subprocess.CompletedProcess[str], - next_fix: str | None, -) -> str: - pieces = [ - f"Command failed: {shlex.join(command)}", - f"Exit code: {result.returncode}", - ] - stdout = summarize_text(result.stdout or "", 20) - stderr = summarize_text(result.stderr or "", 20) - if stdout: - pieces.append(f"stdout:\n{stdout}") - if stderr: - pieces.append(f"stderr:\n{stderr}") - if next_fix: - pieces.append(f"Next likely fix: {next_fix}") - return "\n".join(pieces) - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/pkgs/helpers/default.nix b/pkgs/helpers/default.nix deleted file mode 100644 index 4476ed9..0000000 --- a/pkgs/helpers/default.nix +++ /dev/null @@ -1,32 +0,0 @@ -{ - lib, - writeShellApplication, - python3, - openbao, - openssh, - gitMinimal, - nix, -}: -writeShellApplication { - name = "nodeiwest"; - - runtimeInputs = [ - python3 - openbao - openssh - gitMinimal - nix - ]; - - text = '' - export NODEIWEST_HELPER_TEMPLATES=${./templates} - exec ${python3}/bin/python ${./cli.py} "$@" - ''; - - meta = with lib; { - description = "Safe VPS provisioning helper for the NodeiWest NixOS flake"; - license = licenses.mit; - mainProgram = "nodeiwest"; - platforms = platforms.unix; - }; -} diff --git a/pkgs/helpers/templates/configuration.nix.tmpl b/pkgs/helpers/templates/configuration.nix.tmpl deleted file mode 100644 index 3b5b2b9..0000000 --- a/pkgs/helpers/templates/configuration.nix.tmpl +++ /dev/null @@ -1,23 +0,0 @@ -{ lib, ... }: -{ - # Generated by nodeiwest host init. - imports = [ - ./disko.nix - ./hardware-configuration.nix - ]; - - networking.hostName = "@@HOST_NAME@@"; - networking.useDHCP = lib.mkDefault true; - - time.timeZone = "@@TIMEZONE@@"; - -@@BOOT_LOADER_BLOCK@@ - - nodeiwest.ssh.userCAPublicKeys = @@SSH_CA_KEYS@@; - - nodeiwest.tailscale.openbao = { - enable = @@TAILSCALE_OPENBAO_ENABLE@@; - }; - - system.stateVersion = "@@STATE_VERSION@@"; -} diff --git a/pkgs/helpers/templates/disko-bios-ext4.nix b/pkgs/helpers/templates/disko-bios-ext4.nix deleted file mode 100644 index b8ac109..0000000 --- a/pkgs/helpers/templates/disko-bios-ext4.nix +++ /dev/null @@ -1,41 +0,0 @@ -{ - lib, - ... -}: -{ - # Generated by nodeiwest host init. - # Replace the disk only if the provider exposes a different primary device. - disko.devices = { - disk.main = { - type = "disk"; - device = lib.mkDefault "@@DISK_DEVICE@@"; - content = { - type = "gpt"; - partitions = { - BIOS = { - priority = 1; - name = "BIOS"; - start = "1MiB"; - end = "2MiB"; - type = "EF02"; - }; - swap = { - size = "@@SWAP_SIZE@@"; - content = { - type = "swap"; - resumeDevice = true; - }; - }; - root = { - size = "100%"; - content = { - type = "filesystem"; - format = "ext4"; - mountpoint = "/"; - }; - }; - }; - }; - }; - }; -} diff --git a/pkgs/helpers/templates/disko-uefi-ext4.nix b/pkgs/helpers/templates/disko-uefi-ext4.nix deleted file mode 100644 index 3677816..0000000 --- a/pkgs/helpers/templates/disko-uefi-ext4.nix +++ /dev/null @@ -1,47 +0,0 @@ -{ - lib, - ... -}: -{ - # Generated by nodeiwest host init. - # Replace the disk only if the provider exposes a different primary device. - disko.devices = { - disk.main = { - type = "disk"; - device = lib.mkDefault "@@DISK_DEVICE@@"; - content = { - type = "gpt"; - partitions = { - ESP = { - priority = 1; - name = "ESP"; - start = "1MiB"; - end = "512MiB"; - type = "EF00"; - content = { - type = "filesystem"; - format = "vfat"; - mountpoint = "/boot"; - mountOptions = [ "umask=0077" ]; - }; - }; - swap = { - size = "@@SWAP_SIZE@@"; - content = { - type = "swap"; - resumeDevice = true; - }; - }; - root = { - size = "100%"; - content = { - type = "filesystem"; - format = "ext4"; - mountpoint = "/"; - }; - }; - }; - }; - }; - }; -} diff --git a/pkgs/helpers/templates/hardware-configuration.placeholder.nix b/pkgs/helpers/templates/hardware-configuration.placeholder.nix deleted file mode 100644 index 3f6bc7b..0000000 --- a/pkgs/helpers/templates/hardware-configuration.placeholder.nix +++ /dev/null @@ -1,5 +0,0 @@ -{ ... }: -{ - # Placeholder generated by nodeiwest host init. - # nixos-anywhere will replace this with the generated hardware config. -} diff --git a/pkgs/helpers/templates/openbao-policy.hcl.tmpl b/pkgs/helpers/templates/openbao-policy.hcl.tmpl deleted file mode 100644 index 5466886..0000000 --- a/pkgs/helpers/templates/openbao-policy.hcl.tmpl +++ /dev/null @@ -1,3 +0,0 @@ -path "@@POLICY_PATH@@" { - capabilities = ["read"] -} diff --git a/pkgs/helpers/tests/test_cli.py b/pkgs/helpers/tests/test_cli.py deleted file mode 100644 index b33609c..0000000 --- a/pkgs/helpers/tests/test_cli.py +++ /dev/null @@ -1,114 +0,0 @@ -from __future__ import annotations - -import importlib.util -import sys -import unittest -from unittest import mock -from pathlib import Path - - -REPO_ROOT = Path(__file__).resolve().parents[3] -CLI_PATH = REPO_ROOT / "pkgs" / "helpers" / "cli.py" - -spec = importlib.util.spec_from_file_location("nodeiwest_cli", CLI_PATH) -cli = importlib.util.module_from_spec(spec) -assert spec.loader is not None -sys.modules[spec.name] = cli -spec.loader.exec_module(cli) - - -class HelperCliTests(unittest.TestCase): - def test_format_activity_frame_highlights_one_block_and_keeps_label(self) -> None: - frame = cli.format_activity_frame("Executing install", 2) - - self.assertIn("Executing install", frame) - self.assertEqual(frame.count("â–ˆ"), 4) - self.assertEqual(frame.count("[38;5;220m"), 1) - self.assertEqual(frame.count("[38;5;208m"), 3) - - def test_supports_ansi_status_requires_tty_and_real_term(self) -> None: - tty_stream = mock.Mock() - tty_stream.isatty.return_value = True - dumb_stream = mock.Mock() - dumb_stream.isatty.return_value = True - pipe_stream = mock.Mock() - pipe_stream.isatty.return_value = False - - with mock.patch.dict(cli.os.environ, {"TERM": "xterm-256color"}, clear=False): - self.assertTrue(cli.supports_ansi_status(tty_stream)) - self.assertFalse(cli.supports_ansi_status(pipe_stream)) - - with mock.patch.dict(cli.os.environ, {"TERM": "dumb"}, clear=False): - self.assertFalse(cli.supports_ansi_status(dumb_stream)) - - def test_disk_from_device_supports_sd_and_nvme(self) -> None: - self.assertEqual(cli.disk_from_device("/dev/sda2"), "/dev/sda") - self.assertEqual(cli.disk_from_device("/dev/nvme0n1p2"), "/dev/nvme0n1") - - def test_lookup_colmena_target_host_reads_existing_inventory(self) -> None: - flake_text = (REPO_ROOT / "flake.nix").read_text() - self.assertEqual(cli.lookup_colmena_target_host(flake_text, "vps1"), "100.101.167.118") - - def test_parse_existing_vps1_configuration(self) -> None: - configuration = cli.parse_existing_configuration(REPO_ROOT / "hosts" / "vps1" / "configuration.nix") - self.assertEqual(configuration.host_name, "vps1") - self.assertEqual(configuration.boot_mode, "uefi") - self.assertTrue(configuration.tailscale_openbao) - self.assertEqual(configuration.state_version, "25.05") - self.assertTrue(configuration.user_ca_public_keys) - - def test_parse_existing_vps1_disko(self) -> None: - disko = cli.parse_existing_disko(REPO_ROOT / "hosts" / "vps1" / "disko.nix") - self.assertEqual(disko.disk_device, "/dev/sda") - self.assertEqual(disko.boot_mode, "uefi") - self.assertEqual(disko.swap_size, "4G") - - def test_render_bios_disko_uses_bios_partition(self) -> None: - rendered = cli.render_disko(boot_mode="bios", disk_device="/dev/vda", swap_size="8G") - self.assertIn('type = "EF02";', rendered) - self.assertIn('device = lib.mkDefault "/dev/vda";', rendered) - self.assertIn('size = "8G";', rendered) - - def test_parse_lsblk_output_reads_pairs_without_smearing_columns(self) -> None: - output = ( - 'NAME="sda" SIZE="11G" TYPE="disk" MODEL="QEMU HARDDISK" FSTYPE="" PTTYPE="gpt" MOUNTPOINTS=""\n' - 'NAME="sda1" SIZE="512M" TYPE="part" MODEL="" FSTYPE="vfat" PTTYPE="" MOUNTPOINTS="/boot"\n' - ) - rows = cli.parse_lsblk_output(output) - - self.assertEqual(rows[0]["NAME"], "sda") - self.assertEqual(rows[0]["SIZE"], "11G") - self.assertEqual(rows[0]["MODEL"], "QEMU HARDDISK") - self.assertEqual(rows[1]["NAME"], "sda1") - self.assertEqual(rows[1]["MOUNTPOINTS"], "/boot") - - def test_normalize_swap_size_accepts_gib_suffix(self) -> None: - self.assertEqual(cli.normalize_swap_size("4GiB"), "4G") - self.assertEqual(cli.normalize_swap_size("512MiB"), "512M") - self.assertEqual(cli.normalize_swap_size("8G"), "8G") - - def test_bao_kv_get_uses_explicit_kv_mount(self) -> None: - completed = mock.Mock() - completed.stdout = '{"data": {"data": {"CLIENT_ID": "x"}}}' - with mock.patch.object(cli, "run_command", return_value=completed) as run_command: - data = cli.bao_kv_get("it", "kv", "tailscale") - - self.assertEqual(data["data"]["data"]["CLIENT_ID"], "x") - command = run_command.call_args.args[0] - self.assertEqual(command, ["bao", "kv", "get", "-mount=kv", "-format=json", "tailscale"]) - self.assertEqual(run_command.call_args.kwargs["env"], {"BAO_NAMESPACE": "it"}) - - def test_derive_openbao_policy_uses_explicit_kv_mount(self) -> None: - completed = mock.Mock() - completed.stdout = 'path "kv/data/tailscale" { capabilities = ["read"] }\n' - with mock.patch.object(cli, "run_command", return_value=completed) as run_command: - policy = cli.derive_openbao_policy("it", "kv", "tailscale") - - self.assertIn('path "kv/data/tailscale"', policy) - command = run_command.call_args.args[0] - self.assertEqual(command, ["bao", "kv", "get", "-mount=kv", "-output-policy", "tailscale"]) - self.assertEqual(run_command.call_args.kwargs["env"], {"BAO_NAMESPACE": "it"}) - - -if __name__ == "__main__": - unittest.main()