feat: add spinner
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
bootstrap/
|
||||
@@ -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
|
||||
|
||||
|
||||
16
flake.nix
16
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;
|
||||
|
||||
30
hosts/lab/configuration.nix
Normal file
30
hosts/lab/configuration.nix
Normal 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
47
hosts/lab/disko.nix
Normal 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 = "/";
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
5
hosts/lab/hardware-configuration.nix
Normal file
5
hosts/lab/hardware-configuration.nix
Normal file
@@ -0,0 +1,5 @@
|
||||
{ ... }:
|
||||
{
|
||||
# Placeholder generated by nodeiwest host init.
|
||||
# nixos-anywhere will replace this with the generated hardware config.
|
||||
}
|
||||
@@ -25,7 +25,7 @@
|
||||
};
|
||||
};
|
||||
swap = {
|
||||
size = "4GiB";
|
||||
size = "4G";
|
||||
content = {
|
||||
type = "swap";
|
||||
resumeDevice = true;
|
||||
|
||||
@@ -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.";
|
||||
}
|
||||
];
|
||||
|
||||
Binary file not shown.
@@ -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],
|
||||
|
||||
Binary file not shown.
@@ -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"}}}'
|
||||
|
||||
Reference in New Issue
Block a user