Merge pull request 'add nixos-facter to flash installer' (#2149) from flash-installer into main

This commit is contained in:
clan-bot
2024-09-24 10:59:27 +00:00
14 changed files with 460 additions and 41 deletions

View File

@@ -5,6 +5,8 @@
setuptools,
util-linux,
systemd,
colorama,
junit-xml,
}:
buildPythonApplication {
pname = "test-driver";
@@ -12,6 +14,8 @@ buildPythonApplication {
propagatedBuildInputs = [
util-linux
systemd
colorama
junit-xml
] ++ extraPythonPackages python3Packages;
nativeBuildInputs = [ setuptools ];
format = "pyproject";

View File

@@ -5,10 +5,13 @@ import subprocess
import time
import types
from collections.abc import Callable
from contextlib import _GeneratorContextManager
from pathlib import Path
from tempfile import TemporaryDirectory
from typing import Any
from .logger import AbstractLogger, CompositeLogger, TerminalLogger
class Error(Exception):
pass
@@ -42,12 +45,20 @@ def retry(fn: Callable, timeout: int = 900) -> None:
class Machine:
def __init__(self, name: str, toplevel: Path, rootdir: Path, out_dir: str) -> None:
def __init__(
self,
name: str,
toplevel: Path,
logger: AbstractLogger,
rootdir: Path,
out_dir: str,
) -> None:
self.name = name
self.toplevel = toplevel
self.out_dir = out_dir
self.process: subprocess.Popen | None = None
self.rootdir: Path = rootdir
self.logger = logger
def start(self) -> None:
prepare_machine_root(self.name, self.rootdir)
@@ -78,7 +89,10 @@ class Machine:
assert self.process.stdout is not None, "Machine has no stdout"
for line in self.process.stdout:
print(line, end="")
if line.startswith("systemd[1]: Startup finished in"):
if (
line.startswith("systemd[1]: Startup finished in")
or "Welcome to NixOS" in line
):
break
else:
msg = f"Failed to start container {self.name}"
@@ -184,6 +198,15 @@ class Machine:
)
return proc
def nested(
self, msg: str, attrs: dict[str, str] | None = None
) -> _GeneratorContextManager:
if attrs is None:
attrs = {}
my_attrs = {"machine": self.name}
my_attrs.update(attrs)
return self.logger.nested(msg, my_attrs)
def systemctl(self, q: str) -> subprocess.CompletedProcess:
"""
Runs `systemctl` commands with optional support for
@@ -200,6 +223,25 @@ class Machine:
"""
return self.execute(f"systemctl {q}")
def wait_until_succeeds(self, command: str, timeout: int = 900) -> str:
"""
Repeat a shell command with 1-second intervals until it succeeds.
Has a default timeout of 900 seconds which can be modified, e.g.
`wait_until_succeeds(cmd, timeout=10)`. See `execute` for details on
command execution.
Throws an exception on timeout.
"""
output = ""
def check_success(_: Any) -> bool:
nonlocal output
result = self.execute(command, timeout=timeout)
return result.returncode == 0
with self.nested(f"waiting for success: {command}"):
retry(check_success, timeout)
return output
def wait_for_unit(self, unit: str, timeout: int = 900) -> None:
"""
Wait for a systemd unit to get into "active" state.
@@ -257,10 +299,19 @@ def setup_filesystems() -> None:
class Driver:
def __init__(self, containers: list[Path], testscript: str, out_dir: str) -> None:
logger: AbstractLogger
def __init__(
self,
containers: list[Path],
logger: AbstractLogger,
testscript: str,
out_dir: str,
) -> None:
self.containers = containers
self.testscript = testscript
self.out_dir = out_dir
self.logger = logger
setup_filesystems()
self.tempdir = TemporaryDirectory()
@@ -279,6 +330,7 @@ class Driver:
toplevel=container,
rootdir=tempdir_path / name,
out_dir=self.out_dir,
logger=self.logger,
)
)
@@ -364,9 +416,11 @@ def main() -> None:
type=writeable_dir,
)
args = arg_parser.parse_args()
logger = CompositeLogger([TerminalLogger()])
with Driver(
args.containers,
args.test_script.read_text(),
args.output_directory.resolve(),
containers=args.containers,
testscript=args.test_script.read_text(),
out_dir=args.output_directory.resolve(),
logger=logger,
) as driver:
driver.run_tests()

View File

@@ -0,0 +1,335 @@
import atexit
import codecs
import os
import sys
import time
import unicodedata
from abc import ABC, abstractmethod
from collections.abc import Iterator
from contextlib import ExitStack, contextmanager
from pathlib import Path
from queue import Empty, Queue
from typing import Any
from xml.sax.saxutils import XMLGenerator
from xml.sax.xmlreader import AttributesImpl
from colorama import Fore, Style
from junit_xml import TestCase, TestSuite
class AbstractLogger(ABC):
@abstractmethod
def log(self, message: str, attributes: dict[str, str] | None = None) -> None:
pass
@abstractmethod
@contextmanager
def subtest(
self, name: str, attributes: dict[str, str] | None = None
) -> Iterator[None]:
pass
@abstractmethod
@contextmanager
def nested(
self, message: str, attributes: dict[str, str] | None = None
) -> Iterator[None]:
pass
@abstractmethod
def info(self, *args: Any, **kwargs: Any) -> None: # type: ignore
pass
@abstractmethod
def warning(self, *args: Any, **kwargs: Any) -> None: # type: ignore
pass
@abstractmethod
def error(self, *args: Any, **kwargs: Any) -> None: # type: ignore
pass
@abstractmethod
def log_serial(self, message: str, machine: str) -> None:
pass
@abstractmethod
def print_serial_logs(self, enable: bool) -> None:
pass
class JunitXMLLogger(AbstractLogger):
class TestCaseState:
def __init__(self) -> None:
self.stdout = ""
self.stderr = ""
self.failure = False
def __init__(self, outfile: Path) -> None:
self.tests: dict[str, JunitXMLLogger.TestCaseState] = {
"main": self.TestCaseState()
}
self.currentSubtest = "main"
self.outfile: Path = outfile
self._print_serial_logs = True
atexit.register(self.close)
def log(self, message: str, attributes: dict[str, str] | None = None) -> None:
self.tests[self.currentSubtest].stdout += message + os.linesep
@contextmanager
def subtest(
self, name: str, attributes: dict[str, str] | None = None
) -> Iterator[None]:
old_test = self.currentSubtest
self.tests.setdefault(name, self.TestCaseState())
self.currentSubtest = name
yield
self.currentSubtest = old_test
@contextmanager
def nested(
self, message: str, attributes: dict[str, str] | None = None
) -> Iterator[None]:
self.log(message)
yield
def info(self, *args: Any, **kwargs: Any) -> None:
self.tests[self.currentSubtest].stdout += args[0] + os.linesep
def warning(self, *args: Any, **kwargs: Any) -> None:
self.tests[self.currentSubtest].stdout += args[0] + os.linesep
def error(self, *args: Any, **kwargs: Any) -> None:
self.tests[self.currentSubtest].stderr += args[0] + os.linesep
self.tests[self.currentSubtest].failure = True
def log_serial(self, message: str, machine: str) -> None:
if not self._print_serial_logs:
return
self.log(f"{machine} # {message}")
def print_serial_logs(self, enable: bool) -> None:
self._print_serial_logs = enable
def close(self) -> None:
with Path.open(self.outfile, "w") as f:
test_cases = []
for name, test_case_state in self.tests.items():
tc = TestCase(
name,
stdout=test_case_state.stdout,
stderr=test_case_state.stderr,
)
if test_case_state.failure:
tc.add_failure_info("test case failed")
test_cases.append(tc)
ts = TestSuite("NixOS integration test", test_cases)
f.write(TestSuite.to_xml_string([ts]))
class CompositeLogger(AbstractLogger):
def __init__(self, logger_list: list[AbstractLogger]) -> None:
self.logger_list = logger_list
def add_logger(self, logger: AbstractLogger) -> None:
self.logger_list.append(logger)
def log(self, message: str, attributes: dict[str, str] | None = None) -> None:
for logger in self.logger_list:
logger.log(message, attributes)
@contextmanager
def subtest(
self, name: str, attributes: dict[str, str] | None = None
) -> Iterator[None]:
with ExitStack() as stack:
for logger in self.logger_list:
stack.enter_context(logger.subtest(name, attributes))
yield
@contextmanager
def nested(
self, message: str, attributes: dict[str, str] | None = None
) -> Iterator[None]:
with ExitStack() as stack:
for logger in self.logger_list:
stack.enter_context(logger.nested(message, attributes))
yield
def info(self, *args: Any, **kwargs: Any) -> None: # type: ignore
for logger in self.logger_list:
logger.info(*args, **kwargs)
def warning(self, *args: Any, **kwargs: Any) -> None: # type: ignore
for logger in self.logger_list:
logger.warning(*args, **kwargs)
def error(self, *args: Any, **kwargs: Any) -> None: # type: ignore
for logger in self.logger_list:
logger.error(*args, **kwargs)
sys.exit(1)
def print_serial_logs(self, enable: bool) -> None:
for logger in self.logger_list:
logger.print_serial_logs(enable)
def log_serial(self, message: str, machine: str) -> None:
for logger in self.logger_list:
logger.log_serial(message, machine)
class TerminalLogger(AbstractLogger):
def __init__(self) -> None:
self._print_serial_logs = True
def maybe_prefix(self, message: str, attributes: dict[str, str] | None) -> str:
if attributes and "machine" in attributes:
return f"{attributes['machine']}: {message}"
return message
@staticmethod
def _eprint(*args: object, **kwargs: Any) -> None:
print(*args, file=sys.stderr, **kwargs)
def log(self, message: str, attributes: dict[str, str] | None = None) -> None:
self._eprint(self.maybe_prefix(message, attributes))
@contextmanager
def subtest(
self, name: str, attributes: dict[str, str] | None = None
) -> Iterator[None]:
with self.nested("subtest: " + name, attributes):
yield
@contextmanager
def nested(
self, message: str, attributes: dict[str, str] | None = None
) -> Iterator[None]:
self._eprint(
self.maybe_prefix(
Style.BRIGHT + Fore.GREEN + message + Style.RESET_ALL, attributes
)
)
tic = time.time()
yield
toc = time.time()
self.log(f"(finished: {message}, in {toc - tic:.2f} seconds)")
def info(self, *args: Any, **kwargs: Any) -> None: # type: ignore
self.log(*args, **kwargs)
def warning(self, *args: Any, **kwargs: Any) -> None: # type: ignore
self.log(*args, **kwargs)
def error(self, *args: Any, **kwargs: Any) -> None: # type: ignore
self.log(*args, **kwargs)
def print_serial_logs(self, enable: bool) -> None:
self._print_serial_logs = enable
def log_serial(self, message: str, machine: str) -> None:
if not self._print_serial_logs:
return
self._eprint(Style.DIM + f"{machine} # {message}" + Style.RESET_ALL)
class XMLLogger(AbstractLogger):
def __init__(self, outfile: str) -> None:
self.logfile_handle = codecs.open(outfile, "wb")
self.xml = XMLGenerator(self.logfile_handle, encoding="utf-8")
self.queue: Queue[dict[str, str]] = Queue()
self._print_serial_logs = True
self.xml.startDocument()
self.xml.startElement("logfile", attrs=AttributesImpl({}))
def close(self) -> None:
self.xml.endElement("logfile")
self.xml.endDocument()
self.logfile_handle.close()
def sanitise(self, message: str) -> str:
return "".join(ch for ch in message if unicodedata.category(ch)[0] != "C")
def maybe_prefix(
self, message: str, attributes: dict[str, str] | None = None
) -> str:
if attributes and "machine" in attributes:
return f"{attributes['machine']}: {message}"
return message
def log_line(self, message: str, attributes: dict[str, str]) -> None:
self.xml.startElement("line", attrs=AttributesImpl(attributes))
self.xml.characters(message)
self.xml.endElement("line")
def info(self, *args: Any, **kwargs: Any) -> None: # type: ignore
self.log(*args, **kwargs)
def warning(self, *args: Any, **kwargs: Any) -> None: # type: ignore
self.log(*args, **kwargs)
def error(self, *args: Any, **kwargs: Any) -> None: # type: ignore
self.log(*args, **kwargs)
def log(self, message: str, attributes: dict[str, str] | None = None) -> None:
if attributes is None:
attributes = {}
self.drain_log_queue()
self.log_line(message, attributes)
def print_serial_logs(self, enable: bool) -> None:
self._print_serial_logs = enable
def log_serial(self, message: str, machine: str) -> None:
if not self._print_serial_logs:
return
self.enqueue({"msg": message, "machine": machine, "type": "serial"})
def enqueue(self, item: dict[str, str]) -> None:
self.queue.put(item)
def drain_log_queue(self) -> None:
try:
while True:
item = self.queue.get_nowait()
msg = self.sanitise(item["msg"])
del item["msg"]
self.log_line(msg, item)
except Empty:
pass
@contextmanager
def subtest(
self, name: str, attributes: dict[str, str] | None = None
) -> Iterator[None]:
with self.nested("subtest: " + name, attributes):
yield
@contextmanager
def nested(
self, message: str, attributes: dict[str, str] | None = None
) -> Iterator[None]:
if attributes is None:
attributes = {}
self.xml.startElement("nest", attrs=AttributesImpl({}))
self.xml.startElement("head", attrs=AttributesImpl(attributes))
self.xml.characters(message)
self.xml.endElement("head")
tic = time.time()
self.drain_log_queue()
yield
self.drain_log_queue()
toc = time.time()
self.log(f"(finished: {message}, in {toc - tic:.2f} seconds)")
self.xml.endElement("nest")

View File

@@ -70,7 +70,7 @@
start_all()
machine.wait_for_unit("matrix-synapse")
machine.succeed("${pkgs.netcat}/bin/nc -z -v ::1 8008")
machine.succeed("${pkgs.curl}/bin/curl -Ssf -L http://localhost/_matrix/static/ -H 'Host: matrix.clan.test'")
machine.wait_until_succeeds("${pkgs.curl}/bin/curl -Ssf -L http://localhost/_matrix/static/ -H 'Host: matrix.clan.test'")
machine.systemctl("restart matrix-synapse >&2") # check if user creation is idempotent
machine.execute("journalctl -u matrix-synapse --no-pager >&2")

View File

@@ -52,7 +52,7 @@ nav:
- Disk Encryption: getting-started/disk-encryption.md
- Mesh VPN: getting-started/mesh-vpn.md
- Backup & Restore: getting-started/backups.md
- Include Machines: manual/include-machines.md
- Adding Machines: manual/adding-machines.md
- Inventory: manual/inventory.md
- Secrets: manual/secrets.md
- Secure Boot: manual/secure-boot.md

View File

@@ -1,4 +1,4 @@
# Include Machines
# Adding Machines
Clan has two general methods of adding machines

View File

@@ -23,7 +23,7 @@ Instructions and explanations for practical Implementations ordered by Topics.
**How-to Guides for achieving a certain goal or solving a specific issue.**
- [Include Machines](./include-machines.md): Learn how Clan automatically includes machines and Nix files.
- [Adding Machines](./adding-machines.md): Learn how Clan automatically includes machines and Nix files.
- [Secrets](./secrets.md): Learn how to manage secrets.

View File

@@ -14,7 +14,7 @@ This guide will walk you through setting up a backup service, where the inventor
## Prerequisites
- [x] [Add machines](./include-machines.md) to your clan.
- [x] [Add machines](./adding-machines.md) to your clan.
## Services

26
flake.lock generated
View File

@@ -7,11 +7,11 @@
]
},
"locked": {
"lastModified": 1726842196,
"narHash": "sha256-u9h03JQUuQJ607xmti9F9Eh6E96kKUAGP+aXWgwm70o=",
"lastModified": 1727156717,
"narHash": "sha256-Ef7UgoTdOB4PGQKSkHGu6SOxnTiArPHGcRf8qGFC39o=",
"owner": "nix-community",
"repo": "disko",
"rev": "51994df8ba24d5db5459ccf17b6494643301ad28",
"rev": "c61e50b63ad50dda5797b1593ad7771be496efbb",
"type": "github"
},
"original": {
@@ -63,11 +63,11 @@
]
},
"locked": {
"lastModified": 1727020761,
"narHash": "sha256-hDH9XlbsNAoTmdMn//s0OOIyHOjF0RIAFLaiy9nWq9I=",
"lastModified": 1727055034,
"narHash": "sha256-nRy1zsY8HPIGgyfQFsopkC9kmpxC9Dl/+POs7RYMug0=",
"owner": "nix-community",
"repo": "nixos-images",
"rev": "776ee2484dcf6c8a667b1b918981493ee976dba9",
"rev": "1420644027326490d330828b941a8e612b9cc130",
"type": "github"
},
"original": {
@@ -78,16 +78,16 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1725814378,
"narHash": "sha256-cwnCIninNWySL3ruFH5iVFnx/Fr0xL44NOLzvf1s2tc=",
"lastModified": 1727089097,
"narHash": "sha256-ZMHMThPsthhUREwDebXw7GX45bJnBCVbfnH1g5iuSPc=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "61ddb09cfaa7424d7fc8e3040ccd5c8c6f875b15",
"rev": "568bfef547c14ca438c56a0bece08b8bb2b71a9c",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable-small",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
@@ -147,11 +147,11 @@
]
},
"locked": {
"lastModified": 1726734507,
"narHash": "sha256-VUH5O5AcOSxb0uL/m34dDkxFKP6WLQ6y4I1B4+N3L2w=",
"lastModified": 1727098951,
"narHash": "sha256-gplorAc0ISAUPemUNOnRUs7jr3WiLiHZb3DJh++IkZs=",
"owner": "numtide",
"repo": "treefmt-nix",
"rev": "ee41a466c2255a3abe6bc50fc6be927cdee57a9f",
"rev": "35dfece10c642eb52928a48bee7ac06a59f93e9a",
"type": "github"
},
"original": {

View File

@@ -2,7 +2,7 @@
description = "clan.lol base operating system";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable-small";
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
disko.url = "github:nix-community/disko";
disko.inputs.nixpkgs.follows = "nixpkgs";
sops-nix.url = "github:Mic92/sops-nix";

View File

@@ -1,15 +1,11 @@
{
lib,
pkgs,
config,
...
}:
{
# If we also need zfs, we can use the unstable version as we otherwise don't have a new enough kernel version
boot.zfs.package = pkgs.zfsUnstable;
boot.kernelPackages = lib.mkIf config.boot.zfs.enabled (
lib.mkForce config.boot.zfs.package.latestCompatibleLinuxPackages
);
# Enable bcachefs support
boot.supportedFilesystems.bcachefs = lib.mkDefault true;

View File

@@ -47,8 +47,11 @@ in
(modulesPath + "/profiles/installation-device.nix")
(modulesPath + "/profiles/all-hardware.nix")
(modulesPath + "/profiles/base.nix")
./zfs-latest.nix
];
environment.systemPackages = [ pkgs.nixos-facter ];
########################################################################################################
# #
# Copied from: #

View File

@@ -0,0 +1,28 @@
{
lib,
pkgs,
config,
...
}:
let
isUnstable = config.boot.zfs.package == pkgs.zfsUnstable;
zfsCompatibleKernelPackages = lib.filterAttrs (
name: kernelPackages:
(builtins.match "linux_[0-9]+_[0-9]+" name) != null
&& (builtins.tryEval kernelPackages).success
&& (
(!isUnstable && !kernelPackages.zfs.meta.broken)
|| (isUnstable && !kernelPackages.zfs_unstable.meta.broken)
)
) pkgs.linuxKernel.packages;
latestKernelPackage = lib.last (
lib.sort (a: b: (lib.versionOlder a.kernel.version b.kernel.version)) (
builtins.attrValues zfsCompatibleKernelPackages
)
);
in
{
# Note this might jump back and worth as kernel get added or removed.
boot.kernelPackages = latestKernelPackage;
}

View File

@@ -117,13 +117,16 @@ def generate_machine_hardware_info(
if hostname is not None:
machine.target_host_address = hostname
nixos_generate_cmd = [
"nixos-generate-config", # Filesystems are managed by disko
config_command = (
["nixos-facter"]
if report_type == "nixos-facter"
else [
"nixos-generate-config",
# Filesystems are managed by disko
"--no-filesystems",
"--show-hardware-config",
]
nixos_facter_cmd = ["nix", "run", "--refresh", "github:numtide/nixos-facter"]
)
host = machine.target_host
target_host = f"{host.user or 'root'}@{host.host}"
@@ -148,11 +151,7 @@ def generate_machine_hardware_info(
else []
),
target_host,
*(
nixos_generate_cmd
if report_type == "nixos-generate-config"
else nixos_facter_cmd
),
*config_command,
],
)
out = run(cmd)