feat: add spinner

This commit is contained in:
eric
2026-03-18 13:28:51 +01:00
parent f150afec0a
commit f558ab4ba9
12 changed files with 287 additions and 15 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
bootstrap/

View File

@@ -17,7 +17,7 @@ This repo currently provisions NixOS hosts with:
- New machines are installed with `nixos-anywhere` - New machines are installed with `nixos-anywhere`
- Ongoing changes are deployed with `colmena` - Ongoing changes are deployed with `colmena`
- Hosts authenticate to OpenBao as clients - 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 - Public SSH must work independently of Tailscale for first access and recovery
## Repo Layout ## Repo Layout
@@ -230,7 +230,7 @@ The host uses:
- KV mount: `kv` - KV mount: `kv`
- auth mount: `auth/approle` - auth mount: `auth/approle`
- secret path: `tailscale` - secret path: `tailscale`
- field: `auth_key` - field: `CLIENT_SECRET`
The host stores: The host stores:
@@ -342,7 +342,7 @@ On first boot:
1. `vault-agent-tailscale.service` starts using `pkgs.openbao` 1. `vault-agent-tailscale.service` starts using `pkgs.openbao`
2. it authenticates to OpenBao with AppRole 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 4. `nodeiwest-tailscale-authkey-ready.service` waits until that file exists
5. `tailscaled-autoconnect.service` uses that file and runs `tailscale up --ssh` 5. `tailscaled-autoconnect.service` uses that file and runs `tailscale up --ssh`
@@ -379,7 +379,7 @@ Typical causes:
- wrong OpenBao policy - wrong OpenBao policy
- wrong secret path - wrong secret path
- wrong KV mount path - wrong KV mount path
- `auth_key` field missing in the secret - `CLIENT_SECRET` field missing in the secret
## Deploy Changes After Install ## Deploy Changes After Install

View File

@@ -85,6 +85,7 @@
nixosConfigurations = { nixosConfigurations = {
vps1 = mkHost "vps1"; vps1 = mkHost "vps1";
lab = mkHost "lab";
}; };
colmena = { colmena = {
@@ -118,6 +119,21 @@
imports = [ ./hosts/vps1/configuration.nix ]; 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; colmenaHive = colmena.lib.makeHive self.outputs.colmena;

View File

@@ -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";
}

47
hosts/lab/disko.nix Normal file
View File

@@ -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 = "/";
};
};
};
};
};
};
}

View File

@@ -0,0 +1,5 @@
{ ... }:
{
# Placeholder generated by nodeiwest host init.
# nixos-anywhere will replace this with the generated hardware config.
}

View File

@@ -25,7 +25,7 @@
}; };
}; };
swap = { swap = {
size = "4GiB"; size = "4G";
content = { content = {
type = "swap"; type = "swap";
resumeDevice = true; resumeDevice = true;

View File

@@ -32,7 +32,7 @@ in
field = lib.mkOption { field = lib.mkOption {
type = lib.types.str; type = lib.types.str;
default = "auth_key"; default = "CLIENT_SECRET";
description = "Field in the OpenBao secret that contains the Tailscale auth key."; description = "Field in the OpenBao secret that contains the Tailscale auth key.";
}; };
@@ -147,10 +147,7 @@ in
{ {
assertion = assertion =
(!tailscaleOpenbaoCfg.enable) (!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."; message = "AppRole roleIdFile and secretIdFile must be set when OpenBao-backed Tailscale enrollment is enabled.";
} }
]; ];

View File

@@ -11,9 +11,11 @@ import os
import re import re
import shlex import shlex
import shutil import shutil
import select
import subprocess import subprocess
import sys import sys
import tempfile import tempfile
import time
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -22,6 +24,7 @@ SUPPORTED_CONFIG_MARKER = "Generated by nodeiwest host init."
SUPPORTED_DISKO_MARKER = "Generated by nodeiwest host init." SUPPORTED_DISKO_MARKER = "Generated by nodeiwest host init."
DEFAULT_STATE_VERSION = "25.05" DEFAULT_STATE_VERSION = "25.05"
BOOT_MODE_CHOICES = ("uefi", "bios") BOOT_MODE_CHOICES = ("uefi", "bios")
ACTIVITY_FRAMES = (0, 1, 2, 3, 2, 1)
class NodeiwestError(RuntimeError): 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("--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("--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("--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("--timezone", help="Time zone. Default for new hosts: UTC.")
init_parser.add_argument( init_parser.add_argument(
"--tailscale-openbao", "--tailscale-openbao",
@@ -267,7 +270,7 @@ def cmd_host_init(args: argparse.Namespace) -> int:
file=sys.stderr, 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") 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) 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 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"], install_context["command"],
cwd=repo_root, cwd=repo_root,
next_fix="Recover via provider console or public SSH, then re-check the generated host files and bootstrap material.", 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("")
@@ -705,6 +709,21 @@ def parse_swaps(output: str) -> list[str]:
return [line.split()[0] for line in lines[1:]] 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: def parse_existing_configuration(path: Path) -> ExistingConfiguration:
text = path.read_text() text = path.read_text()
if "./disko.nix" not in text or "./hardware-configuration.nix" not in 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, cwd: Path | None = None,
env: dict[str, str] | None = None, env: dict[str, str] | None = None,
next_fix: str | None = None, next_fix: str | None = None,
activity_label: str | None = None,
) -> None: ) -> None:
merged_env = os.environ.copy() merged_env = os.environ.copy()
if env: if env:
merged_env.update(env) merged_env.update(env)
indicator = BottomActivityIndicator(activity_label) if activity_label else None
process = subprocess.Popen( process = subprocess.Popen(
command, command,
cwd=str(cwd) if cwd else None, cwd=str(cwd) if cwd else None,
env=merged_env, 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() return_code = process.wait()
if return_code != 0: if return_code != 0:
raise NodeiwestError( 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( def format_command_failure(
command: list[str], command: list[str],
result: subprocess.CompletedProcess[str], result: subprocess.CompletedProcess[str],

View File

@@ -18,6 +18,29 @@ spec.loader.exec_module(cli)
class HelperCliTests(unittest.TestCase): 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: 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/sda2"), "/dev/sda")
self.assertEqual(cli.disk_from_device("/dev/nvme0n1p2"), "/dev/nvme0n1") 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") disko = cli.parse_existing_disko(REPO_ROOT / "hosts" / "vps1" / "disko.nix")
self.assertEqual(disko.disk_device, "/dev/sda") self.assertEqual(disko.disk_device, "/dev/sda")
self.assertEqual(disko.boot_mode, "uefi") 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: 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('type = "EF02";', rendered)
self.assertIn('device = lib.mkDefault "/dev/vda";', 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: def test_parse_lsblk_output_reads_pairs_without_smearing_columns(self) -> None:
output = ( output = (
@@ -59,6 +82,11 @@ class HelperCliTests(unittest.TestCase):
self.assertEqual(rows[1]["NAME"], "sda1") self.assertEqual(rows[1]["NAME"], "sda1")
self.assertEqual(rows[1]["MOUNTPOINTS"], "/boot") 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: def test_bao_kv_get_uses_explicit_kv_mount(self) -> None:
completed = mock.Mock() completed = mock.Mock()
completed.stdout = '{"data": {"data": {"CLIENT_ID": "x"}}}' completed.stdout = '{"data": {"data": {"CLIENT_ID": "x"}}}'