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`
- 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

View File

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

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 = {
size = "4GiB";
size = "4G";
content = {
type = "swap";
resumeDevice = true;

View File

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

View File

@@ -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],

View File

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