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`
|
- 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
|
||||||
|
|
||||||
|
|||||||
16
flake.nix
16
flake.nix
@@ -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;
|
||||||
|
|||||||
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 = {
|
swap = {
|
||||||
size = "4GiB";
|
size = "4G";
|
||||||
content = {
|
content = {
|
||||||
type = "swap";
|
type = "swap";
|
||||||
resumeDevice = true;
|
resumeDevice = true;
|
||||||
|
|||||||
@@ -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.";
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|||||||
Binary file not shown.
@@ -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],
|
||||||
|
|||||||
Binary file not shown.
@@ -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"}}}'
|
||||||
|
|||||||
Reference in New Issue
Block a user