From f558ab4ba9302a1fad4e11aee86f45e376cdf707 Mon Sep 17 00:00:00 2001 From: eric Date: Wed, 18 Mar 2026 13:28:51 +0100 Subject: [PATCH] feat: add spinner --- .gitignore | 1 + README.md | 8 +- flake.nix | 16 ++ hosts/lab/configuration.nix | 30 ++++ hosts/lab/disko.nix | 47 ++++++ hosts/lab/hardware-configuration.nix | 5 + hosts/vps1/disko.nix | 2 +- modules/nixos/tailscale-init.nix | 7 +- pkgs/helpers/__pycache__/cli.cpython-313.pyc | Bin 70098 -> 78473 bytes pkgs/helpers/cli.py | 152 +++++++++++++++++- .../__pycache__/test_cli.cpython-313.pyc | Bin 7628 -> 10366 bytes pkgs/helpers/tests/test_cli.py | 34 +++- 12 files changed, 287 insertions(+), 15 deletions(-) create mode 100644 .gitignore create mode 100644 hosts/lab/configuration.nix create mode 100644 hosts/lab/disko.nix create mode 100644 hosts/lab/hardware-configuration.nix diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ee6fed2 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +bootstrap/ \ No newline at end of file diff --git a/README.md b/README.md index 0ebdd84..6ef7e44 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ This repo currently provisions NixOS hosts with: - 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 `auth_key` +- 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 ## Repo Layout @@ -230,7 +230,7 @@ The host uses: - KV mount: `kv` - auth mount: `auth/approle` - secret path: `tailscale` -- field: `auth_key` +- field: `CLIENT_SECRET` The host stores: @@ -342,7 +342,7 @@ On first boot: 1. `vault-agent-tailscale.service` starts using `pkgs.openbao` 2. it authenticates to OpenBao with AppRole -3. it renders `auth_key` from namespace `it`, KV mount `kv`, path `tailscale` to `/run/nodeiwest/tailscale-auth-key` +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` @@ -379,7 +379,7 @@ Typical causes: - wrong OpenBao policy - wrong secret path - wrong KV mount path -- `auth_key` field missing in the secret +- `CLIENT_SECRET` field missing in the secret ## Deploy Changes After Install diff --git a/flake.nix b/flake.nix index 1ee2ba2..068e9db 100644 --- a/flake.nix +++ b/flake.nix @@ -85,6 +85,7 @@ nixosConfigurations = { vps1 = mkHost "vps1"; + lab = mkHost "lab"; }; colmena = { @@ -118,6 +119,21 @@ 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 new file mode 100644 index 0000000..f0de599 --- /dev/null +++ b/hosts/lab/configuration.nix @@ -0,0 +1,30 @@ +{ 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 new file mode 100644 index 0000000..d703537 --- /dev/null +++ b/hosts/lab/disko.nix @@ -0,0 +1,47 @@ +{ + 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 new file mode 100644 index 0000000..3f6bc7b --- /dev/null +++ b/hosts/lab/hardware-configuration.nix @@ -0,0 +1,5 @@ +{ ... }: +{ + # Placeholder generated by nodeiwest host init. + # nixos-anywhere will replace this with the generated hardware config. +} diff --git a/hosts/vps1/disko.nix b/hosts/vps1/disko.nix index 05d3249..eee0690 100644 --- a/hosts/vps1/disko.nix +++ b/hosts/vps1/disko.nix @@ -25,7 +25,7 @@ }; }; swap = { - size = "4GiB"; + size = "4G"; content = { type = "swap"; resumeDevice = true; diff --git a/modules/nixos/tailscale-init.nix b/modules/nixos/tailscale-init.nix index a6c4654..374c5af 100644 --- a/modules/nixos/tailscale-init.nix +++ b/modules/nixos/tailscale-init.nix @@ -32,7 +32,7 @@ in field = lib.mkOption { type = lib.types.str; - default = "auth_key"; + default = "CLIENT_SECRET"; description = "Field in the OpenBao secret that contains the Tailscale auth key."; }; @@ -147,10 +147,7 @@ in { assertion = (!tailscaleOpenbaoCfg.enable) - || ( - tailscaleOpenbaoCfg.approle.roleIdFile != "" - && tailscaleOpenbaoCfg.approle.secretIdFile != "" - ); + || (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 index b6559c56458f4b1c9df30ff9e07dace7117a37ee..65ea1cb161728d6482272992776d93d80d0c9bb5 100644 GIT binary patch delta 19158 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 diff --git a/pkgs/helpers/cli.py b/pkgs/helpers/cli.py index 0276e4f..4d2c467 100644 --- a/pkgs/helpers/cli.py +++ b/pkgs/helpers/cli.py @@ -11,9 +11,11 @@ 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 @@ -22,6 +24,7 @@ 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): @@ -110,7 +113,7 @@ def build_parser() -> argparse.ArgumentParser: 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: 4GiB.") + 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", @@ -267,7 +270,7 @@ def cmd_host_init(args: argparse.Namespace) -> int: file=sys.stderr, ) - swap_size = args.swap_size or (existing_disko.swap_size if existing_disko else "4GiB") + 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 @@ -466,6 +469,7 @@ def cmd_install_run(args: argparse.Namespace) -> int: 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("") @@ -705,6 +709,21 @@ def parse_swaps(output: str) -> list[str]: 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: @@ -1309,15 +1328,54 @@ def stream_command( 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( @@ -1326,6 +1384,96 @@ def stream_command( ) +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], diff --git a/pkgs/helpers/tests/__pycache__/test_cli.cpython-313.pyc b/pkgs/helpers/tests/__pycache__/test_cli.cpython-313.pyc index eb5ac18fce1e03661393ecadd9d105e13f1326a7..10427fe13d9ef7fbda709a7a8d8b396f180cb1bb 100644 GIT binary patch delta 3297 zcmbtWU2GKB6`nhPJNwHn>s`ESI~YSu*2HG*c#VrO)OKvs^4EA-k^sqMyq-0KXMcQW z)_|*KDV0*YsshPXevpu$eW>U|B_})-^(m=>)CV5y+GyQr9-^p1qLtc6v4oULQO})a zZE8%bs=u3|uJmJ8iD!BVUd!U4r3z%xMOrB!k%` z!d!Awl8rKFdCUXjY$gEXZ6*Q}Y~}za+DroGu$dE>WHT2qr_J2JTsHFnbKA@d%wsdB zFX_db#(gnAxzFxmqlg@KHEl|wB%5GqqzscoDRPkub0~$#n~v?sNq)jTNA9{J!2D9k zi7IMIkV@u}L{CRYJo|uJS{1~*vseT{Ot84Lu34eXa2{tBQ&!TZHl>*t@P$p#B?4m_JrJV5){GXErh^pV7w8=yBM)$q`P?5Y z`(q3K*nCUx?a4)d-;D5uKDi%f2AX@V?HvHw+V zHw<~VwM$l{L=jvSfxoi|mSZS6>OrSp0o#}Jf-t}58# z$BNm}msG8q*^sbu~5iks2U|_Xfjm1Sv0CuIi%=@8sl-8+Hip*n8&J;wcIq6)j>fk zsE-B)4XzO`Tuaw<+6A_e3mL1E#gc|qL#B3Vp@L;yrq6B-Y*eOCwX(612JmmN(4C+( zLy-?&efL#zYR0u3Yn^eGrO1L5S(2JdpNFF#XzyxdV#dAP(l+BROVI@>x+LxVw~5`4 zP5i|hxO{T<$A9_Q?6GSD<@(Nr z`p&tNi}l}K^7MhY?5SVy)L%=M8xji*iMi6mVnhFuXW*g0_)ee|iScdT$oO1lzP0z0 z6t#WmNh4Jo9OFBT=Dmj=I;pXH-qbe_!Qf$FhrTf)@HT$`e4lf02lrXmvx5ol&JNdL zhr@9v;Tqx`A#pz_gQwnQzN!=W(10 z4**zf=Wxt{1LUkb+FLP9rEdF)Di4GEsl;iVQwp-7O{+1slHh`Eu5#SKLy}M3BWNF~ z@ubBa_oUtS^F_$}o{s};_i&7}t>DAd?-6p!+ui4(wiP@Q5sAFpm|Li@xjmBmS0YKUY!{y-ih2Zv^nNM2gUpYM=+&&+CjbwZ~omX-< z&QxNeFLcfqWLfMdcl?d}Y_FRO3N}<(H%9ddIbSpjMbqA0h{`Tyu_Cj0oIht6^r)~a zmTU{WjSiBzz%RGffhX8(bGT%*jOI5+$M9=90o%uZ6D(@u@0k6YU*Gw0?`Hcbo(fM6;H1!5lr+b$EsH>c-#>(0V-QcG>M@oo0!?clf|a zs|Dy{;~Mm(VkV=VxAtz-Mb<5T%GwllOk=#rte(Nwg5^kjArhYpFGjj=?^u!!RHi!M zL|>h&U5xC#J+veZJh`l9G17ILzbExpGU=ASSz-m;1Ww4Mnod+8zpZIPlf_f{7syY(jdqo2gTt*9UZBuJVVJ@(3MVL}DV(Kn ziNa3-tdMLUd0JYAa};Z%MaVE&#P{KL}0mXZ3s9 zehyYGKC5WCb>1y$Up7Bh)Ybj?SD-_S(kPRE)c+=Y5}}vTl~>B4=7mu63S$qynkI7% IwLFdeUk7Gm^Z)<= delta 1353 zcmZ9MUuYaf9LI0=b~pDwxx3t^7ZdM#y<9kJW3E&UNsTm8np$J7UNlupw|jeY$>#QU z&zZe61|cYz3W`+*eN<2oUlcS?`c&T)QnV=Ri%-&mNWlX|P@LbKHQ+vc_|DAE@B5wK z-rV2SujdlK#p96xf8L0GX}vr6S;CQa8hrRp;9`KFT7XD3M1r+gO&Ses$VBEUD3rpL zER@QXBGdp^s!$=W280T86%s1KRamHKEka^z(Q@2REMmKZ$z!&)f~iMu4|8jl4QlMq z=yw%AYEX)aclw2{Vf#VTw)~-nMO%84xNW_GH!TyFkuOthHz|Uhx8!B^SFDLLtPwxO z9#!vQ*W=gKLB2$W*lwbbO>yZ*nzqrxx`($sD)EVYo}}4ViCvUsi-RYT#>Pgnd+!al z5IV#n$r2i3m1N;)7OFrgKfLZzPj`$q_QQ48_4Kyez<#73c7=2?yONw86IOB4b(+>% zml&SqItOmwBhM!&f_*q_s->Hu z3engpHOnS4pQq&+a1M$(xDV~hR7Bp*lA$W&*X>tZ8_`Q zwGH)`apS<*&^FYgye>V%ZfL^;m-f>~_R~81MVpN5Yo(7vBF^5}$o-OZ8?nc;`NP5} z)o+4L>vl_byI!a3i4Vksq}c1(QIuvMW#2><_TcCTywChNd*Shrl<;{%uS*<#)3Eu+ z(sC!zGCP+mpel26_fGrrl54hnm3j?c)K@x&XRiCITi?K@*FR#UZn%1DQ(wc}k$sWN zX`-WH(`|Qb?BNDE2W>jfe$9>W6l%VX=J%}p&r(LrKLLwR0+s<));Kb;vhv^fX;9B_ zY$uNOk9Dl$+Lrkuslv)C-~vZEMr6>>0-ggr54Z>r4a%^WihVip>W1eLtKRi6)pfD~FK7VpAkn*kvUG}LH_($Lu@#VH*IsbF>Lnryg?b>*r zT;=b;9|*n4-kSS&#y9?6vK0gV`VC*wv+1mR?=H6AZ|s GBK$vT_++*K diff --git a/pkgs/helpers/tests/test_cli.py b/pkgs/helpers/tests/test_cli.py index 7893708..b33609c 100644 --- a/pkgs/helpers/tests/test_cli.py +++ b/pkgs/helpers/tests/test_cli.py @@ -18,6 +18,29 @@ 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") @@ -38,13 +61,13 @@ class HelperCliTests(unittest.TestCase): 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, "4GiB") + 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="8GiB") + 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 = "8GiB";', rendered) + self.assertIn('size = "8G";', rendered) def test_parse_lsblk_output_reads_pairs_without_smearing_columns(self) -> None: output = ( @@ -59,6 +82,11 @@ class HelperCliTests(unittest.TestCase): 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"}}}'