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 b6559c5..65ea1cb 100644 Binary files a/pkgs/helpers/__pycache__/cli.cpython-313.pyc and b/pkgs/helpers/__pycache__/cli.cpython-313.pyc differ 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 eb5ac18..10427fe 100644 Binary files a/pkgs/helpers/tests/__pycache__/test_cli.cpython-313.pyc and b/pkgs/helpers/tests/__pycache__/test_cli.cpython-313.pyc differ 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"}}}'