From b102aeb54751714a5015e527c7c24ce61c4daaae Mon Sep 17 00:00:00 2001 From: Felix Uhl Date: Mon, 28 Oct 2024 22:44:33 +0100 Subject: [PATCH 01/37] disko2: Set up dev environment for python --- .envrc | 1 + .gitignore | 2 ++ .vscode/extensions.json | 10 ++++++++++ flake.nix | 16 ++++++++++++++++ 4 files changed, 29 insertions(+) create mode 100644 .envrc create mode 100644 .vscode/extensions.json diff --git a/.envrc b/.envrc new file mode 100644 index 00000000..8392d159 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake \ No newline at end of file diff --git a/.gitignore b/.gitignore index 31b0a4b6..7f8dcda6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ result +.direnv + # Created by the NixOS interactive test driver .nixos-test-history \ No newline at end of file diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 00000000..16e6537b --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,10 @@ +{ + "recommendations": [ + "mkhl.direnv", + "jnoortheen.nix-ide", + "ms-python.python", + "ms-python.debugpy", + "ms-python.black-formatter", + "ms-python.isort" + ] +} \ No newline at end of file diff --git a/flake.nix b/flake.nix index ae51fc59..d1b984e9 100644 --- a/flake.nix +++ b/flake.nix @@ -72,6 +72,22 @@ inherit (self.packages.${system}) disko-doc; }); + devShells = forAllSystems (system: + let + pkgs = nixpkgs.legacyPackages.${system}; + in + { + default = pkgs.mkShell { + name = "disko-dev"; + packages = with pkgs; [ + (python3.withPackages (ps: [ + ps.black # Code formatter + ps.isort # Import sorter + ])) + ]; + }; + }); + nixosConfigurations.testmachine = lib.nixosSystem { system = "x86_64-linux"; modules = [ From b752b835b644ea663079e7574f05fb965697e1ce Mon Sep 17 00:00:00 2001 From: Felix Uhl Date: Mon, 28 Oct 2024 22:55:47 +0100 Subject: [PATCH 02/37] disko2: Set up basic argument parsing --- disko2 | 93 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100755 disko2 diff --git a/disko2 b/disko2 new file mode 100755 index 00000000..9a90c7c7 --- /dev/null +++ b/disko2 @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 + +import argparse +from enum import Enum +from pathlib import Path + + +class Mode(Enum): + DESTROY = "destroy" + FORMAT = "format" + MOUNT = "mount" + DESTROY_FORMAT_MOUNT = "destroy,format,mount" + FORMAT_MOUNT = "format,mount" + GENERATE = "generate" + + +# Modes to apply an existing configuration +APPLY_MODES = [ + Mode.DESTROY, + Mode.FORMAT, + Mode.MOUNT, + Mode.DESTROY_FORMAT_MOUNT, + Mode.FORMAT_MOUNT, +] + +MODE_DESCRIPTION = { + Mode.DESTROY: "Destroy the partition tables on the specified disks", + Mode.FORMAT: "Change formatting and filesystems on the specified disks", + Mode.MOUNT: "Mount the specified disks", + Mode.DESTROY_FORMAT_MOUNT: "Run destroy, format and mount in sequence", + Mode.FORMAT_MOUNT: "Run format and mount in sequence", + Mode.GENERATE: "Generate a disko configuration file from the system's current state", +} + + +def run_apply(*, mode: str, disko_file=Path | None, flake=str): + print(f"{mode=} {disko_file=} {flake=}") + + +def run_generate(): + print("generate") + + +def parse_args(): + root_parser = argparse.ArgumentParser( + prog="disko2", + description="Automated disk partitioning and formatting tool for NixOS", + ) + + mode_parsers = root_parser.add_subparsers(dest="mode") + + def create_apply_parser(mode: Mode) -> argparse.ArgumentParser: + parser = mode_parsers.add_parser( + mode.value, + help=MODE_DESCRIPTION[mode], + ) + parser.add_argument( + "disko_file", + nargs="?", + default=None, + help="Path to the disko configuration file", + ) + parser.add_argument( + "--flake", + "-f", + help="Flake to fetch the disko configuration from", + ) + return parser + + # Commands to apply an existing configuration + apply_parsers = [create_apply_parser(mode) for mode in APPLY_MODES] + + # Other commands + generate_parser = mode_parsers.add_parser( + "generate", + help=MODE_DESCRIPTION[Mode.GENERATE], + ) + return root_parser.parse_args() + + +def main(): + args = parse_args() + if args.mode == None: + print("No mode specified") + exit(1) + elif args.mode == "generate": + run_generate() + else: + run_apply(**vars(args)) + + +if __name__ == "__main__": + main() From 8cf1486b17111c342b50711bf981f578929b1601 Mon Sep 17 00:00:00 2001 From: Felix Uhl Date: Tue, 29 Oct 2024 14:53:37 +0100 Subject: [PATCH 03/37] dev: Add mypy type checker --- .vscode/extensions.json | 3 ++- flake.nix | 1 + pyproject.toml | 2 ++ 3 files changed, 5 insertions(+), 1 deletion(-) create mode 100644 pyproject.toml diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 16e6537b..e36dd5d7 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -5,6 +5,7 @@ "ms-python.python", "ms-python.debugpy", "ms-python.black-formatter", - "ms-python.isort" + "ms-python.isort", + "ms-python.mypy-type-checker" ] } \ No newline at end of file diff --git a/flake.nix b/flake.nix index d1b984e9..d77c8726 100644 --- a/flake.nix +++ b/flake.nix @@ -83,6 +83,7 @@ (python3.withPackages (ps: [ ps.black # Code formatter ps.isort # Import sorter + ps.mypy # Static type checker ])) ]; }; diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..f8ab0005 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,2 @@ +[tool.mypy] +strict = true \ No newline at end of file From db2985165251b338a4167fa96eb650117806952c Mon Sep 17 00:00:00 2001 From: Felix Uhl Date: Tue, 29 Oct 2024 16:30:18 +0100 Subject: [PATCH 04/37] dev: Add vscode launch config for disko2 --- .gitignore | 2 ++ .vscode/launch.json | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+) create mode 100644 .vscode/launch.json diff --git a/.gitignore b/.gitignore index 7f8dcda6..363b3019 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,7 @@ result .direnv +__pycache__/ + # Created by the NixOS interactive test driver .nixos-test-history \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 00000000..87755f63 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,19 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Python Debugger: disko2", + "type": "debugpy", + "request": "launch", + "program": "${workspaceFolder}/disko2", + "console": "integratedTerminal", + "args": [ + "mount", + "example/simple-efi.nix" + ] + } + ] +} \ No newline at end of file From ffa1b89bb0075152bbe1af26ec4ef539690f2123 Mon Sep 17 00:00:00 2001 From: Felix Uhl Date: Tue, 29 Oct 2024 16:45:34 +0100 Subject: [PATCH 05/37] disko2: Add basic logging setup --- disko2 | 94 ++++++++++++++---------- lib/__init__.py | 0 lib/ansi.py | 185 ++++++++++++++++++++++++++++++++++++++++++++++++ lib/logging.py | 179 ++++++++++++++++++++++++++++++++++++++++++++++ lib/result.py | 36 ++++++++++ 5 files changed, 457 insertions(+), 37 deletions(-) create mode 100644 lib/__init__.py create mode 100755 lib/ansi.py create mode 100644 lib/logging.py create mode 100644 lib/result.py diff --git a/disko2 b/disko2 index 9a90c7c7..ad80a764 100755 --- a/disko2 +++ b/disko2 @@ -1,47 +1,71 @@ #!/usr/bin/env python3 import argparse -from enum import Enum from pathlib import Path +from typing import Literal +from lib.logging import DiskoMessage, info +from lib.result import DiskoError, DiskoSuccess, DiskoResult, exit_on_error -class Mode(Enum): - DESTROY = "destroy" - FORMAT = "format" - MOUNT = "mount" - DESTROY_FORMAT_MOUNT = "destroy,format,mount" - FORMAT_MOUNT = "format,mount" - GENERATE = "generate" +Mode = Literal[ + "destroy", "format", "mount", "destroy,format,mount", "format,mount", "generate" +] # Modes to apply an existing configuration -APPLY_MODES = [ - Mode.DESTROY, - Mode.FORMAT, - Mode.MOUNT, - Mode.DESTROY_FORMAT_MOUNT, - Mode.FORMAT_MOUNT, +APPLY_MODES: list[Mode] = [ + "destroy", + "format", + "mount", + "destroy,format,mount", + "format,mount", ] - -MODE_DESCRIPTION = { - Mode.DESTROY: "Destroy the partition tables on the specified disks", - Mode.FORMAT: "Change formatting and filesystems on the specified disks", - Mode.MOUNT: "Mount the specified disks", - Mode.DESTROY_FORMAT_MOUNT: "Run destroy, format and mount in sequence", - Mode.FORMAT_MOUNT: "Run format and mount in sequence", - Mode.GENERATE: "Generate a disko configuration file from the system's current state", +ALL_MODES: list[Mode] = APPLY_MODES + ["generate"] + +MODE_DESCRIPTION: dict[Mode, str] = { + "destroy": "Destroy the partition tables on the specified disks", + "format": "Change formatting and filesystems on the specified disks", + "mount": "Mount the specified disks", + "destroy,format,mount": "Run destroy, format and mount in sequence", + "format,mount": "Run format and mount in sequence", + "generate": "Generate a disko configuration file from the system's current state", } -def run_apply(*, mode: str, disko_file=Path | None, flake=str): - print(f"{mode=} {disko_file=} {flake=}") +def run_apply(*, mode: str, disko_file: Path | None, flake: str | None) -> DiskoResult: + if flake is None and disko_file is None: + return DiskoError([DiskoMessage("ERR_MISSING_ARGUMENTS")], "validate args") + if flake is not None and disko_file is not None: + return DiskoError([DiskoMessage("ERR_TOO_MANY_ARGUMENTS")], "validate args") + + return DiskoSuccess( + { + "mode": mode, + "disko_file": disko_file, + "flake": flake, + }, + "apply configuration", + ) -def run_generate(): - print("generate") +def run_generate() -> DiskoResult: + return DiskoSuccess("generate", "generate config") -def parse_args(): +def run(args: argparse.Namespace) -> DiskoResult: + if args.mode == None: + return DiskoError( + [DiskoMessage("ERR_MISSING_MODE", {"valid_modes": ALL_MODES})], + "select mode", + ) + + if args.mode == "generate": + return run_generate() + + return run_apply(**vars(args)) + + +def parse_args() -> argparse.Namespace: root_parser = argparse.ArgumentParser( prog="disko2", description="Automated disk partitioning and formatting tool for NixOS", @@ -51,7 +75,7 @@ def parse_args(): def create_apply_parser(mode: Mode) -> argparse.ArgumentParser: parser = mode_parsers.add_parser( - mode.value, + mode, help=MODE_DESCRIPTION[mode], ) parser.add_argument( @@ -73,20 +97,16 @@ def parse_args(): # Other commands generate_parser = mode_parsers.add_parser( "generate", - help=MODE_DESCRIPTION[Mode.GENERATE], + help=MODE_DESCRIPTION["generate"], ) return root_parser.parse_args() -def main(): +def main() -> None: args = parse_args() - if args.mode == None: - print("No mode specified") - exit(1) - elif args.mode == "generate": - run_generate() - else: - run_apply(**vars(args)) + result = run(args) + output = exit_on_error(result) + info(str(output)) if __name__ == "__main__": diff --git a/lib/__init__.py b/lib/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/lib/ansi.py b/lib/ansi.py new file mode 100755 index 00000000..7471e880 --- /dev/null +++ b/lib/ansi.py @@ -0,0 +1,185 @@ +#!/usr/bin/env python3 +# ANSI escape sequences for coloring and formatting text +# Inspired by rene-d's colors.py, published in 2018 +# See https://gist.github.com/rene-d/9e584a7dd2935d0f461904b9f2950007 + +# Run as a script to see all available colors and styles + + +class Colors: + """ + ANSI escape sequences + These constants were generated using nushell with the following command: + + nix run nixpkgs#nushell -- -c ' + ansi -l + | where name !~ "^xterm" + | each { |line| + $'"'"'($line.name | str upcase) = "($line.code | str replace "\\e" "\\033")"'"'"' + } + | print -r' + + I then removed many useless codes that are related to cursor movement, clearing the screen, etc. + """ + + GREEN = "\033[32m" + GREEN_BOLD = "\033[1;32m" + GREEN_ITALIC = "\033[3;32m" + GREEN_DIMMED = "\033[2;32m" + GREEN_REVERSE = "\033[7;32m" + BG_GREEN = "\033[42m" + LIGHT_GREEN = "\033[92m" + LIGHT_GREEN_BOLD = "\033[1;92m" + LIGHT_GREEN_UNDERLINE = "\033[4;92m" + LIGHT_GREEN_ITALIC = "\033[3;92m" + LIGHT_GREEN_DIMMED = "\033[2;92m" + LIGHT_GREEN_REVERSE = "\033[7;92m" + BG_LIGHT_GREEN = "\033[102m" + RED = "\033[31m" + RED_BOLD = "\033[1;31m" + RED_UNDERLINE = "\033[4;31m" + RED_ITALIC = "\033[3;31m" + RED_DIMMED = "\033[2;31m" + RED_REVERSE = "\033[7;31m" + BG_RED = "\033[41m" + LIGHT_RED = "\033[91m" + LIGHT_RED_BOLD = "\033[1;91m" + LIGHT_RED_UNDERLINE = "\033[4;91m" + LIGHT_RED_ITALIC = "\033[3;91m" + LIGHT_RED_DIMMED = "\033[2;91m" + LIGHT_RED_REVERSE = "\033[7;91m" + BG_LIGHT_RED = "\033[101m" + BLUE = "\033[34m" + BLUE_BOLD = "\033[1;34m" + BLUE_UNDERLINE = "\033[4;34m" + BLUE_ITALIC = "\033[3;34m" + BLUE_DIMMED = "\033[2;34m" + BLUE_REVERSE = "\033[7;34m" + BG_BLUE = "\033[44m" + LIGHT_BLUE = "\033[94m" + LIGHT_BLUE_BOLD = "\033[1;94m" + LIGHT_BLUE_UNDERLINE = "\033[4;94m" + LIGHT_BLUE_ITALIC = "\033[3;94m" + LIGHT_BLUE_DIMMED = "\033[2;94m" + LIGHT_BLUE_REVERSE = "\033[7;94m" + BG_LIGHT_BLUE = "\033[104m" + BLACK = "\033[30m" + BLACK_BOLD = "\033[1;30m" + BLACK_UNDERLINE = "\033[4;30m" + BLACK_ITALIC = "\033[3;30m" + BLACK_DIMMED = "\033[2;30m" + BLACK_REVERSE = "\033[7;30m" + BG_BLACK = "\033[40m" + LIGHT_GRAY = "\033[97m" + LIGHT_GRAY_BOLD = "\033[1;97m" + LIGHT_GRAY_UNDERLINE = "\033[4;97m" + LIGHT_GRAY_ITALIC = "\033[3;97m" + LIGHT_GRAY_DIMMED = "\033[2;97m" + LIGHT_GRAY_REVERSE = "\033[7;97m" + BG_LIGHT_GRAY = "\033[107m" + YELLOW = "\033[33m" + YELLOW_BOLD = "\033[1;33m" + YELLOW_UNDERLINE = "\033[4;33m" + YELLOW_ITALIC = "\033[3;33m" + YELLOW_DIMMED = "\033[2;33m" + YELLOW_REVERSE = "\033[7;33m" + BG_YELLOW = "\033[43m" + LIGHT_YELLOW = "\033[93m" + LIGHT_YELLOW_BOLD = "\033[1;93m" + LIGHT_YELLOW_UNDERLINE = "\033[4;93m" + LIGHT_YELLOW_ITALIC = "\033[3;93m" + LIGHT_YELLOW_DIMMED = "\033[2;93m" + LIGHT_YELLOW_REVERSE = "\033[7;93m" + BG_LIGHT_YELLOW = "\033[103m" + PURPLE = "\033[35m" + PURPLE_BOLD = "\033[1;35m" + PURPLE_UNDERLINE = "\033[4;35m" + PURPLE_ITALIC = "\033[3;35m" + PURPLE_DIMMED = "\033[2;35m" + PURPLE_REVERSE = "\033[7;35m" + BG_PURPLE = "\033[45m" + LIGHT_PURPLE = "\033[95m" + LIGHT_PURPLE_BOLD = "\033[1;95m" + LIGHT_PURPLE_UNDERLINE = "\033[4;95m" + LIGHT_PURPLE_ITALIC = "\033[3;95m" + LIGHT_PURPLE_DIMMED = "\033[2;95m" + LIGHT_PURPLE_REVERSE = "\033[7;95m" + BG_LIGHT_PURPLE = "\033[105m" + MAGENTA = "\033[35m" + MAGENTA_BOLD = "\033[1;35m" + MAGENTA_UNDERLINE = "\033[4;35m" + MAGENTA_ITALIC = "\033[3;35m" + MAGENTA_DIMMED = "\033[2;35m" + MAGENTA_REVERSE = "\033[7;35m" + BG_MAGENTA = "\033[45m" + LIGHT_MAGENTA = "\033[95m" + LIGHT_MAGENTA_BOLD = "\033[1;95m" + LIGHT_MAGENTA_UNDERLINE = "\033[4;95m" + LIGHT_MAGENTA_ITALIC = "\033[3;95m" + LIGHT_MAGENTA_DIMMED = "\033[2;95m" + LIGHT_MAGENTA_REVERSE = "\033[7;95m" + BG_LIGHT_MAGENTA = "\033[105m" + CYAN = "\033[36m" + CYAN_BOLD = "\033[1;36m" + CYAN_UNDERLINE = "\033[4;36m" + CYAN_ITALIC = "\033[3;36m" + CYAN_DIMMED = "\033[2;36m" + CYAN_REVERSE = "\033[7;36m" + BG_CYAN = "\033[46m" + LIGHT_CYAN = "\033[96m" + LIGHT_CYAN_BOLD = "\033[1;96m" + LIGHT_CYAN_UNDERLINE = "\033[4;96m" + LIGHT_CYAN_ITALIC = "\033[3;96m" + LIGHT_CYAN_DIMMED = "\033[2;96m" + LIGHT_CYAN_REVERSE = "\033[7;96m" + BG_LIGHT_CYAN = "\033[106m" + WHITE = "\033[37m" + WHITE_BOLD = "\033[1;37m" + WHITE_UNDERLINE = "\033[4;37m" + WHITE_ITALIC = "\033[3;37m" + WHITE_DIMMED = "\033[2;37m" + WHITE_REVERSE = "\033[7;37m" + BG_WHITE = "\033[47m" + DARK_GRAY = "\033[90m" + DARK_GRAY_BOLD = "\033[1;90m" + DARK_GRAY_UNDERLINE = "\033[4;90m" + DARK_GRAY_ITALIC = "\033[3;90m" + DARK_GRAY_DIMMED = "\033[2;90m" + DARK_GRAY_REVERSE = "\033[7;90m" + BG_DARK_GRAY = "\033[100m" + DEFAULT = "\033[39m" + DEFAULT_BOLD = "\033[1;39m" + DEFAULT_UNDERLINE = "\033[4;39m" + DEFAULT_ITALIC = "\033[3;39m" + DEFAULT_DIMMED = "\033[2;39m" + DEFAULT_REVERSE = "\033[7;39m" + BG_DEFAULT = "\033[49m" + RESET = "\033[0m" + ATTR_NORMAL = "\033[0m" + ATTR_BOLD = "\033[1m" + ATTR_DIMMED = "\033[2m" + ATTR_ITALIC = "\033[3m" + ATTR_UNDERLINE = "\033[4m" + ATTR_BLINK = "\033[5m" + ATTR_HIDDEN = "\033[8m" + ATTR_STRIKE = "\033[9m" + + # cancel SGR codes if we don't write to a terminal + if not __import__("sys").stdout.isatty(): + for _ in dir(): + if isinstance(_, str) and _[0] != "_": + locals()[_] = "" + else: + # set Windows console in VT mode + if __import__("platform").system() == "Windows": + kernel32 = __import__("ctypes").windll.kernel32 + kernel32.SetConsoleMode(kernel32.GetStdHandle(-11), 7) + del kernel32 + + +if __name__ == "__main__": + import inspect + + for name, value in inspect.getmembers(Colors): + if value != "_" and not name.startswith("_") and name != "RESET": + print("{:>30} {}".format(name, value + name + Colors.RESET)) diff --git a/lib/logging.py b/lib/logging.py new file mode 100644 index 00000000..b8885d2b --- /dev/null +++ b/lib/logging.py @@ -0,0 +1,179 @@ +# Logging functionality and global logging configuration +from dataclasses import dataclass, field +from enum import Enum +import logging +import textwrap +from typing import Any, Literal, assert_never + +from lib.ansi import Colors + +logging.basicConfig(format="%(message)s", level=logging.INFO) +LOGGER = logging.getLogger("disko_logger") + + +# Color definitions. Note: Sort them alphabetically when adding new ones! +COMMAND = Colors.CYAN_ITALIC # Commands that were run or can be run +FILE = Colors.BLUE # File paths +FLAG = Colors.GREEN # Command line flags (like --version or -f) +INVALID = Colors.RED # Invalid values +PLACEHOLDER = Colors.MAGENTA_ITALIC # Values that need to be replaced +VALUE = Colors.GREEN # Values that are allowed + +RESET = Colors.RESET # Shortcut to reset the color + + +@dataclass +class ReadableMessage: + type: Literal["bug", "error", "warning", "info", "help", "debug"] + msg: str + + +# Codes for every single message that disko can print +# Note: Sort them alphabetically when adding new ones! +MessageCode = Literal[ + "BUG_SUCCESS_WITHOUT_CONTEXT", + "ERR_MISSING_ARGUMENTS", + "ERR_MISSING_MODE", + "ERR_TOO_MANY_ARGUMENTS", +] + + +@dataclass +class DiskoMessage: + code: MessageCode + details: dict[str, Any] = field(default_factory=dict) + + +ERR_ARGUMENTS_HELP_TXT = f"Provide either {PLACEHOLDER}disko_file{RESET} as the second argument or \ +{FLAG}--flake{RESET}/{FLAG}-f{RESET} {PLACEHOLDER}flake-uri{RESET}." + + +def bug_help_message(error_code: MessageCode) -> ReadableMessage: + return ReadableMessage( + "help", + f""" + Please report this bug! + First, check if has already been reported at + https://github.com/nix-community/disko/issues?q=is%3Aissue+{error_code} + If not, open a new issue at + https://github.com/nix-community/disko/issues/new?title={error_code} + and include the full logs printed above! + """, + ) + + +def to_readable(message: DiskoMessage) -> list[ReadableMessage]: + match message.code: + case "BUG_SUCCESS_WITHOUT_CONTEXT": + return [ + ReadableMessage( + "bug", + f""" + Success message without context! + Returned value: + {message.details['value']} + """, + ), + bug_help_message(message.code), + ] + case "ERR_MISSING_ARGUMENTS": + return [ + ReadableMessage( + "error", + f"Missing arguments!", + ), + ReadableMessage("help", ERR_ARGUMENTS_HELP_TXT), + ] + case "ERR_TOO_MANY_ARGUMENTS": + return [ + ReadableMessage( + "error", + f"Too many arguments!", + ), + ReadableMessage("help", ERR_ARGUMENTS_HELP_TXT), + ] + case "ERR_MISSING_MODE": + modes_list = "\n".join( + [f" - {VALUE}{m}{RESET}" for m in message.details["valid_modes"]] + ) + return [ + ReadableMessage("error", "Missing mode!"), + ReadableMessage("help", "Allowed modes are:\n" + modes_list), + ] + + # We could also remove these two lines, but assert_never emits a better error message + case _ as c: + assert_never(c) + + +def render_message(message: ReadableMessage) -> None: + bg_color = { + "bug": Colors.BG_RED, + "error": Colors.BG_RED, + "warning": Colors.BG_YELLOW, + "info": Colors.BG_GREEN, + "help": Colors.BG_LIGHT_MAGENTA, + "debug": Colors.BG_LIGHT_CYAN, + }[message.type] + + decor_color = { + "bug": Colors.RED, + "error": Colors.RED, + "warning": Colors.YELLOW, + "info": Colors.GREEN, + "help": Colors.LIGHT_MAGENTA, + "debug": Colors.LIGHT_CYAN, + }[message.type] + + title_raw = { + "bug": "BUG", + "error": "ERROR", + "warning": "WARNING", + "info": "INFO", + "help": "HELP", + "debug": "DEBUG", + }[message.type] + + log_msg = { + "bug": LOGGER.error, + "error": LOGGER.error, + "warning": LOGGER.warning, + "info": LOGGER.info, + "help": LOGGER.info, + "debug": LOGGER.debug, + }[message.type] + + msg_lines = textwrap.dedent(message.msg).splitlines() + + # "WARNING:" is 8 characters long, center in 10 for space on each side + title = f"{bg_color}{title_raw + ":":^10}{RESET}" + + if len(msg_lines) == 1: + log_msg(f" {title} {msg_lines[0]}") + return + + log_msg(f"{decor_color}╭─{title} {msg_lines[0]}") + + for line in msg_lines[1:]: + log_msg(f"{decor_color}│ {RESET} {line}") + + log_msg(f"{decor_color}╰───────────{RESET}") # Exactly as long as the heading + + +def print_msg(code: MessageCode, details: dict[str, Any]) -> None: + for msg in to_readable(DiskoMessage(code, details)): + render_message(msg) + + +def debug(msg: str) -> None: + # Check debug level immediately to avoid unnecessary formatting + if LOGGER.isEnabledFor(logging.DEBUG): + render_message(ReadableMessage("debug", str(msg))) + + +# In general, only debug messages should be logged directly, all other +# messages should be wrapped in a DiskoResult for easier testing +# Info is exposed only for testing during initial development of disko2 +# TODO: Remove this function and use DiskoResult instead +def info(msg: str) -> None: + render_message(ReadableMessage("info", str(msg))) diff --git a/lib/result.py b/lib/result.py new file mode 100644 index 00000000..c1ffa23d --- /dev/null +++ b/lib/result.py @@ -0,0 +1,36 @@ +from dataclasses import dataclass +from typing import Literal, Union + +from lib.logging import DiskoMessage, debug, print_msg + + +@dataclass +class DiskoSuccess: + value: object + context: None | str = None + success: Literal[True] = True + + +@dataclass +class DiskoError: + messages: list[DiskoMessage] + context: str + success: Literal[False] = False + + +DiskoResult = Union[DiskoSuccess, DiskoError] + + +def exit_on_error(result: DiskoResult) -> object: + if isinstance(result, DiskoSuccess): + if result.context is None: + print_msg("BUG_SUCCESS_WITHOUT_CONTEXT", {"value": result.value}) + else: + debug(f"Success in '{result.context}'") + debug(f"Returned value: {result.value}") + return result.value + + for message in result.messages: + print_msg(message.code, message.details) + + exit(1) From d4a944c3277d9c9dd7bdf7928d76c873a9ff29de Mon Sep 17 00:00:00 2001 From: Felix Uhl Date: Tue, 29 Oct 2024 16:49:04 +0100 Subject: [PATCH 06/37] disko2: Add --verbose,-v flag --- disko2 | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/disko2 b/disko2 index ad80a764..2096c6a5 100755 --- a/disko2 +++ b/disko2 @@ -4,7 +4,7 @@ import argparse from pathlib import Path from typing import Literal -from lib.logging import DiskoMessage, info +from lib.logging import LOGGER, DiskoMessage, debug, info from lib.result import DiskoError, DiskoSuccess, DiskoResult, exit_on_error Mode = Literal[ @@ -53,6 +53,10 @@ def run_generate() -> DiskoResult: def run(args: argparse.Namespace) -> DiskoResult: + if args.verbose == True: + LOGGER.setLevel("DEBUG") + debug("Enabled debug logging.") + if args.mode == None: return DiskoError( [DiskoMessage("ERR_MISSING_MODE", {"valid_modes": ALL_MODES})], @@ -71,6 +75,14 @@ def parse_args() -> argparse.Namespace: description="Automated disk partitioning and formatting tool for NixOS", ) + root_parser.add_argument( + "--verbose", + "-v", + action="store_true", + default=False, + help="Print more detailed output, helpful for debugging", + ) + mode_parsers = root_parser.add_subparsers(dest="mode") def create_apply_parser(mode: Mode) -> argparse.ArgumentParser: From 88a85411ba073acce0ab3299e120a8afe79ebda7 Mon Sep 17 00:00:00 2001 From: Felix Uhl Date: Tue, 29 Oct 2024 20:26:21 +0100 Subject: [PATCH 07/37] disko2: Make Result generic over the value it contains This will be essential for type safety later on. --- disko2 | 12 ++++++++---- lib/result.py | 12 +++++++----- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/disko2 b/disko2 index 2096c6a5..9aecbe08 100755 --- a/disko2 +++ b/disko2 @@ -2,7 +2,7 @@ import argparse from pathlib import Path -from typing import Literal +from typing import Any, Literal, Union from lib.logging import LOGGER, DiskoMessage, debug, info from lib.result import DiskoError, DiskoSuccess, DiskoResult, exit_on_error @@ -32,7 +32,9 @@ MODE_DESCRIPTION: dict[Mode, str] = { } -def run_apply(*, mode: str, disko_file: Path | None, flake: str | None) -> DiskoResult: +def run_apply( + *, mode: str, disko_file: Path | None, flake: str | None +) -> DiskoResult[dict[str, Any]]: if flake is None and disko_file is None: return DiskoError([DiskoMessage("ERR_MISSING_ARGUMENTS")], "validate args") if flake is not None and disko_file is not None: @@ -48,11 +50,13 @@ def run_apply(*, mode: str, disko_file: Path | None, flake: str | None) -> Disko ) -def run_generate() -> DiskoResult: +def run_generate() -> DiskoResult[Literal["generate"]]: return DiskoSuccess("generate", "generate config") -def run(args: argparse.Namespace) -> DiskoResult: +def run( + args: argparse.Namespace, +) -> DiskoResult[Literal["generate"] | dict[str, Any]]: if args.verbose == True: LOGGER.setLevel("DEBUG") debug("Enabled debug logging.") diff --git a/lib/result.py b/lib/result.py index c1ffa23d..30e33699 100644 --- a/lib/result.py +++ b/lib/result.py @@ -1,12 +1,14 @@ from dataclasses import dataclass -from typing import Literal, Union +from typing import Generic, Literal, TypeVar from lib.logging import DiskoMessage, debug, print_msg +T = TypeVar("T", covariant=True) + @dataclass -class DiskoSuccess: - value: object +class DiskoSuccess(Generic[T]): + value: T context: None | str = None success: Literal[True] = True @@ -18,10 +20,10 @@ class DiskoError: success: Literal[False] = False -DiskoResult = Union[DiskoSuccess, DiskoError] +DiskoResult = DiskoSuccess[T] | DiskoError -def exit_on_error(result: DiskoResult) -> object: +def exit_on_error(result: DiskoResult[T]) -> T: if isinstance(result, DiskoSuccess): if result.context is None: print_msg("BUG_SUCCESS_WITHOUT_CONTEXT", {"value": result.value}) From ed506048a4bedf81469afc5470481d62071dc7ad Mon Sep 17 00:00:00 2001 From: Felix Uhl Date: Tue, 29 Oct 2024 23:42:25 +0100 Subject: [PATCH 08/37] dev: Add autoflake to remove unused imports automatically --- flake.nix | 1 + pyproject.toml | 6 +++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/flake.nix b/flake.nix index d77c8726..5d7d4ba7 100644 --- a/flake.nix +++ b/flake.nix @@ -84,6 +84,7 @@ ps.black # Code formatter ps.isort # Import sorter ps.mypy # Static type checker + ps.autoflake # Remove unused imports automatically ])) ]; }; diff --git a/pyproject.toml b/pyproject.toml index f8ab0005..4934e1d0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,2 +1,6 @@ [tool.mypy] -strict = true \ No newline at end of file +strict = true + +[tool.autoflake] +remove_all_unused_imports = true +in_place = true \ No newline at end of file From 8acd24950d93a3f9ef51b4f3f29a33294cde2d74 Mon Sep 17 00:00:00 2001 From: Felix Uhl Date: Tue, 29 Oct 2024 23:50:09 +0100 Subject: [PATCH 09/37] disko2: Add evaluation for configs and flakes --- .vscode/launch.json | 14 +++++++- default.nix | 1 + disko2 | 41 +++++++++++------------ lib/eval-config.nix | 49 ++++++++++++++++++++++++++++ lib/eval_config.py | 79 +++++++++++++++++++++++++++++++++++++++++++++ lib/logging.py | 79 +++++++++++++++++++++++++++++++++++++++++++-- lib/result.py | 10 ++++-- lib/run_cmd.py | 33 +++++++++++++++++++ 8 files changed, 278 insertions(+), 28 deletions(-) create mode 100644 lib/eval-config.nix create mode 100644 lib/eval_config.py create mode 100644 lib/run_cmd.py diff --git a/.vscode/launch.json b/.vscode/launch.json index 87755f63..cf28c700 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -5,7 +5,7 @@ "version": "0.2.0", "configurations": [ { - "name": "Python Debugger: disko2", + "name": "disko2 mount disko_file", "type": "debugpy", "request": "launch", "program": "${workspaceFolder}/disko2", @@ -14,6 +14,18 @@ "mount", "example/simple-efi.nix" ] + }, + { + "name": "disko2 mount flake", + "type": "debugpy", + "request": "launch", + "program": "${workspaceFolder}/disko2", + "console": "integratedTerminal", + "args": [ + "mount", + "--flake", + ".#testmachine" + ] } ] } \ No newline at end of file diff --git a/default.nix b/default.nix index bf3570e2..65e49038 100644 --- a/default.nix +++ b/default.nix @@ -17,6 +17,7 @@ let }; in { + inherit eval; lib = lib.warn "the .lib.lib output is deprecated" diskoLib; # legacy alias diff --git a/disko2 b/disko2 index 9aecbe08..f28ef3b0 100755 --- a/disko2 +++ b/disko2 @@ -1,10 +1,12 @@ #!/usr/bin/env python3 import argparse +import json from pathlib import Path -from typing import Any, Literal, Union +from typing import Any, Literal -from lib.logging import LOGGER, DiskoMessage, debug, info +from lib.eval_config import eval_disko_file, eval_flake +from lib.logging import LOGGER, debug, info from lib.result import DiskoError, DiskoSuccess, DiskoResult, exit_on_error Mode = Literal[ @@ -33,21 +35,17 @@ MODE_DESCRIPTION: dict[Mode, str] = { def run_apply( - *, mode: str, disko_file: Path | None, flake: str | None + *, mode: str, disko_file: str | None, flake: str | None, **_kwargs: dict[str, Any] ) -> DiskoResult[dict[str, Any]]: - if flake is None and disko_file is None: - return DiskoError([DiskoMessage("ERR_MISSING_ARGUMENTS")], "validate args") - if flake is not None and disko_file is not None: - return DiskoError([DiskoMessage("ERR_TOO_MANY_ARGUMENTS")], "validate args") - - return DiskoSuccess( - { - "mode": mode, - "disko_file": disko_file, - "flake": flake, - }, - "apply configuration", - ) + # match would be nicer, but mypy doesn't understand type narrowing in tuples + if not disko_file and not flake: + return DiskoError.single_message("ERR_MISSING_ARGUMENTS", {}, "validate args") + if not disko_file and flake: + return eval_flake(flake) + if disko_file and not flake: + return eval_disko_file(Path(disko_file)) + + return DiskoError.single_message("ERR_TOO_MANY_ARGUMENTS", {}, "validate args") def run_generate() -> DiskoResult[Literal["generate"]]: @@ -57,14 +55,13 @@ def run_generate() -> DiskoResult[Literal["generate"]]: def run( args: argparse.Namespace, ) -> DiskoResult[Literal["generate"] | dict[str, Any]]: - if args.verbose == True: + if args.verbose: LOGGER.setLevel("DEBUG") debug("Enabled debug logging.") - if args.mode == None: - return DiskoError( - [DiskoMessage("ERR_MISSING_MODE", {"valid_modes": ALL_MODES})], - "select mode", + if not args.mode: + return DiskoError.single_message( + "ERR_MISSING_MODE", {"valid_modes": ALL_MODES}, "select mode" ) if args.mode == "generate": @@ -122,7 +119,7 @@ def main() -> None: args = parse_args() result = run(args) output = exit_on_error(result) - info(str(output)) + info("Output:\n" + json.dumps(output, indent=2)) if __name__ == "__main__": diff --git a/lib/eval-config.nix b/lib/eval-config.nix new file mode 100644 index 00000000..88662133 --- /dev/null +++ b/lib/eval-config.nix @@ -0,0 +1,49 @@ +{ pkgs ? import { } +, lib ? pkgs.lib +, flake ? null +, flakeAttr ? null +, diskoFile ? null +, rootMountPoint ? "/mnt" +, ... +}@args: +let + disko = import ./. { + inherit rootMountPoint; + inherit lib; + }; + + flake' = (builtins.getFlake flake); + + hasDiskoFile = diskoFile != null; + + hasFlakeDiskoConfig = lib.hasAttrByPath [ "diskoConfigurations" flakeAttr ] flake'; + + hasFlakeDiskoModule = + lib.hasAttrByPath [ "nixosConfigurations" flakeAttr "config" "disko" "devices" ] flake'; + + diskFormat = + let + diskoConfig = + if hasDiskoFile then + import diskoFile + else + flake'.diskoConfigurations.${flakeAttr}; + in + if builtins.isFunction diskoConfig then + diskoConfig ({ inherit lib; } // args) + else + diskoConfig; + + evaluatedConfig = + if hasDiskoFile || hasFlakeDiskoConfig then + disko.eval diskFormat + else if (lib.traceValSeq hasFlakeDiskoModule) then + flake'.nixosConfigurations.${flakeAttr} + else + (builtins.abort "couldn't find `diskoConfigurations.${flakeAttr}` or `nixosConfigurations.${flakeAttr}.config.disko.devices`"); + + diskoConfig = evaluatedConfig.config.disko.devices; + + finalConfig = lib.filterAttrsRecursive (name: value: !lib.hasPrefix "_" name) diskoConfig; +in +finalConfig diff --git a/lib/eval_config.py b/lib/eval_config.py new file mode 100644 index 00000000..5715e856 --- /dev/null +++ b/lib/eval_config.py @@ -0,0 +1,79 @@ +import json +from pathlib import Path +import re +from typing import Any + +from lib.run_cmd import run +from lib.result import DiskoError, DiskoResult, DiskoSuccess + +NIX_BASE_CMD = [ + "nix", + "--extra-experimental-features", + "nix-command", + "--extra-experimental-features", + "flakes", +] + +NIX_EVAL_EXPR_CMD = NIX_BASE_CMD + ["eval", "--impure", "--json", "--expr"] + +EVAL_CONFIG_NIX = Path(__file__).absolute().parent / "eval-config.nix" +assert ( + EVAL_CONFIG_NIX.exists() +), f"Can't find `eval-config.nix`, expected it next to {__file__}" + + +def eval_config(args: dict[str, str]) -> DiskoResult[dict[str, Any]]: + args_as_json = json.dumps(args) + + result = run( + NIX_EVAL_EXPR_CMD + + [f"import {EVAL_CONFIG_NIX} (builtins.fromJSON ''{args_as_json}'')"] + ) + + if isinstance(result, DiskoError): + return DiskoError.single_message( + "ERR_EVAL_CONFIG_FAILED", + {"args": args, "stderr": result.messages[0].details["stderr"]}, + "evaluate disko configuration", + ) + return result + + # We trust the output of `nix eval` to be valid JSON + return DiskoSuccess(json.loads(result.value), "evaluate disko config") + + +def eval_disko_file(config_file: Path) -> DiskoResult[dict[str, Any]]: + abs_path = config_file.absolute() + + if not abs_path.exists(): + return DiskoError.single_message( + "ERR_FILE_NOT_FOUND", + {"path": abs_path}, + "evaluate disko_file", + ) + + return eval_config({"diskoFile": str(abs_path)}) + + +def eval_flake(flake_uri: str) -> DiskoResult[dict[str, Any]]: + # arg parser should not allow empty strings + assert len(flake_uri) > 0 + + flake_match = re.match(r"^([^#]+)(?:#(.*))?$", flake_uri) + + # Match can't be none if we receive at least one character + assert flake_match is not None + + flake = flake_match.group(1) + flake_attr = flake_match.group(2) + + if not flake_attr: + return DiskoError.single_message( + "ERR_FLAKE_URI_NO_ATTR", {"flake_uri": flake_uri}, "evaluate flake" + ) + + flake_path = Path(flake) + if flake_path.exists(): + flake = str(flake_path.absolute()) + + return eval_config({"flake": flake, "flakeAttr": flake_attr}) diff --git a/lib/logging.py b/lib/logging.py index b8885d2b..a4dee4e6 100644 --- a/lib/logging.py +++ b/lib/logging.py @@ -1,8 +1,7 @@ # Logging functionality and global logging configuration from dataclasses import dataclass, field -from enum import Enum import logging -import textwrap +import re from typing import Any, Literal, assert_never from lib.ansi import Colors @@ -32,6 +31,10 @@ class ReadableMessage: # Note: Sort them alphabetically when adding new ones! MessageCode = Literal[ "BUG_SUCCESS_WITHOUT_CONTEXT", + "ERR_COMMAND_FAILED", + "ERR_EVAL_CONFIG_FAILED", + "ERR_FILE_NOT_FOUND", + "ERR_FLAKE_URI_NO_ATTR", "ERR_MISSING_ARGUMENTS", "ERR_MISSING_MODE", "ERR_TOO_MANY_ARGUMENTS", @@ -76,6 +79,44 @@ def to_readable(message: DiskoMessage) -> list[ReadableMessage]: ), bug_help_message(message.code), ] + case "ERR_COMMAND_FAILED": + return [ + ReadableMessage( + "error", + f""" + Command failed: {COMMAND}{message.details['command']}{COMMAND} + Exit code: {INVALID}{message.details['exit_code']}{RESET} + stderr: {message.details['stderr']} + """, + ) + ] + case "ERR_EVAL_CONFIG_FAILED": + return [ + ReadableMessage( + "error", + f""" + Failed to evaluate disko config with args {INVALID}{message.details['args']}{RESET}! + Stderr from {COMMAND}nix eval{RESET}:\n{message.details['stderr']} + """, + ) + ] + case "ERR_FILE_NOT_FOUND": + return [ + ReadableMessage( + "error", f"File not found: {FILE}{message.details['path']}{RESET}" + ) + ] + case "ERR_FLAKE_URI_NO_ATTR": + return [ + ReadableMessage( + "error", + f"Flake URI {INVALID}{message.details['flake_uri']}{RESET} has no attribute.", + ), + ReadableMessage( + "help", + f"Append an attribute like {VALUE}#{PLACEHOLDER}foo{RESET} to the flake URI.", + ), + ] case "ERR_MISSING_ARGUMENTS": return [ ReadableMessage( @@ -106,6 +147,36 @@ def to_readable(message: DiskoMessage) -> list[ReadableMessage]: assert_never(c) +# Dedent lines based on the indent of the first line until a non-indented line is hit. +# This will dedent the lines written in multiline f-strigns without breaking +# indentation for verbatim output that is inserted at the end +def dedent_start_lines(lines: list[str]) -> list[str]: + spaces_prefix_match = re.match(r"^( *)", lines[0]) + # Regex will even match an empty string, match can't be none + assert spaces_prefix_match is not None + dedent_width = len(spaces_prefix_match.group(1)) + + if dedent_width == 0: + return lines + + match_indent_regex = re.compile(f"^ {{1,{dedent_width}}}") + + dedented_lines = [] + stop_dedenting = False + for line in lines: + if not line.startswith(" "): + stop_dedenting = True + + if stop_dedenting: + dedented_lines.append(line) + continue + + dedented_line = re.sub(match_indent_regex, "", line) + dedented_lines.append(dedented_line) + + return dedented_lines + + def render_message(message: ReadableMessage) -> None: bg_color = { "bug": Colors.BG_RED, @@ -143,7 +214,7 @@ def render_message(message: ReadableMessage) -> None: "debug": LOGGER.debug, }[message.type] - msg_lines = textwrap.dedent(message.msg).splitlines() + msg_lines = message.msg.strip("\n").splitlines() # "WARNING:" is 8 characters long, center in 10 for space on each side title = f"{bg_color}{title_raw + ":":^10}{RESET}" @@ -152,6 +223,8 @@ def render_message(message: ReadableMessage) -> None: log_msg(f" {title} {msg_lines[0]}") return + msg_lines = dedent_start_lines(msg_lines) + log_msg(f"{decor_color}╭─{title} {msg_lines[0]}") for line in msg_lines[1:]: diff --git a/lib/result.py b/lib/result.py index 30e33699..b11d4c4d 100644 --- a/lib/result.py +++ b/lib/result.py @@ -1,7 +1,7 @@ from dataclasses import dataclass -from typing import Generic, Literal, TypeVar +from typing import Any, Generic, Literal, TypeVar -from lib.logging import DiskoMessage, debug, print_msg +from lib.logging import DiskoMessage, debug, print_msg, MessageCode T = TypeVar("T", covariant=True) @@ -19,6 +19,12 @@ class DiskoError: context: str success: Literal[False] = False + @classmethod + def single_message( + cls, code: MessageCode, details: dict[str, Any], context: str + ) -> "DiskoError": + return cls([DiskoMessage(code, details)], context) + DiskoResult = DiskoSuccess[T] | DiskoError diff --git a/lib/run_cmd.py b/lib/run_cmd.py new file mode 100644 index 00000000..9cc3fd45 --- /dev/null +++ b/lib/run_cmd.py @@ -0,0 +1,33 @@ +import subprocess + +from lib.logging import debug +from lib.result import DiskoError, DiskoResult, DiskoSuccess + + +def run(args: list[str]) -> DiskoResult[str]: + command = " ".join(args) + debug(f"Running: {command}") + + result = subprocess.run(args, capture_output=True, text=True) + + debug( + f""" + Ran: {command} + Exit code: {result.returncode} + Stdout: {result.stdout} + Stderr: {result.stderr} + """ + ) + + if result.returncode == 0: + return DiskoSuccess(result.stdout, "run command") + + return DiskoError.single_message( + "ERR_COMMAND_FAILED", + { + "command": command, + "stderr": result.stderr, + "exit_code": result.returncode, + }, + "run command", + ) From e825c64c8dedb3a2e3221b812f0d9e1698d91aea Mon Sep 17 00:00:00 2001 From: Felix Uhl Date: Wed, 30 Oct 2024 19:33:18 +0100 Subject: [PATCH 10/37] disko2: Implement rudimentary generate mode Error handling could be done better, but I'm pretty happy with it already. The important part is aggregating all errors instead of failing on the first issue we encounter. --- .vscode/launch.json | 10 ++++ disko2 | 5 +- lib/logging.py | 44 +++++++++++++- lib/types/__init__.py | 0 lib/types/device.py | 126 ++++++++++++++++++++++++++++++++++++++++ lib/types/disk.py | 75 ++++++++++++++++++++++++ lib/types/filesystem.py | 17 ++++++ lib/types/gpt.py | 64 ++++++++++++++++++++ 8 files changed, 337 insertions(+), 4 deletions(-) create mode 100644 lib/types/__init__.py create mode 100644 lib/types/device.py create mode 100644 lib/types/disk.py create mode 100644 lib/types/filesystem.py create mode 100644 lib/types/gpt.py diff --git a/.vscode/launch.json b/.vscode/launch.json index cf28c700..9a5da229 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -26,6 +26,16 @@ "--flake", ".#testmachine" ] + }, + { + "name": "disko2 generate", + "type": "debugpy", + "request": "launch", + "program": "${workspaceFolder}/disko2", + "console": "integratedTerminal", + "args": [ + "generate", + ] } ] } \ No newline at end of file diff --git a/disko2 b/disko2 index f28ef3b0..44145da5 100755 --- a/disko2 +++ b/disko2 @@ -8,6 +8,7 @@ from typing import Any, Literal from lib.eval_config import eval_disko_file, eval_flake from lib.logging import LOGGER, debug, info from lib.result import DiskoError, DiskoSuccess, DiskoResult, exit_on_error +from lib.types.disk import generate_config Mode = Literal[ "destroy", "format", "mount", "destroy,format,mount", "format,mount", "generate" @@ -48,8 +49,8 @@ def run_apply( return DiskoError.single_message("ERR_TOO_MANY_ARGUMENTS", {}, "validate args") -def run_generate() -> DiskoResult[Literal["generate"]]: - return DiskoSuccess("generate", "generate config") +def run_generate() -> DiskoResult[dict[str, Any]]: + return generate_config() def run( diff --git a/lib/logging.py b/lib/logging.py index a4dee4e6..c0995062 100644 --- a/lib/logging.py +++ b/lib/logging.py @@ -1,5 +1,6 @@ # Logging functionality and global logging configuration from dataclasses import dataclass, field +import json import logging import re from typing import Any, Literal, assert_never @@ -12,6 +13,8 @@ # Color definitions. Note: Sort them alphabetically when adding new ones! COMMAND = Colors.CYAN_ITALIC # Commands that were run or can be run +EM = Colors.WHITE_ITALIC # Emphasized text +EM_WARN = Colors.YELLOW_ITALIC # Emphasized text that is a warning FILE = Colors.BLUE # File paths FLAG = Colors.GREEN # Command line flags (like --version or -f) INVALID = Colors.RED # Invalid values @@ -38,6 +41,8 @@ class ReadableMessage: "ERR_MISSING_ARGUMENTS", "ERR_MISSING_MODE", "ERR_TOO_MANY_ARGUMENTS", + "ERR_UNSUPPORTED_PTTYPE", + "WARN_GENERATE_PARTIAL_FAILURE", ] @@ -141,6 +146,41 @@ def to_readable(message: DiskoMessage) -> list[ReadableMessage]: ReadableMessage("error", "Missing mode!"), ReadableMessage("help", "Allowed modes are:\n" + modes_list), ] + case "ERR_UNSUPPORTED_PTTYPE": + return [ + ReadableMessage( + "error", + f"Device {FILE}{message.details['device']}{RESET} has unsupported partition type {INVALID}{message.details['pttype']}{RESET}!", + ), + ] + case "WARN_GENERATE_PARTIAL_FAILURE": + return [ + ReadableMessage( + "info", + f""" + Successfully generated config for {EM}some{RESET} devices. + Errors are printed above. The generated partial config is: + {json.dumps(message.details["partial_config"], indent=2)} + """, + ), + ReadableMessage( + "warning", + f""" + Successfully generated config for {EM}some{RESET} devices, {EM_WARN}but not all{RESET}! + Failed devices: {", ".join(f"{INVALID}{d}{RESET}" for d in message.details["failed_devices"])} + Successful devices: {", ".join(f"{VALUE}{d}{RESET}" for d in message.details["successful_devices"])} + """, + ), + ReadableMessage( + "help", + f""" + The {INVALID}ERROR{RESET} messages are printed {EM}above the generated config{RESET}. + Take a look at them and see if you can fix or safely ignore them. + If you can't, but you need a solution now, you can try to use the generated config, + but there is {EM_WARN}no guarantee{RESET} that it will work! + """, + ), + ] # We could also remove these two lines, but assert_never emits a better error message case _ as c: @@ -159,7 +199,7 @@ def dedent_start_lines(lines: list[str]) -> list[str]: if dedent_width == 0: return lines - match_indent_regex = re.compile(f"^ {{1,{dedent_width}}}") + match_indent_regex = re.compile(f"^ {{{dedent_width}}}") dedented_lines = [] stop_dedenting = False @@ -214,7 +254,7 @@ def render_message(message: ReadableMessage) -> None: "debug": LOGGER.debug, }[message.type] - msg_lines = message.msg.strip("\n").splitlines() + msg_lines = message.msg.strip("\n").rstrip(" \n").splitlines() # "WARNING:" is 8 characters long, center in 10 for space on each side title = f"{bg_color}{title_raw + ":":^10}{RESET}" diff --git a/lib/types/__init__.py b/lib/types/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/lib/types/device.py b/lib/types/device.py new file mode 100644 index 00000000..7986d5ff --- /dev/null +++ b/lib/types/device.py @@ -0,0 +1,126 @@ +from dataclasses import dataclass +import json +from pathlib import Path +from typing import Any + +from lib.result import DiskoError, DiskoResult, DiskoSuccess +from lib.run_cmd import run + +# To see what other fields are available in the lsblk output and what +# sort of values you can expect from them, run: +# lsblk -O | less -S +LSBLK_OUTPUT_FIELDS = [ + "ID-LINK", + "FSTYPE", + "FSSIZE", + "FSUSE%", + "KNAME", + "LABEL", + "MODEL", + "PARTFLAGS", + "PARTLABEL", + "PARTN", + "PARTTYPE", + "PARTTYPENAME", + "PARTUUID", # The UUID used for /dev/disk/by-partuuid + "PATH", # The canonical path of the block device + "PHY-SEC", + "PTTYPE", + "REV", + "SERIAL", + "SIZE", + "START", + "MOUNTPOINT", # Canonical mountpoint + "MOUNTPOINTS", # All mountpoints, including e.g. bind mounts + "TYPE", + "UUID", # The UUID used for /dev/disk/by-uuid, if available +] + + +# Could think about splitting this into multiple classes based on the type field +# Would make access to the fields more type safe +@dataclass +class BlockDevice: + id_link: str + fstype: str + fssize: str + fsuse_pct: str + kname: str + label: str + model: str + partflags: str + partlabel: str + partn: int | None + parttype: str + parttypename: str + partuuid: str + path: Path + phy_sec: int + pttype: str + rev: str + serial: str + size: str + start: str + mountpoint: str + mountpoints: list[str] + type: str + uuid: str + children: list["BlockDevice"] + + @classmethod + def from_json_dict(cls, json_dict: dict[str, Any]) -> "BlockDevice": + children = [ + cls.from_json_dict(child_dict) + for child_dict in json_dict.get("children", []) + ] + + # The mountpoints field will be a list containing a single null if there are no mountpoints + mountpoints = json_dict["mountpoints"] or [] + if not any(mountpoints): + mountpoints = [] + + # When we request the output fields from lsblk, the keys are guaranteed to exists, + # but some might be null. Set a default value for the fields we have observed to be optional. + return cls( + children=children, + id_link=json_dict["id-link"], + fstype=json_dict["fstype"] or "", + fssize=json_dict["fssize"] or "", + fsuse_pct=json_dict["fsuse%"] or "", + kname=json_dict["kname"], + label=json_dict["label"] or "", + model=json_dict["model"] or "", + partflags=json_dict["partflags"] or "", + partlabel=json_dict["partlabel"] or "", + partn=json_dict["partn"], + parttype=json_dict["parttype"] or "", + parttypename=json_dict["parttypename"] or "", + partuuid=json_dict["partuuid"] or "", + path=Path(json_dict["path"]), + phy_sec=json_dict["phy-sec"], + pttype=json_dict["pttype"], + rev=json_dict["rev"] or "", + serial=json_dict["serial"] or "", + size=json_dict["size"], + start=json_dict["start"] or "", + mountpoint=json_dict["mountpoint"] or "", + mountpoints=mountpoints, + type=json_dict["type"], + uuid=json_dict["uuid"] or "", + ) + + +def list_block_devices() -> DiskoResult[list[BlockDevice]]: + lsblk_result = run( + ["lsblk", "--json", "--tree", "--output", ",".join(LSBLK_OUTPUT_FIELDS)] + ) + + if isinstance(lsblk_result, DiskoError): + return lsblk_result + + # We trust the output of `lsblk` to be valid JSON + lsblk_json: list[dict[str, Any]] = json.loads(lsblk_result.value)["blockdevices"] + + blockdevices = [BlockDevice.from_json_dict(dev) for dev in lsblk_json] + + return DiskoSuccess(blockdevices, "list block devices") diff --git a/lib/types/disk.py b/lib/types/disk.py new file mode 100644 index 00000000..6c1bbf67 --- /dev/null +++ b/lib/types/disk.py @@ -0,0 +1,75 @@ +from typing import Any + +from lib.logging import DiskoMessage, debug +from lib.result import DiskoError, DiskoResult, DiskoSuccess +from lib.types.device import BlockDevice, list_block_devices +import lib.types.gpt as gpt + + +def _generate_content(device: BlockDevice) -> DiskoResult[dict[str, Any]]: + match device.pttype: + case "gpt": + return gpt.generate_config(device) + case _: + return DiskoError.single_message( + "ERR_UNSUPPORTED_PTTYPE", + {"device": device.path, "pttype": device.pttype}, + "generate disk content", + ) + + +def generate_config(devices: list[BlockDevice] = []) -> DiskoResult[dict[str, Any]]: + block_devices = devices + if not block_devices: + lsblk_result = list_block_devices() + + if isinstance(lsblk_result, DiskoError): + return lsblk_result + + block_devices = lsblk_result.value + + if isinstance(block_devices, DiskoError): + return block_devices + + debug(f"Generating config for devices {[d.path for d in block_devices]}") + + disks = {} + error_messages = [] + failed_devices = [] + successful_devices = [] + + for device in block_devices: + content = _generate_content(device) + + if isinstance(content, DiskoError): + error_messages.extend(content.messages) + failed_devices.append(device.path) + continue + + disks[f"MODEL:{device.model},SN:{device.serial}"] = { + "device": device.kname, + "type": device.type, + "content": content.value, + } + successful_devices.append(device.path) + + if not failed_devices: + return DiskoSuccess({"disks": disks}, "generate disk config") + + if not successful_devices: + return DiskoError(error_messages, "generate disk config") + + return DiskoError( + error_messages + + [ + DiskoMessage( + "WARN_GENERATE_PARTIAL_FAILURE", + { + "partial_config": {"disks": disks}, + "failed_devices": failed_devices, + "successful_devices": successful_devices, + }, + ) + ], + "generate disk config", + ) diff --git a/lib/types/filesystem.py b/lib/types/filesystem.py new file mode 100644 index 00000000..168df875 --- /dev/null +++ b/lib/types/filesystem.py @@ -0,0 +1,17 @@ +from typing import Any +from lib.types.device import BlockDevice +from lib.result import DiskoResult, DiskoSuccess + + +def generate_config(device: BlockDevice) -> DiskoResult[dict[str, Any]]: + assert ( + device.type == "part" + ), f"BUG! filesystem.generate_config called with non-partition device {device.path}" + + return DiskoSuccess( + { + "type": "filesystem", + "format": device.fstype, + "mountpoint": device.mountpoint, + } + ) diff --git a/lib/types/gpt.py b/lib/types/gpt.py new file mode 100644 index 00000000..b5cc5c82 --- /dev/null +++ b/lib/types/gpt.py @@ -0,0 +1,64 @@ +from typing import Any +from lib.logging import debug +from lib.types import filesystem +from lib.types.device import BlockDevice +from lib.result import DiskoError, DiskoResult, DiskoSuccess + + +def _add_type_if_required( + device: BlockDevice, part_config: dict[str, Any] +) -> dict[str, Any]: + type = { + "c12a7328-f81f-11d2-ba4b-00a0c93ec93b": "EF00", # EFI System + "21686148-6449-6e6f-744e-656564454649": "EF02", # BIOS boot + }.get(device.parttype) + + if type: + part_config["type"] = type + + return part_config + + +def _generate_name(device: BlockDevice) -> str: + if device.uuid: + return f"UUID:{device.uuid}" + + return f"PARTUUID:{device.partuuid}" + + +def _generate_content(device: BlockDevice) -> DiskoResult[dict[str, Any]]: + match device.fstype: + # TODO: Add filesystems that are not supported by `mkfs` here + case _: + return filesystem.generate_config(device) + + +def generate_config(device: BlockDevice) -> DiskoResult[dict[str, Any]]: + assert ( + device.pttype == "gpt" + ), f"BUG! gpt.generate_config called with non-gpt device {device.path}" + + debug(f"Generating GPT config for device {device.path}") + + partitions = {} + error_messages = [] + failed_partitions = [] + successful_partitions = [] + + for partition in device.children: + content = _generate_content(partition) + + if isinstance(content, DiskoError): + error_messages.extend(content.messages) + failed_partitions.append(partition.path) + continue + + partitions[_generate_name(partition)] = _add_type_if_required( + partition, {"size": partition.size, "content": content.value} + ) + successful_partitions.append(partition.path) + + if not failed_partitions: + return DiskoSuccess({"partitions": partitions}, "generate gpt config") + + return DiskoError(error_messages, "generate gpt config") From 5da578139ee348e2c9d91f3424bb10f1f0dd3430 Mon Sep 17 00:00:00 2001 From: Felix Uhl Date: Wed, 30 Oct 2024 20:24:00 +0100 Subject: [PATCH 11/37] disko2: Add dev subcommand --- disko2 | 58 ++++++++++++++++++++++++++++++++++----------- lib/ansi.py | 8 +++++-- lib/types/device.py | 28 ++++++++++++++++------ 3 files changed, 71 insertions(+), 23 deletions(-) mode change 100755 => 100644 lib/ansi.py diff --git a/disko2 b/disko2 index 44145da5..ccf9adeb 100755 --- a/disko2 +++ b/disko2 @@ -3,15 +3,23 @@ import argparse import json from pathlib import Path -from typing import Any, Literal +from typing import Any, Literal, assert_never +from lib.ansi import disko_dev_ansi from lib.eval_config import eval_disko_file, eval_flake from lib.logging import LOGGER, debug, info from lib.result import DiskoError, DiskoSuccess, DiskoResult, exit_on_error from lib.types.disk import generate_config +from lib.types.device import disko_dev_lsblk Mode = Literal[ - "destroy", "format", "mount", "destroy,format,mount", "format,mount", "generate" + "destroy", + "format", + "mount", + "destroy,format,mount", + "format,mount", + "generate", + "dev", ] @@ -23,7 +31,7 @@ APPLY_MODES: list[Mode] = [ "destroy,format,mount", "format,mount", ] -ALL_MODES: list[Mode] = APPLY_MODES + ["generate"] +ALL_MODES: list[Mode] = APPLY_MODES + ["generate", "dev"] MODE_DESCRIPTION: dict[Mode, str] = { "destroy": "Destroy the partition tables on the specified disks", @@ -32,6 +40,7 @@ MODE_DESCRIPTION: dict[Mode, str] = { "destroy,format,mount": "Run destroy, format and mount in sequence", "format,mount": "Run format and mount in sequence", "generate": "Generate a disko configuration file from the system's current state", + "dev": "Print information useful for developers", } @@ -53,22 +62,34 @@ def run_generate() -> DiskoResult[dict[str, Any]]: return generate_config() +def run_dev(args: argparse.Namespace) -> DiskoResult[None]: + match args.dev_command: + case "lsblk": + return disko_dev_lsblk() + case "ansi": + return DiskoSuccess(None, disko_dev_ansi()) + case _: + assert_never(args.dev_command) + + def run( args: argparse.Namespace, -) -> DiskoResult[Literal["generate"] | dict[str, Any]]: +) -> DiskoResult[None | dict[str, Any]]: if args.verbose: LOGGER.setLevel("DEBUG") debug("Enabled debug logging.") - if not args.mode: - return DiskoError.single_message( - "ERR_MISSING_MODE", {"valid_modes": ALL_MODES}, "select mode" - ) - - if args.mode == "generate": - return run_generate() - - return run_apply(**vars(args)) + match args.mode: + case None: + return DiskoError.single_message( + "ERR_MISSING_MODE", {"valid_modes": ALL_MODES}, "select mode" + ) + case "generate": + return run_generate() + case "dev": + return run_dev(args) + case _: + return run_apply(**vars(args)) def parse_args() -> argparse.Namespace: @@ -113,6 +134,14 @@ def parse_args() -> argparse.Namespace: "generate", help=MODE_DESCRIPTION["generate"], ) + + # Commands for developers + dev_parsers = mode_parsers.add_parser( + "dev", + help=MODE_DESCRIPTION["dev"], + ).add_subparsers(dest="dev_command") + dev_parsers.add_parser("lsblk", help="List block devices the way disko sees them") + dev_parsers.add_parser("ansi", help="Print defined ansi color codes") return root_parser.parse_args() @@ -120,7 +149,8 @@ def main() -> None: args = parse_args() result = run(args) output = exit_on_error(result) - info("Output:\n" + json.dumps(output, indent=2)) + if output: + info("Output:\n" + json.dumps(output, indent=2)) if __name__ == "__main__": diff --git a/lib/ansi.py b/lib/ansi.py old mode 100755 new mode 100644 index 7471e880..e33c9189 --- a/lib/ansi.py +++ b/lib/ansi.py @@ -3,7 +3,8 @@ # Inspired by rene-d's colors.py, published in 2018 # See https://gist.github.com/rene-d/9e584a7dd2935d0f461904b9f2950007 -# Run as a script to see all available colors and styles + +from typing import Any class Colors: @@ -177,9 +178,12 @@ class Colors: del kernel32 -if __name__ == "__main__": +def disko_dev_ansi() -> str: import inspect + from lib.result import DiskoSuccess # Import here to avoid circular dependency for name, value in inspect.getmembers(Colors): if value != "_" and not name.startswith("_") and name != "RESET": print("{:>30} {}".format(name, value + name + Colors.RESET)) + + return "run disko dev ansi" diff --git a/lib/types/device.py b/lib/types/device.py index 7986d5ff..00779a20 100644 --- a/lib/types/device.py +++ b/lib/types/device.py @@ -110,17 +110,31 @@ def from_json_dict(cls, json_dict: dict[str, Any]) -> "BlockDevice": ) -def list_block_devices() -> DiskoResult[list[BlockDevice]]: - lsblk_result = run( - ["lsblk", "--json", "--tree", "--output", ",".join(LSBLK_OUTPUT_FIELDS)] - ) +def _run_lsblk() -> DiskoResult[str]: + return run(["lsblk", "--json", "--tree", "--output", ",".join(LSBLK_OUTPUT_FIELDS)]) - if isinstance(lsblk_result, DiskoError): - return lsblk_result + +def list_block_devices(lsblk_output: str = "") -> DiskoResult[list[BlockDevice]]: + if not lsblk_output: + lsblk_result = _run_lsblk() + + if isinstance(lsblk_result, DiskoError): + return lsblk_result + + lsblk_output = lsblk_result.value # We trust the output of `lsblk` to be valid JSON - lsblk_json: list[dict[str, Any]] = json.loads(lsblk_result.value)["blockdevices"] + lsblk_json: list[dict[str, Any]] = json.loads(lsblk_output)["blockdevices"] blockdevices = [BlockDevice.from_json_dict(dev) for dev in lsblk_json] return DiskoSuccess(blockdevices, "list block devices") + + +def disko_dev_lsblk() -> DiskoResult[None]: + output = _run_lsblk() + if isinstance(output, DiskoError): + return output + + print(output.value) + return DiskoSuccess(None, "run disko dev lsblk") From 389235b63f8488229559a1217351826f1d3dd7b3 Mon Sep 17 00:00:00 2001 From: Felix Uhl Date: Wed, 30 Oct 2024 23:43:26 +0100 Subject: [PATCH 12/37] disko2: Create package This requires a lot of restructuring. The .nix files have to be bundled together with the python files, so they need to follow python's module system structure. I ran `nix-fast-build --no-link -j 2 --eval-workers 3 --flake .#checks` and it succeeded, so I'm reasonably confident I changed everything as required. --- default.nix | 43 +---- disko | 2 +- disko-install | 2 +- disko2 | 167 ++---------------- doc.nix | 2 +- docs/reference.md | 2 +- docs/testing.md | 2 +- flake.nix | 5 +- module.nix | 6 +- package-disko2.nix | 42 +++++ package.nix | 2 +- pyproject.toml | 19 +- cli.nix => src/cli.nix | 2 +- .../disk-deactivate}/disk-deactivate | 0 .../disk-deactivate}/disk-deactivate.jq | 0 src/disko/__init__.py | 5 + src/disko/__main__.py | 4 + src/disko/cli.py | 157 ++++++++++++++++ {lib => src/disko_lib}/__init__.py | 0 {lib => src/disko_lib}/ansi.py | 4 - {lib => src/disko_lib}/default.nix | 70 +++++++- {lib => src/disko_lib}/eval-config.nix | 2 +- {lib => src/disko_lib}/eval_config.py | 4 +- {lib => src/disko_lib}/interactive-vm.nix | 0 {lib => src/disko_lib}/logging.py | 2 +- {lib => src/disko_lib}/make-disk-image.nix | 0 {lib => src/disko_lib}/result.py | 2 +- {lib => src/disko_lib}/run_cmd.py | 4 +- {lib => src/disko_lib}/tests.nix | 6 +- {lib => src/disko_lib}/types/__init__.py | 0 {lib => src/disko_lib}/types/btrfs.nix | 0 {lib => src/disko_lib}/types/device.py | 4 +- {lib => src/disko_lib}/types/disk.nix | 0 {lib => src/disko_lib}/types/disk.py | 8 +- {lib => src/disko_lib}/types/filesystem.nix | 0 {lib => src/disko_lib}/types/filesystem.py | 4 +- {lib => src/disko_lib}/types/gpt.nix | 0 {lib => src/disko_lib}/types/gpt.py | 8 +- {lib => src/disko_lib}/types/luks.nix | 0 {lib => src/disko_lib}/types/lvm_pv.nix | 0 {lib => src/disko_lib}/types/lvm_vg.nix | 0 {lib => src/disko_lib}/types/mdadm.nix | 0 {lib => src/disko_lib}/types/mdraid.nix | 0 {lib => src/disko_lib}/types/nodev.nix | 0 {lib => src/disko_lib}/types/swap.nix | 0 {lib => src/disko_lib}/types/table.nix | 0 {lib => src/disko_lib}/types/zfs.nix | 0 {lib => src/disko_lib}/types/zfs_fs.nix | 0 {lib => src/disko_lib}/types/zfs_volume.nix | 0 {lib => src/disko_lib}/types/zpool.nix | 0 install-cli.nix => src/install-cli.nix | 0 tests/bcachefs.nix | 2 +- tests/boot-raid1.nix | 2 +- tests/btrfs-only-root-subvolume.nix | 2 +- tests/btrfs-subvolumes.nix | 2 +- tests/cli.nix | 2 +- tests/complex.nix | 2 +- tests/default.nix | 2 +- tests/f2fs.nix | 2 +- tests/gpt-bios-compat.nix | 2 +- tests/gpt-name-with-special-chars.nix | 2 +- tests/hybrid-mbr.nix | 2 +- tests/hybrid-tmpfs-on-root.nix | 2 +- tests/hybrid.nix | 2 +- tests/legacy-table-with-whitespace.nix | 2 +- tests/legacy-table.nix | 2 +- tests/long-device-name.nix | 2 +- tests/luks-btrfs-raid.nix | 6 +- tests/luks-btrfs-subvolumes.nix | 2 +- tests/luks-interactive-login.nix | 2 +- tests/luks-lvm.nix | 2 +- tests/luks-on-mdadm.nix | 2 +- tests/lvm-raid.nix | 2 +- tests/lvm-sizes-sort.nix | 2 +- tests/lvm-thin.nix | 2 +- tests/mdadm-raid0.nix | 2 +- tests/mdadm.nix | 2 +- tests/module.nix | 2 +- tests/multi-device-no-deps.nix | 2 +- tests/negative-size.nix | 2 +- tests/non-root-zfs.nix | 2 +- tests/simple-efi.nix | 2 +- tests/swap.nix | 2 +- tests/tmpfs.nix | 2 +- tests/with-lib.nix | 2 +- tests/zfs-over-legacy.nix | 2 +- tests/zfs-with-vdevs.nix | 2 +- tests/zfs.nix | 2 +- 88 files changed, 375 insertions(+), 283 deletions(-) create mode 100644 package-disko2.nix rename cli.nix => src/cli.nix (98%) rename {disk-deactivate => src/disk-deactivate}/disk-deactivate (100%) rename {disk-deactivate => src/disk-deactivate}/disk-deactivate.jq (100%) create mode 100644 src/disko/__init__.py create mode 100644 src/disko/__main__.py create mode 100644 src/disko/cli.py rename {lib => src/disko_lib}/__init__.py (100%) rename {lib => src/disko_lib}/ansi.py (98%) rename {lib => src/disko_lib}/default.nix (89%) rename {lib => src/disko_lib}/eval-config.nix (97%) rename {lib => src/disko_lib}/eval_config.py (96%) rename {lib => src/disko_lib}/interactive-vm.nix (100%) rename {lib => src/disko_lib}/logging.py (99%) rename {lib => src/disko_lib}/make-disk-image.nix (100%) rename {lib => src/disko_lib}/result.py (93%) rename {lib => src/disko_lib}/run_cmd.py (88%) rename {lib => src/disko_lib}/tests.nix (98%) rename {lib => src/disko_lib}/types/__init__.py (100%) rename {lib => src/disko_lib}/types/btrfs.nix (100%) rename {lib => src/disko_lib}/types/device.py (97%) rename {lib => src/disko_lib}/types/disk.nix (100%) rename {lib => src/disko_lib}/types/disk.py (91%) rename {lib => src/disko_lib}/types/filesystem.nix (100%) rename {lib => src/disko_lib}/types/filesystem.py (81%) rename {lib => src/disko_lib}/types/gpt.nix (100%) rename {lib => src/disko_lib}/types/gpt.py (91%) rename {lib => src/disko_lib}/types/luks.nix (100%) rename {lib => src/disko_lib}/types/lvm_pv.nix (100%) rename {lib => src/disko_lib}/types/lvm_vg.nix (100%) rename {lib => src/disko_lib}/types/mdadm.nix (100%) rename {lib => src/disko_lib}/types/mdraid.nix (100%) rename {lib => src/disko_lib}/types/nodev.nix (100%) rename {lib => src/disko_lib}/types/swap.nix (100%) rename {lib => src/disko_lib}/types/table.nix (100%) rename {lib => src/disko_lib}/types/zfs.nix (100%) rename {lib => src/disko_lib}/types/zfs_fs.nix (100%) rename {lib => src/disko_lib}/types/zfs_volume.nix (100%) rename {lib => src/disko_lib}/types/zpool.nix (100%) rename install-cli.nix => src/install-cli.nix (100%) diff --git a/default.nix b/default.nix index 65e49038..19ff77b4 100644 --- a/default.nix +++ b/default.nix @@ -1,45 +1,8 @@ { lib ? import , rootMountPoint ? "/mnt" , checked ? false -, diskoLib ? import ./lib { inherit lib rootMountPoint; } +, diskoLib ? import ./src/disko_lib { inherit lib rootMountPoint; } }: -let - eval = cfg: lib.evalModules { - modules = lib.singleton { - # _file = toString input; - imports = lib.singleton { disko.devices = cfg.disko.devices; }; - options = { - disko.devices = lib.mkOption { - type = diskoLib.toplevel; - }; - }; - }; - }; -in -{ - inherit eval; - lib = lib.warn "the .lib.lib output is deprecated" diskoLib; - - # legacy alias - create = cfg: builtins.trace "the create output is deprecated, use format instead" (eval cfg).config.disko.devices._create; - createScript = cfg: pkgs: builtins.trace "the create output is deprecated, use format instead" ((eval cfg).config.disko.devices._scripts { inherit pkgs checked; }).formatScript; - createScriptNoDeps = cfg: pkgs: builtins.trace "the create output is deprecated, use format instead" ((eval cfg).config.disko.devices._scripts { inherit pkgs checked; }).formatScriptNoDeps; - - format = cfg: (eval cfg).config.disko.devices._create; - formatScript = cfg: pkgs: ((eval cfg).config.disko.devices._scripts { inherit pkgs checked; }).formatScript; - formatScriptNoDeps = cfg: pkgs: ((eval cfg).config.disko.devices._scripts { inherit pkgs checked; }).formatScriptNoDeps; - - mount = cfg: (eval cfg).config.disko.devices._mount; - mountScript = cfg: pkgs: ((eval cfg).config.disko.devices._scripts { inherit pkgs checked; }).mountScript; - mountScriptNoDeps = cfg: pkgs: ((eval cfg).config.disko.devices._scripts { inherit pkgs checked; }).mountScriptNoDeps; - - disko = cfg: (eval cfg).config.disko.devices._disko; - diskoScript = cfg: pkgs: ((eval cfg).config.disko.devices._scripts { inherit pkgs checked; }).diskoScript; - diskoScriptNoDeps = cfg: pkgs: ((eval cfg).config.disko.devices._scripts { inherit pkgs checked; }).diskoScriptNoDeps; - - # we keep this old output for backwards compatibility - diskoNoDeps = cfg: pkgs: builtins.trace "the diskoNoDeps output is deprecated, please use disko instead" ((eval cfg).config.disko.devices._scripts { inherit pkgs checked; }).diskoScriptNoDeps; - - config = cfg: (eval cfg).config.disko.devices._config; - packages = cfg: (eval cfg).config.disko.devices._packages; +diskoLib.outputs { + inherit lib checked rootMountPoint; } diff --git a/disko b/disko index 4ef771a9..af97de37 100755 --- a/disko +++ b/disko @@ -153,7 +153,7 @@ else fi # The "--impure" is still pure, as the path is within the nix store. -script=$(nixBuild "${libexec_dir}"/cli.nix \ +script=$(nixBuild "${libexec_dir}"/src/cli.nix \ --no-out-link \ --impure \ --argstr mode "$mode" \ diff --git a/disko-install b/disko-install index a10ff80d..9c3fc8a8 100755 --- a/disko-install +++ b/disko-install @@ -197,7 +197,7 @@ main() { # shellcheck disable=SC2064 trap "cleanupMountPoint ${escapeMountPoint}" EXIT - outputs=$(nixBuild "${libexec_dir}"/install-cli.nix \ + outputs=$(nixBuild "${libexec_dir}"/src/install-cli.nix \ "${nix_args[@]}" \ --no-out-link \ --impure \ diff --git a/disko2 b/disko2 index ccf9adeb..18b1f6b1 100755 --- a/disko2 +++ b/disko2 @@ -1,157 +1,10 @@ -#!/usr/bin/env python3 - -import argparse -import json -from pathlib import Path -from typing import Any, Literal, assert_never - -from lib.ansi import disko_dev_ansi -from lib.eval_config import eval_disko_file, eval_flake -from lib.logging import LOGGER, debug, info -from lib.result import DiskoError, DiskoSuccess, DiskoResult, exit_on_error -from lib.types.disk import generate_config -from lib.types.device import disko_dev_lsblk - -Mode = Literal[ - "destroy", - "format", - "mount", - "destroy,format,mount", - "format,mount", - "generate", - "dev", -] - - -# Modes to apply an existing configuration -APPLY_MODES: list[Mode] = [ - "destroy", - "format", - "mount", - "destroy,format,mount", - "format,mount", -] -ALL_MODES: list[Mode] = APPLY_MODES + ["generate", "dev"] - -MODE_DESCRIPTION: dict[Mode, str] = { - "destroy": "Destroy the partition tables on the specified disks", - "format": "Change formatting and filesystems on the specified disks", - "mount": "Mount the specified disks", - "destroy,format,mount": "Run destroy, format and mount in sequence", - "format,mount": "Run format and mount in sequence", - "generate": "Generate a disko configuration file from the system's current state", - "dev": "Print information useful for developers", -} - - -def run_apply( - *, mode: str, disko_file: str | None, flake: str | None, **_kwargs: dict[str, Any] -) -> DiskoResult[dict[str, Any]]: - # match would be nicer, but mypy doesn't understand type narrowing in tuples - if not disko_file and not flake: - return DiskoError.single_message("ERR_MISSING_ARGUMENTS", {}, "validate args") - if not disko_file and flake: - return eval_flake(flake) - if disko_file and not flake: - return eval_disko_file(Path(disko_file)) - - return DiskoError.single_message("ERR_TOO_MANY_ARGUMENTS", {}, "validate args") - - -def run_generate() -> DiskoResult[dict[str, Any]]: - return generate_config() - - -def run_dev(args: argparse.Namespace) -> DiskoResult[None]: - match args.dev_command: - case "lsblk": - return disko_dev_lsblk() - case "ansi": - return DiskoSuccess(None, disko_dev_ansi()) - case _: - assert_never(args.dev_command) - - -def run( - args: argparse.Namespace, -) -> DiskoResult[None | dict[str, Any]]: - if args.verbose: - LOGGER.setLevel("DEBUG") - debug("Enabled debug logging.") - - match args.mode: - case None: - return DiskoError.single_message( - "ERR_MISSING_MODE", {"valid_modes": ALL_MODES}, "select mode" - ) - case "generate": - return run_generate() - case "dev": - return run_dev(args) - case _: - return run_apply(**vars(args)) - - -def parse_args() -> argparse.Namespace: - root_parser = argparse.ArgumentParser( - prog="disko2", - description="Automated disk partitioning and formatting tool for NixOS", - ) - - root_parser.add_argument( - "--verbose", - "-v", - action="store_true", - default=False, - help="Print more detailed output, helpful for debugging", - ) - - mode_parsers = root_parser.add_subparsers(dest="mode") - - def create_apply_parser(mode: Mode) -> argparse.ArgumentParser: - parser = mode_parsers.add_parser( - mode, - help=MODE_DESCRIPTION[mode], - ) - parser.add_argument( - "disko_file", - nargs="?", - default=None, - help="Path to the disko configuration file", - ) - parser.add_argument( - "--flake", - "-f", - help="Flake to fetch the disko configuration from", - ) - return parser - - # Commands to apply an existing configuration - apply_parsers = [create_apply_parser(mode) for mode in APPLY_MODES] - - # Other commands - generate_parser = mode_parsers.add_parser( - "generate", - help=MODE_DESCRIPTION["generate"], - ) - - # Commands for developers - dev_parsers = mode_parsers.add_parser( - "dev", - help=MODE_DESCRIPTION["dev"], - ).add_subparsers(dest="dev_command") - dev_parsers.add_parser("lsblk", help="List block devices the way disko sees them") - dev_parsers.add_parser("ansi", help="Print defined ansi color codes") - return root_parser.parse_args() - - -def main() -> None: - args = parse_args() - result = run(args) - output = exit_on_error(result) - if output: - info("Output:\n" + json.dumps(output, indent=2)) - - -if __name__ == "__main__": - main() +#!/usr/bin/env bash +# This script only exists so you can run `./disko2` directly +# It should not be be installed as part of the package! +# Check src/disko/cli.py for the actual entrypoint + +set -euo pipefail +( + cd "$(dirname "$(realpath "$0")")"/src + python3 -m disko "$@" +) \ No newline at end of file diff --git a/doc.nix b/doc.nix index d07f8f32..fae176fe 100644 --- a/doc.nix +++ b/doc.nix @@ -1,7 +1,7 @@ { lib, nixosOptionsDoc, runCommand, fetchurl, pandoc }: let - diskoLib = import ./lib { + diskoLib = import ./src/disko_lib { inherit lib; rootMountPoint = "/mnt"; }; diff --git a/docs/reference.md b/docs/reference.md index 104ba1da..2ae88429 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -4,7 +4,7 @@ We are currently having issues being able to generate proper module option documentation for our recursive disko types. However you can read the available -options [here](https://github.com/nix-community/disko/tree/master/lib/types). +options [here](https://github.com/nix-community/disko/tree/master/src/disko_lib/types). Combined with the [examples](https://github.com/nix-community/disko/tree/master/example) this hopefully gives you an overview. diff --git a/docs/testing.md b/docs/testing.md index 769b1a33..152270a5 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -7,7 +7,7 @@ the example is working in [the tests directory](../tests/). They utilize the We use a wrapper around this called `makeDiskoTest`. There is currently (as of 2024-10-16) no documentation for all its arguments, but you can have a look at -[its current code](https://github.com/nix-community/disko/blob/master/lib/tests.nix#L44C5-L58C10), +[its current code](https://github.com/nix-community/disko/blob/master/src/disko_lib/tests.nix#L44C5-L58C10), that should already be helpful. However, you don't need to know about all of the inner workings to interact with diff --git a/flake.nix b/flake.nix index 5d7d4ba7..e93bfb11 100644 --- a/flake.nix +++ b/flake.nix @@ -23,7 +23,7 @@ { nixosModules.default = self.nixosModules.disko; # convention nixosModules.disko.imports = [ ./module.nix ]; - lib = import ./lib { + lib = import ./src/disko_lib { inherit (nixpkgs) lib; }; packages = forAllSystems (system: @@ -32,6 +32,7 @@ in { disko = pkgs.callPackage ./package.nix { diskoVersion = version; }; + disko2 = pkgs.callPackage ./package-disko2.nix { diskoVersion = version; }; # alias to make `nix run` more convenient disko-install = self.packages.${system}.disko.overrideAttrs (_old: { name = "disko-install"; @@ -61,7 +62,7 @@ shellcheck = pkgs.runCommand "shellcheck" { nativeBuildInputs = [ pkgs.shellcheck ]; } '' cd ${./.} - shellcheck disk-deactivate/disk-deactivate disko + shellcheck src/disk-deactivate/disk-deactivate disko disko2 touch $out ''; in diff --git a/module.nix b/module.nix index 3f444de4..a7a9e568 100644 --- a/module.nix +++ b/module.nix @@ -4,13 +4,13 @@ let vmVariantWithDisko = extendModules { modules = [ - ./lib/interactive-vm.nix + ./src/disko_lib/interactive-vm.nix config.disko.tests.extraConfig ]; }; in { - imports = [ ./lib/make-disk-image.nix ]; + imports = [ ./src/disko_lib/make-disk-image.nix ]; options.disko = { imageBuilder = { @@ -205,7 +205,7 @@ in } ]; - _module.args.diskoLib = import ./lib { + _module.args.diskoLib = import ./src/disko_lib { inherit lib; rootMountPoint = config.disko.rootMountPoint; makeTest = import (pkgs.path + "/nixos/tests/make-test-python.nix"); diff --git a/package-disko2.nix b/package-disko2.nix new file mode 100644 index 00000000..3c9dc920 --- /dev/null +++ b/package-disko2.nix @@ -0,0 +1,42 @@ +{ python3Packages, lib, lix, coreutils, nixos-install-tools, binlore, diskoVersion }: + +let + self = python3Packages.buildPythonApplication { + pname = "disko2"; + version = diskoVersion; + src = ./.; + pyproject = true; + + build-system = [ python3Packages.setuptools ]; + dependencies = [ + lix # lix instead of nix because it produces way better eval errors + coreutils + nixos-install-tools + ]; + + # Otherwise resholve thinks that disko and disko-install might be able to execute their arguments + passthru.binlore.out = binlore.synthesize self '' + execer cannot bin/.disko2-wrapped + ''; + postInstall = '' + mkdir -p $out/share/disko/ + cp example/simple-efi.nix $out/share/disko/ + ''; + + makeWrapperArgs = [ "--set DISKO_VERSION ${diskoVersion}" ]; + + doCheck = true; + # installCheckPhase = '' + # $out/bin/disko2 mount $out/share/disko/simple-efi.nix + # ''; + meta = with lib; { + description = "Format disks with nix-config"; + homepage = "https://github.com/nix-community/disko"; + license = licenses.mit; + maintainers = with maintainers; [ lassulus ]; + platforms = platforms.linux; + mainProgram = "disko2"; + }; + }; +in +self diff --git a/package.nix b/package.nix index 3464854d..f1dd6e3f 100644 --- a/package.nix +++ b/package.nix @@ -9,7 +9,7 @@ let ]; installPhase = '' mkdir -p $out/bin $out/share/disko - cp -r install-cli.nix cli.nix default.nix disk-deactivate lib $out/share/disko + cp -r default.nix src $out/share/disko for i in disko disko-install; do sed -e "s|libexec_dir=\".*\"|libexec_dir=\"$out/share/disko\"|" "$i" > "$out/bin/$i" diff --git a/pyproject.toml b/pyproject.toml index 4934e1d0..9d968b90 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,23 @@ +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" + +[project] +name = "disko2" +version = "2.0.0-preview" + +[project.scripts] +disko2 = "disko:main" + +[tool.setuptools] +package-dir = { "" = "src" } + +[tool.setuptools.package-data] +"*" = ["*.nix"] + [tool.mypy] strict = true [tool.autoflake] remove_all_unused_imports = true -in_place = true \ No newline at end of file +in_place = true diff --git a/cli.nix b/src/cli.nix similarity index 98% rename from cli.nix rename to src/cli.nix index 3203cedf..cf8d9aa8 100644 --- a/cli.nix +++ b/src/cli.nix @@ -9,7 +9,7 @@ , ... }@args: let - disko = import ./. { + disko = import ../. { inherit rootMountPoint; inherit lib; }; diff --git a/disk-deactivate/disk-deactivate b/src/disk-deactivate/disk-deactivate similarity index 100% rename from disk-deactivate/disk-deactivate rename to src/disk-deactivate/disk-deactivate diff --git a/disk-deactivate/disk-deactivate.jq b/src/disk-deactivate/disk-deactivate.jq similarity index 100% rename from disk-deactivate/disk-deactivate.jq rename to src/disk-deactivate/disk-deactivate.jq diff --git a/src/disko/__init__.py b/src/disko/__init__.py new file mode 100644 index 00000000..9595be4a --- /dev/null +++ b/src/disko/__init__.py @@ -0,0 +1,5 @@ +from . import cli + + +def main() -> None: + cli.main() diff --git a/src/disko/__main__.py b/src/disko/__main__.py new file mode 100644 index 00000000..868d99ef --- /dev/null +++ b/src/disko/__main__.py @@ -0,0 +1,4 @@ +from . import main + +if __name__ == "__main__": + main() diff --git a/src/disko/cli.py b/src/disko/cli.py new file mode 100644 index 00000000..3adc8d56 --- /dev/null +++ b/src/disko/cli.py @@ -0,0 +1,157 @@ +#!/usr/bin/env python3 + +import argparse +import json +from pathlib import Path +from typing import Any, Literal, assert_never + +from disko_lib.ansi import disko_dev_ansi +from disko_lib.eval_config import eval_disko_file, eval_flake +from disko_lib.logging import LOGGER, debug, info +from disko_lib.result import DiskoError, DiskoSuccess, DiskoResult, exit_on_error +from disko_lib.types.disk import generate_config +from disko_lib.types.device import disko_dev_lsblk + +Mode = Literal[ + "destroy", + "format", + "mount", + "destroy,format,mount", + "format,mount", + "generate", + "dev", +] + + +# Modes to apply an existing configuration +APPLY_MODES: list[Mode] = [ + "destroy", + "format", + "mount", + "destroy,format,mount", + "format,mount", +] +ALL_MODES: list[Mode] = APPLY_MODES + ["generate", "dev"] + +MODE_DESCRIPTION: dict[Mode, str] = { + "destroy": "Destroy the partition tables on the specified disks", + "format": "Change formatting and filesystems on the specified disks", + "mount": "Mount the specified disks", + "destroy,format,mount": "Run destroy, format and mount in sequence", + "format,mount": "Run format and mount in sequence", + "generate": "Generate a disko configuration file from the system's current state", + "dev": "Print information useful for developers", +} + + +def run_apply( + *, mode: str, disko_file: str | None, flake: str | None, **_kwargs: dict[str, Any] +) -> DiskoResult[dict[str, Any]]: + # match would be nicer, but mypy doesn't understand type narrowing in tuples + if not disko_file and not flake: + return DiskoError.single_message("ERR_MISSING_ARGUMENTS", {}, "validate args") + if not disko_file and flake: + return eval_flake(flake) + if disko_file and not flake: + return eval_disko_file(Path(disko_file)) + + return DiskoError.single_message("ERR_TOO_MANY_ARGUMENTS", {}, "validate args") + + +def run_generate() -> DiskoResult[dict[str, Any]]: + return generate_config() + + +def run_dev(args: argparse.Namespace) -> DiskoResult[None]: + match args.dev_command: + case "lsblk": + return disko_dev_lsblk() + case "ansi": + return DiskoSuccess(None, disko_dev_ansi()) + case _: + assert_never(args.dev_command) + + +def run( + args: argparse.Namespace, +) -> DiskoResult[None | dict[str, Any]]: + if args.verbose: + LOGGER.setLevel("DEBUG") + debug("Enabled debug logging.") + + match args.mode: + case None: + return DiskoError.single_message( + "ERR_MISSING_MODE", {"valid_modes": ALL_MODES}, "select mode" + ) + case "generate": + return run_generate() + case "dev": + return run_dev(args) + case _: + return run_apply(**vars(args)) + + +def parse_args() -> argparse.Namespace: + root_parser = argparse.ArgumentParser( + prog="disko2", + description="Automated disk partitioning and formatting tool for NixOS", + ) + + root_parser.add_argument( + "--verbose", + "-v", + action="store_true", + default=False, + help="Print more detailed output, helpful for debugging", + ) + + mode_parsers = root_parser.add_subparsers(dest="mode") + + def create_apply_parser(mode: Mode) -> argparse.ArgumentParser: + parser = mode_parsers.add_parser( + mode, + help=MODE_DESCRIPTION[mode], + ) + parser.add_argument( + "disko_file", + nargs="?", + default=None, + help="Path to the disko configuration file", + ) + parser.add_argument( + "--flake", + "-f", + help="Flake to fetch the disko configuration from", + ) + return parser + + # Commands to apply an existing configuration + apply_parsers = [create_apply_parser(mode) for mode in APPLY_MODES] + + # Other commands + generate_parser = mode_parsers.add_parser( + "generate", + help=MODE_DESCRIPTION["generate"], + ) + + # Commands for developers + dev_parsers = mode_parsers.add_parser( + "dev", + help=MODE_DESCRIPTION["dev"], + ).add_subparsers(dest="dev_command") + dev_parsers.add_parser("lsblk", help="List block devices the way disko sees them") + dev_parsers.add_parser("ansi", help="Print defined ansi color codes") + return root_parser.parse_args() + + +def main() -> None: + args = parse_args() + result = run(args) + output = exit_on_error(result) + if output: + info("Output:\n" + json.dumps(output, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/lib/__init__.py b/src/disko_lib/__init__.py similarity index 100% rename from lib/__init__.py rename to src/disko_lib/__init__.py diff --git a/lib/ansi.py b/src/disko_lib/ansi.py similarity index 98% rename from lib/ansi.py rename to src/disko_lib/ansi.py index e33c9189..e031f107 100644 --- a/lib/ansi.py +++ b/src/disko_lib/ansi.py @@ -4,9 +4,6 @@ # See https://gist.github.com/rene-d/9e584a7dd2935d0f461904b9f2950007 -from typing import Any - - class Colors: """ ANSI escape sequences @@ -180,7 +177,6 @@ class Colors: def disko_dev_ansi() -> str: import inspect - from lib.result import DiskoSuccess # Import here to avoid circular dependency for name, value in inspect.getmembers(Colors): if value != "_" and not name.startswith("_") and name != "RESET": diff --git a/lib/default.nix b/src/disko_lib/default.nix similarity index 89% rename from lib/default.nix rename to src/disko_lib/default.nix index d19acac6..40143342 100644 --- a/lib/default.nix +++ b/src/disko_lib/default.nix @@ -4,7 +4,6 @@ , eval-config ? import }: let - outputs = import ../default.nix { inherit lib diskoLib; }; diskoLib = { testLib = import ./tests.nix { inherit lib makeTest eval-config; }; # like lib.types.oneOf but instead of a list takes an attrset @@ -79,7 +78,7 @@ let then "${dev}p${toString index}" # /dev/mapper/vg-lv1 style else abort '' - ${dev} seems not to be a supported disk format. Please add this to disko in https://github.com/nix-community/disko/blob/master/lib/default.nix + ${dev} seems not to be a supported disk format. Please add this to disko in https://github.com/nix-community/disko/blob/master/src/disko_lib/default.nix ''; /* Escape a string as required to be used in udev symlinks @@ -213,11 +212,11 @@ let isAttrsOfSubmodule = o: o.type.name == "attrsOf" && o.type.nestedTypes.elemType.name == "submodule"; isSerializable = n: o: !( lib.hasPrefix "_" n - || lib.hasSuffix "Hook" n - || isAttrsOfSubmodule o - # TODO don't hardcode diskoLib.subType options. - || n == "content" || n == "partitions" || n == "datasets" || n == "swap" - || n == "mode" + || lib.hasSuffix "Hook" n + || isAttrsOfSubmodule o + # TODO don't hardcode diskoLib.subType options. + || n == "content" || n == "partitions" || n == "datasets" || n == "swap" + || n == "mode" ); in lib.toShellVars @@ -297,6 +296,23 @@ let }; + /* Evaluate a disko configuration + + eval :: lib.types.devices -> AttrSet + */ + eval-disko = cfg: lib.evalModules { + modules = lib.singleton { + # _file = toString input; + imports = lib.singleton { disko.devices = cfg.disko.devices; }; + options = { + disko.devices = lib.mkOption { + type = diskoLib.toplevel; + }; + }; + }; + }; + + /* Takes a disko device specification, returns an attrset with metadata meta :: lib.types.devices -> AttrSet @@ -638,6 +654,44 @@ let (lib.attrNames (builtins.readDir ./types)) ); - } // outputs; + + }; + outputs = + { lib ? import + , rootMountPoint ? "/mnt" + , checked ? false + , diskoLib ? import ./. { inherit lib rootMountPoint; } + }: + let + eval = diskoLib.eval-disko; + in + { + # legacy alias + create = cfg: builtins.trace "the create output is deprecated, use format instead" (eval cfg).config.disko.devices._create; + createScript = cfg: pkgs: builtins.trace "the create output is deprecated, use format instead" ((eval cfg).config.disko.devices._scripts { inherit pkgs checked; }).formatScript; + createScriptNoDeps = cfg: pkgs: builtins.trace "the create output is deprecated, use format instead" ((eval cfg).config.disko.devices._scripts { inherit pkgs checked; }).formatScriptNoDeps; + + format = cfg: (eval cfg).config.disko.devices._create; + formatScript = cfg: pkgs: ((eval cfg).config.disko.devices._scripts { inherit pkgs checked; }).formatScript; + formatScriptNoDeps = cfg: pkgs: ((eval cfg).config.disko.devices._scripts { inherit pkgs checked; }).formatScriptNoDeps; + + mount = cfg: (eval cfg).config.disko.devices._mount; + mountScript = cfg: pkgs: ((eval cfg).config.disko.devices._scripts { inherit pkgs checked; }).mountScript; + mountScriptNoDeps = cfg: pkgs: ((eval cfg).config.disko.devices._scripts { inherit pkgs checked; }).mountScriptNoDeps; + + disko = cfg: (eval cfg).config.disko.devices._disko; + diskoScript = cfg: pkgs: ((eval cfg).config.disko.devices._scripts { inherit pkgs checked; }).diskoScript; + diskoScriptNoDeps = cfg: pkgs: ((eval cfg).config.disko.devices._scripts { inherit pkgs checked; }).diskoScriptNoDeps; + + # we keep this old output for backwards compatibility + diskoNoDeps = cfg: pkgs: builtins.trace "the diskoNoDeps output is deprecated, please use disko instead" ((eval cfg).config.disko.devices._scripts { inherit pkgs checked; }).diskoScriptNoDeps; + + config = cfg: (eval cfg).config.disko.devices._config; + packages = cfg: (eval cfg).config.disko.devices._packages; + }; in diskoLib +// (outputs { + inherit lib rootMountPoint; +}) # Compatibility alias + // { inherit outputs; } diff --git a/lib/eval-config.nix b/src/disko_lib/eval-config.nix similarity index 97% rename from lib/eval-config.nix rename to src/disko_lib/eval-config.nix index 88662133..9225f2c6 100644 --- a/lib/eval-config.nix +++ b/src/disko_lib/eval-config.nix @@ -36,7 +36,7 @@ let evaluatedConfig = if hasDiskoFile || hasFlakeDiskoConfig then - disko.eval diskFormat + disko.eval-disko diskFormat else if (lib.traceValSeq hasFlakeDiskoModule) then flake'.nixosConfigurations.${flakeAttr} else diff --git a/lib/eval_config.py b/src/disko_lib/eval_config.py similarity index 96% rename from lib/eval_config.py rename to src/disko_lib/eval_config.py index 5715e856..b3a81fe3 100644 --- a/lib/eval_config.py +++ b/src/disko_lib/eval_config.py @@ -3,8 +3,8 @@ import re from typing import Any -from lib.run_cmd import run -from lib.result import DiskoError, DiskoResult, DiskoSuccess +from .run_cmd import run +from .result import DiskoError, DiskoResult, DiskoSuccess NIX_BASE_CMD = [ "nix", diff --git a/lib/interactive-vm.nix b/src/disko_lib/interactive-vm.nix similarity index 100% rename from lib/interactive-vm.nix rename to src/disko_lib/interactive-vm.nix diff --git a/lib/logging.py b/src/disko_lib/logging.py similarity index 99% rename from lib/logging.py rename to src/disko_lib/logging.py index c0995062..3609f592 100644 --- a/lib/logging.py +++ b/src/disko_lib/logging.py @@ -5,7 +5,7 @@ import re from typing import Any, Literal, assert_never -from lib.ansi import Colors +from .ansi import Colors logging.basicConfig(format="%(message)s", level=logging.INFO) LOGGER = logging.getLogger("disko_logger") diff --git a/lib/make-disk-image.nix b/src/disko_lib/make-disk-image.nix similarity index 100% rename from lib/make-disk-image.nix rename to src/disko_lib/make-disk-image.nix diff --git a/lib/result.py b/src/disko_lib/result.py similarity index 93% rename from lib/result.py rename to src/disko_lib/result.py index b11d4c4d..49b4e21b 100644 --- a/lib/result.py +++ b/src/disko_lib/result.py @@ -1,7 +1,7 @@ from dataclasses import dataclass from typing import Any, Generic, Literal, TypeVar -from lib.logging import DiskoMessage, debug, print_msg, MessageCode +from .logging import DiskoMessage, debug, print_msg, MessageCode T = TypeVar("T", covariant=True) diff --git a/lib/run_cmd.py b/src/disko_lib/run_cmd.py similarity index 88% rename from lib/run_cmd.py rename to src/disko_lib/run_cmd.py index 9cc3fd45..308a16c4 100644 --- a/lib/run_cmd.py +++ b/src/disko_lib/run_cmd.py @@ -1,7 +1,7 @@ import subprocess -from lib.logging import debug -from lib.result import DiskoError, DiskoResult, DiskoSuccess +from .logging import debug +from .result import DiskoError, DiskoResult, DiskoSuccess def run(args: list[str]) -> DiskoResult[str]: diff --git a/lib/tests.nix b/src/disko_lib/tests.nix similarity index 98% rename from lib/tests.nix rename to src/disko_lib/tests.nix index 0aff4207..58501d41 100644 --- a/lib/tests.nix +++ b/src/disko_lib/tests.nix @@ -79,7 +79,7 @@ let # so /dev/vdb becomes /dev/vda etc. testConfigBooted = testLib.prepareDiskoConfig diskoConfigWithArgs testLib.devices; - tsp-generator = pkgs.callPackage ../. { checked = true; }; + tsp-generator = pkgs.callPackage ../../. { checked = true; }; tsp-format = (tsp-generator.formatScript testConfigInstall) pkgs; tsp-mount = (tsp-generator.mountScript testConfigInstall) pkgs; tsp-disko = (tsp-generator.diskoScript testConfigInstall) pkgs; @@ -92,7 +92,7 @@ let (lib.optionalAttrs (testMode == "module") { disko.enableConfig = true; imports = [ - ../module.nix + ../../module.nix testConfigBooted ]; }) @@ -168,7 +168,7 @@ let imports = [ (lib.optionalAttrs (testMode == "module") { imports = [ - ../module.nix + ../../module.nix ]; disko = { enableConfig = false; diff --git a/lib/types/__init__.py b/src/disko_lib/types/__init__.py similarity index 100% rename from lib/types/__init__.py rename to src/disko_lib/types/__init__.py diff --git a/lib/types/btrfs.nix b/src/disko_lib/types/btrfs.nix similarity index 100% rename from lib/types/btrfs.nix rename to src/disko_lib/types/btrfs.nix diff --git a/lib/types/device.py b/src/disko_lib/types/device.py similarity index 97% rename from lib/types/device.py rename to src/disko_lib/types/device.py index 00779a20..db3a15ed 100644 --- a/lib/types/device.py +++ b/src/disko_lib/types/device.py @@ -3,8 +3,8 @@ from pathlib import Path from typing import Any -from lib.result import DiskoError, DiskoResult, DiskoSuccess -from lib.run_cmd import run +from ..result import DiskoError, DiskoResult, DiskoSuccess +from ..run_cmd import run # To see what other fields are available in the lsblk output and what # sort of values you can expect from them, run: diff --git a/lib/types/disk.nix b/src/disko_lib/types/disk.nix similarity index 100% rename from lib/types/disk.nix rename to src/disko_lib/types/disk.nix diff --git a/lib/types/disk.py b/src/disko_lib/types/disk.py similarity index 91% rename from lib/types/disk.py rename to src/disko_lib/types/disk.py index 6c1bbf67..2b74cef8 100644 --- a/lib/types/disk.py +++ b/src/disko_lib/types/disk.py @@ -1,9 +1,9 @@ from typing import Any -from lib.logging import DiskoMessage, debug -from lib.result import DiskoError, DiskoResult, DiskoSuccess -from lib.types.device import BlockDevice, list_block_devices -import lib.types.gpt as gpt +from ..logging import DiskoMessage, debug +from ..result import DiskoError, DiskoResult, DiskoSuccess +from ..types.device import BlockDevice, list_block_devices +from . import gpt def _generate_content(device: BlockDevice) -> DiskoResult[dict[str, Any]]: diff --git a/lib/types/filesystem.nix b/src/disko_lib/types/filesystem.nix similarity index 100% rename from lib/types/filesystem.nix rename to src/disko_lib/types/filesystem.nix diff --git a/lib/types/filesystem.py b/src/disko_lib/types/filesystem.py similarity index 81% rename from lib/types/filesystem.py rename to src/disko_lib/types/filesystem.py index 168df875..2fb080df 100644 --- a/lib/types/filesystem.py +++ b/src/disko_lib/types/filesystem.py @@ -1,6 +1,6 @@ from typing import Any -from lib.types.device import BlockDevice -from lib.result import DiskoResult, DiskoSuccess +from .device import BlockDevice +from ..result import DiskoResult, DiskoSuccess def generate_config(device: BlockDevice) -> DiskoResult[dict[str, Any]]: diff --git a/lib/types/gpt.nix b/src/disko_lib/types/gpt.nix similarity index 100% rename from lib/types/gpt.nix rename to src/disko_lib/types/gpt.nix diff --git a/lib/types/gpt.py b/src/disko_lib/types/gpt.py similarity index 91% rename from lib/types/gpt.py rename to src/disko_lib/types/gpt.py index b5cc5c82..eafac997 100644 --- a/lib/types/gpt.py +++ b/src/disko_lib/types/gpt.py @@ -1,8 +1,8 @@ from typing import Any -from lib.logging import debug -from lib.types import filesystem -from lib.types.device import BlockDevice -from lib.result import DiskoError, DiskoResult, DiskoSuccess +from ..logging import debug +from . import filesystem +from .device import BlockDevice +from ..result import DiskoError, DiskoResult, DiskoSuccess def _add_type_if_required( diff --git a/lib/types/luks.nix b/src/disko_lib/types/luks.nix similarity index 100% rename from lib/types/luks.nix rename to src/disko_lib/types/luks.nix diff --git a/lib/types/lvm_pv.nix b/src/disko_lib/types/lvm_pv.nix similarity index 100% rename from lib/types/lvm_pv.nix rename to src/disko_lib/types/lvm_pv.nix diff --git a/lib/types/lvm_vg.nix b/src/disko_lib/types/lvm_vg.nix similarity index 100% rename from lib/types/lvm_vg.nix rename to src/disko_lib/types/lvm_vg.nix diff --git a/lib/types/mdadm.nix b/src/disko_lib/types/mdadm.nix similarity index 100% rename from lib/types/mdadm.nix rename to src/disko_lib/types/mdadm.nix diff --git a/lib/types/mdraid.nix b/src/disko_lib/types/mdraid.nix similarity index 100% rename from lib/types/mdraid.nix rename to src/disko_lib/types/mdraid.nix diff --git a/lib/types/nodev.nix b/src/disko_lib/types/nodev.nix similarity index 100% rename from lib/types/nodev.nix rename to src/disko_lib/types/nodev.nix diff --git a/lib/types/swap.nix b/src/disko_lib/types/swap.nix similarity index 100% rename from lib/types/swap.nix rename to src/disko_lib/types/swap.nix diff --git a/lib/types/table.nix b/src/disko_lib/types/table.nix similarity index 100% rename from lib/types/table.nix rename to src/disko_lib/types/table.nix diff --git a/lib/types/zfs.nix b/src/disko_lib/types/zfs.nix similarity index 100% rename from lib/types/zfs.nix rename to src/disko_lib/types/zfs.nix diff --git a/lib/types/zfs_fs.nix b/src/disko_lib/types/zfs_fs.nix similarity index 100% rename from lib/types/zfs_fs.nix rename to src/disko_lib/types/zfs_fs.nix diff --git a/lib/types/zfs_volume.nix b/src/disko_lib/types/zfs_volume.nix similarity index 100% rename from lib/types/zfs_volume.nix rename to src/disko_lib/types/zfs_volume.nix diff --git a/lib/types/zpool.nix b/src/disko_lib/types/zpool.nix similarity index 100% rename from lib/types/zpool.nix rename to src/disko_lib/types/zpool.nix diff --git a/install-cli.nix b/src/install-cli.nix similarity index 100% rename from install-cli.nix rename to src/install-cli.nix diff --git a/tests/bcachefs.nix b/tests/bcachefs.nix index f8806d55..9a9b6c1a 100644 --- a/tests/bcachefs.nix +++ b/tests/bcachefs.nix @@ -1,5 +1,5 @@ { pkgs ? import { } -, diskoLib ? pkgs.callPackage ../lib { } +, diskoLib ? pkgs.callPackage ../src/disko_lib { } }: diskoLib.testLib.makeDiskoTest { inherit pkgs; diff --git a/tests/boot-raid1.nix b/tests/boot-raid1.nix index e020fdd8..3ed42a83 100644 --- a/tests/boot-raid1.nix +++ b/tests/boot-raid1.nix @@ -1,5 +1,5 @@ { pkgs ? import { } -, diskoLib ? pkgs.callPackage ../lib { } +, diskoLib ? pkgs.callPackage ../src/disko_lib { } }: diskoLib.testLib.makeDiskoTest { inherit pkgs; diff --git a/tests/btrfs-only-root-subvolume.nix b/tests/btrfs-only-root-subvolume.nix index 818aec70..33f0d17d 100644 --- a/tests/btrfs-only-root-subvolume.nix +++ b/tests/btrfs-only-root-subvolume.nix @@ -1,5 +1,5 @@ { pkgs ? import { } -, diskoLib ? pkgs.callPackage ../lib { } +, diskoLib ? pkgs.callPackage ../src/disko_lib { } }: diskoLib.testLib.makeDiskoTest { inherit pkgs; diff --git a/tests/btrfs-subvolumes.nix b/tests/btrfs-subvolumes.nix index 7059c1a1..df01a444 100644 --- a/tests/btrfs-subvolumes.nix +++ b/tests/btrfs-subvolumes.nix @@ -1,5 +1,5 @@ { pkgs ? import { } -, diskoLib ? pkgs.callPackage ../lib { } +, diskoLib ? pkgs.callPackage ../src/disko_lib { } }: diskoLib.testLib.makeDiskoTest { inherit pkgs; diff --git a/tests/cli.nix b/tests/cli.nix index 76cc2b1c..3f81ec64 100644 --- a/tests/cli.nix +++ b/tests/cli.nix @@ -1,5 +1,5 @@ { pkgs ? import { } -, diskoLib ? pkgs.callPackage ../lib { } +, diskoLib ? pkgs.callPackage ../src/disko_lib { } }: diskoLib.testLib.makeDiskoTest { inherit pkgs; diff --git a/tests/complex.nix b/tests/complex.nix index 26cc5e9d..613d5fe6 100644 --- a/tests/complex.nix +++ b/tests/complex.nix @@ -1,5 +1,5 @@ { pkgs ? import { } -, diskoLib ? pkgs.callPackage ../lib { } +, diskoLib ? pkgs.callPackage ../src/disko_lib { } }: diskoLib.testLib.makeDiskoTest { inherit pkgs; diff --git a/tests/default.nix b/tests/default.nix index cb9d47d3..af0c9060 100644 --- a/tests/default.nix +++ b/tests/default.nix @@ -4,7 +4,7 @@ }: let lib = pkgs.lib; - diskoLib = import ../lib { inherit lib makeTest eval-config; }; + diskoLib = import ../src/disko_lib { inherit lib makeTest eval-config; }; allTestFilenames = builtins.map (lib.removeSuffix ".nix") ( diff --git a/tests/f2fs.nix b/tests/f2fs.nix index ebcf8d8d..98c79de9 100644 --- a/tests/f2fs.nix +++ b/tests/f2fs.nix @@ -1,5 +1,5 @@ { pkgs ? import { } -, diskoLib ? pkgs.callPackage ../lib { } +, diskoLib ? pkgs.callPackage ../src/disko_lib { } }: diskoLib.testLib.makeDiskoTest { inherit pkgs; diff --git a/tests/gpt-bios-compat.nix b/tests/gpt-bios-compat.nix index de45d3b6..08a6ec55 100644 --- a/tests/gpt-bios-compat.nix +++ b/tests/gpt-bios-compat.nix @@ -1,5 +1,5 @@ { pkgs ? import { } -, diskoLib ? pkgs.callPackage ../lib { } +, diskoLib ? pkgs.callPackage ../src/disko_lib { } }: diskoLib.testLib.makeDiskoTest { inherit pkgs; diff --git a/tests/gpt-name-with-special-chars.nix b/tests/gpt-name-with-special-chars.nix index 48b4304d..73a8a1aa 100644 --- a/tests/gpt-name-with-special-chars.nix +++ b/tests/gpt-name-with-special-chars.nix @@ -1,5 +1,5 @@ { pkgs ? import { } -, diskoLib ? pkgs.callPackage ../lib { } +, diskoLib ? pkgs.callPackage ../src/disko_lib { } }: diskoLib.testLib.makeDiskoTest { inherit pkgs; diff --git a/tests/hybrid-mbr.nix b/tests/hybrid-mbr.nix index de68b264..3559dd48 100644 --- a/tests/hybrid-mbr.nix +++ b/tests/hybrid-mbr.nix @@ -1,5 +1,5 @@ { pkgs ? import { } -, diskoLib ? pkgs.callPackage ../lib { } +, diskoLib ? pkgs.callPackage ../src/disko_lib { } }: diskoLib.testLib.makeDiskoTest { inherit pkgs; diff --git a/tests/hybrid-tmpfs-on-root.nix b/tests/hybrid-tmpfs-on-root.nix index 09d15d6a..0b3e3011 100644 --- a/tests/hybrid-tmpfs-on-root.nix +++ b/tests/hybrid-tmpfs-on-root.nix @@ -1,5 +1,5 @@ { pkgs ? import { } -, diskoLib ? pkgs.callPackage ../lib { } +, diskoLib ? pkgs.callPackage ../src/disko_lib { } }: diskoLib.testLib.makeDiskoTest { inherit pkgs; diff --git a/tests/hybrid.nix b/tests/hybrid.nix index adf7ccd1..b8c2cdd1 100644 --- a/tests/hybrid.nix +++ b/tests/hybrid.nix @@ -1,5 +1,5 @@ { pkgs ? import { } -, diskoLib ? pkgs.callPackage ../lib { } +, diskoLib ? pkgs.callPackage ../src/disko_lib { } }: diskoLib.testLib.makeDiskoTest { inherit pkgs; diff --git a/tests/legacy-table-with-whitespace.nix b/tests/legacy-table-with-whitespace.nix index 300c641f..f053a602 100644 --- a/tests/legacy-table-with-whitespace.nix +++ b/tests/legacy-table-with-whitespace.nix @@ -1,5 +1,5 @@ { pkgs ? import { } -, diskoLib ? pkgs.callPackage ../lib { } +, diskoLib ? pkgs.callPackage ../src/disko_lib { } }: diskoLib.testLib.makeDiskoTest { inherit pkgs; diff --git a/tests/legacy-table.nix b/tests/legacy-table.nix index 2c7b6461..cd74116f 100644 --- a/tests/legacy-table.nix +++ b/tests/legacy-table.nix @@ -1,5 +1,5 @@ { pkgs ? import { } -, diskoLib ? pkgs.callPackage ../lib { } +, diskoLib ? pkgs.callPackage ../src/disko_lib { } }: diskoLib.testLib.makeDiskoTest { inherit pkgs; diff --git a/tests/long-device-name.nix b/tests/long-device-name.nix index e328a9fe..d3bea86d 100644 --- a/tests/long-device-name.nix +++ b/tests/long-device-name.nix @@ -1,5 +1,5 @@ { pkgs ? import { } -, diskoLib ? pkgs.callPackage ../lib { } +, diskoLib ? pkgs.callPackage ../src/disko_lib { } }: diskoLib.testLib.makeDiskoTest { inherit pkgs; diff --git a/tests/luks-btrfs-raid.nix b/tests/luks-btrfs-raid.nix index 6e127498..33e035a9 100644 --- a/tests/luks-btrfs-raid.nix +++ b/tests/luks-btrfs-raid.nix @@ -1,6 +1,6 @@ -{ - pkgs ? import { }, - diskoLib ? pkgs.callPackage ../lib { }, +{ pkgs ? import { } +, diskoLib ? pkgs.callPackage ../src/disko_lib { } +, }: diskoLib.testLib.makeDiskoTest { inherit pkgs; diff --git a/tests/luks-btrfs-subvolumes.nix b/tests/luks-btrfs-subvolumes.nix index 6a4e64b3..ccd4418e 100644 --- a/tests/luks-btrfs-subvolumes.nix +++ b/tests/luks-btrfs-subvolumes.nix @@ -1,5 +1,5 @@ { pkgs ? import { } -, diskoLib ? pkgs.callPackage ../lib { } +, diskoLib ? pkgs.callPackage ../src/disko_lib { } }: diskoLib.testLib.makeDiskoTest { inherit pkgs; diff --git a/tests/luks-interactive-login.nix b/tests/luks-interactive-login.nix index 6fae2e11..54d06c61 100644 --- a/tests/luks-interactive-login.nix +++ b/tests/luks-interactive-login.nix @@ -1,5 +1,5 @@ { pkgs ? import { } -, diskoLib ? pkgs.callPackage ../lib { } +, diskoLib ? pkgs.callPackage ../src/disko_lib { } }: diskoLib.testLib.makeDiskoTest { inherit pkgs; diff --git a/tests/luks-lvm.nix b/tests/luks-lvm.nix index 848a5b7e..4b950854 100644 --- a/tests/luks-lvm.nix +++ b/tests/luks-lvm.nix @@ -1,5 +1,5 @@ { pkgs ? import { } -, diskoLib ? pkgs.callPackage ../lib { } +, diskoLib ? pkgs.callPackage ../src/disko_lib { } }: diskoLib.testLib.makeDiskoTest { inherit pkgs; diff --git a/tests/luks-on-mdadm.nix b/tests/luks-on-mdadm.nix index bd49762e..4a23fbf4 100644 --- a/tests/luks-on-mdadm.nix +++ b/tests/luks-on-mdadm.nix @@ -1,5 +1,5 @@ { pkgs ? import { } -, diskoLib ? pkgs.callPackage ../lib { } +, diskoLib ? pkgs.callPackage ../src/disko_lib { } }: diskoLib.testLib.makeDiskoTest { inherit pkgs; diff --git a/tests/lvm-raid.nix b/tests/lvm-raid.nix index b30332a8..5d3debfc 100644 --- a/tests/lvm-raid.nix +++ b/tests/lvm-raid.nix @@ -1,5 +1,5 @@ { pkgs ? import { } -, diskoLib ? pkgs.callPackage ../lib { } +, diskoLib ? pkgs.callPackage ../src/disko_lib { } }: diskoLib.testLib.makeDiskoTest { inherit pkgs; diff --git a/tests/lvm-sizes-sort.nix b/tests/lvm-sizes-sort.nix index 83ed472a..88252cbe 100644 --- a/tests/lvm-sizes-sort.nix +++ b/tests/lvm-sizes-sort.nix @@ -1,5 +1,5 @@ { pkgs ? import { } -, diskoLib ? pkgs.callPackage ../lib { } +, diskoLib ? pkgs.callPackage ../src/disko_lib { } }: diskoLib.testLib.makeDiskoTest { inherit pkgs; diff --git a/tests/lvm-thin.nix b/tests/lvm-thin.nix index bfbcfc19..149efc22 100644 --- a/tests/lvm-thin.nix +++ b/tests/lvm-thin.nix @@ -1,5 +1,5 @@ { pkgs ? import { } -, diskoLib ? pkgs.callPackage ../lib { } +, diskoLib ? pkgs.callPackage ../src/disko_lib { } }: diskoLib.testLib.makeDiskoTest { inherit pkgs; diff --git a/tests/mdadm-raid0.nix b/tests/mdadm-raid0.nix index 7d40109d..3356cf6a 100644 --- a/tests/mdadm-raid0.nix +++ b/tests/mdadm-raid0.nix @@ -1,5 +1,5 @@ { pkgs ? import { } -, diskoLib ? pkgs.callPackage ../lib { } +, diskoLib ? pkgs.callPackage ../src/disko_lib { } }: diskoLib.testLib.makeDiskoTest { inherit pkgs; diff --git a/tests/mdadm.nix b/tests/mdadm.nix index 3bd90377..bfff5e5f 100644 --- a/tests/mdadm.nix +++ b/tests/mdadm.nix @@ -1,5 +1,5 @@ { pkgs ? import { } -, diskoLib ? pkgs.callPackage ../lib { } +, diskoLib ? pkgs.callPackage ../src/disko_lib { } }: diskoLib.testLib.makeDiskoTest { inherit pkgs; diff --git a/tests/module.nix b/tests/module.nix index c6eb0927..419f4df6 100644 --- a/tests/module.nix +++ b/tests/module.nix @@ -1,5 +1,5 @@ { pkgs ? import { } -, diskoLib ? pkgs.callPackage ../lib { } +, diskoLib ? pkgs.callPackage ../src/disko_lib { } }: diskoLib.testLib.makeDiskoTest { inherit pkgs; diff --git a/tests/multi-device-no-deps.nix b/tests/multi-device-no-deps.nix index 77e34775..3138f699 100644 --- a/tests/multi-device-no-deps.nix +++ b/tests/multi-device-no-deps.nix @@ -1,6 +1,6 @@ # this is a regression test for https://github.com/nix-community/disko/issues/52 { pkgs ? import { } -, diskoLib ? pkgs.callPackage ../lib { } +, diskoLib ? pkgs.callPackage ../src/disko_lib { } }: diskoLib.testLib.makeDiskoTest { inherit pkgs; diff --git a/tests/negative-size.nix b/tests/negative-size.nix index b891b081..1d948e5f 100644 --- a/tests/negative-size.nix +++ b/tests/negative-size.nix @@ -1,6 +1,6 @@ # this is a regression test for https://github.com/nix-community/disko/issues/52 { pkgs ? import { } -, diskoLib ? pkgs.callPackage ../lib { } +, diskoLib ? pkgs.callPackage ../src/disko_lib { } }: diskoLib.testLib.makeDiskoTest { inherit pkgs; diff --git a/tests/non-root-zfs.nix b/tests/non-root-zfs.nix index 2338483a..292cdcc5 100644 --- a/tests/non-root-zfs.nix +++ b/tests/non-root-zfs.nix @@ -1,5 +1,5 @@ { pkgs ? import { } -, diskoLib ? pkgs.callPackage ../lib { } +, diskoLib ? pkgs.callPackage ../src/disko_lib { } }: diskoLib.testLib.makeDiskoTest { inherit pkgs; diff --git a/tests/simple-efi.nix b/tests/simple-efi.nix index f3b90719..b16416b4 100644 --- a/tests/simple-efi.nix +++ b/tests/simple-efi.nix @@ -1,5 +1,5 @@ { pkgs ? import { } -, diskoLib ? pkgs.callPackage ../lib { } +, diskoLib ? pkgs.callPackage ../src/disko_lib { } }: diskoLib.testLib.makeDiskoTest { inherit pkgs; diff --git a/tests/swap.nix b/tests/swap.nix index 7d1678b2..0ae4b238 100644 --- a/tests/swap.nix +++ b/tests/swap.nix @@ -1,5 +1,5 @@ { pkgs ? import { } -, diskoLib ? pkgs.callPackage ../lib { } +, diskoLib ? pkgs.callPackage ../src/disko_lib { } }: diskoLib.testLib.makeDiskoTest { inherit pkgs; diff --git a/tests/tmpfs.nix b/tests/tmpfs.nix index 21cb2174..6fff602e 100644 --- a/tests/tmpfs.nix +++ b/tests/tmpfs.nix @@ -1,5 +1,5 @@ { pkgs ? import { } -, diskoLib ? pkgs.callPackage ../lib { } +, diskoLib ? pkgs.callPackage ../src/disko_lib { } }: diskoLib.testLib.makeDiskoTest { inherit pkgs; diff --git a/tests/with-lib.nix b/tests/with-lib.nix index d8274b7e..c78e4fd8 100644 --- a/tests/with-lib.nix +++ b/tests/with-lib.nix @@ -1,5 +1,5 @@ { pkgs ? import { } -, diskoLib ? pkgs.callPackage ../lib { } +, diskoLib ? pkgs.callPackage ../src/disko_lib { } }: diskoLib.testLib.makeDiskoTest { inherit pkgs; diff --git a/tests/zfs-over-legacy.nix b/tests/zfs-over-legacy.nix index af780606..2cdebc3a 100644 --- a/tests/zfs-over-legacy.nix +++ b/tests/zfs-over-legacy.nix @@ -1,5 +1,5 @@ { pkgs ? import { } -, diskoLib ? pkgs.callPackage ../lib { } +, diskoLib ? pkgs.callPackage ../src/disko_lib { } }: diskoLib.testLib.makeDiskoTest { inherit pkgs; diff --git a/tests/zfs-with-vdevs.nix b/tests/zfs-with-vdevs.nix index da301a01..7dda11aa 100644 --- a/tests/zfs-with-vdevs.nix +++ b/tests/zfs-with-vdevs.nix @@ -1,5 +1,5 @@ { pkgs ? import { } -, diskoLib ? pkgs.callPackage ../lib { } +, diskoLib ? pkgs.callPackage ../src/disko_lib { } }: diskoLib.testLib.makeDiskoTest { inherit pkgs; diff --git a/tests/zfs.nix b/tests/zfs.nix index da2e49cd..81ab7b89 100644 --- a/tests/zfs.nix +++ b/tests/zfs.nix @@ -1,5 +1,5 @@ { pkgs ? import { } -, diskoLib ? pkgs.callPackage ../lib { } +, diskoLib ? pkgs.callPackage ../src/disko_lib { } }: diskoLib.testLib.makeDiskoTest { inherit pkgs; From 737e045f53c198b13e01f92b8f9d6ce9530e6c15 Mon Sep 17 00:00:00 2001 From: Felix Uhl Date: Thu, 31 Oct 2024 16:48:27 +0100 Subject: [PATCH 13/37] tests: Move example tests to dedicated folder --- ...ce.nix => gpt-name-with-special-chars.nix} | 0 flake.nix | 1 + src/tests/test_disk.py | 0 tests/default.nix | 25 +++++++++++++------ tests/example/README.md | 3 +++ tests/{ => example}/bcachefs.nix | 2 +- tests/{ => example}/boot-raid1.nix | 2 +- .../btrfs-only-root-subvolume.nix | 2 +- tests/{ => example}/btrfs-subvolumes.nix | 2 +- tests/{ => example}/complex.nix | 2 +- tests/{ => example}/f2fs.nix | 2 +- tests/{ => example}/gpt-bios-compat.nix | 2 +- .../gpt-name-with-special-chars.nix | 2 +- tests/{ => example}/hybrid-mbr.nix | 2 +- tests/{ => example}/hybrid-tmpfs-on-root.nix | 2 +- tests/{ => example}/hybrid.nix | 2 +- .../legacy-table-with-whitespace.nix | 2 +- tests/{ => example}/legacy-table.nix | 2 +- tests/{ => example}/long-device-name.nix | 2 +- tests/{ => example}/luks-btrfs-raid.nix | 2 +- tests/{ => example}/luks-btrfs-subvolumes.nix | 2 +- .../{ => example}/luks-interactive-login.nix | 2 +- tests/{ => example}/luks-lvm.nix | 2 +- tests/{ => example}/luks-on-mdadm.nix | 2 +- tests/{ => example}/lvm-raid.nix | 2 +- tests/{ => example}/lvm-sizes-sort.nix | 2 +- tests/{ => example}/lvm-thin.nix | 2 +- tests/{ => example}/mdadm-raid0.nix | 2 +- tests/{ => example}/mdadm.nix | 2 +- tests/{ => example}/multi-device-no-deps.nix | 2 +- tests/{ => example}/negative-size.nix | 2 +- tests/{ => example}/non-root-zfs.nix | 2 +- tests/{ => example}/simple-efi.nix | 2 +- .../stand-alone.nix} | 2 +- tests/{ => example}/swap.nix | 2 +- tests/{ => example}/tmpfs.nix | 2 +- tests/{ => example}/with-lib.nix | 2 +- tests/{ => example}/zfs-over-legacy.nix | 2 +- tests/{ => example}/zfs-with-vdevs.nix | 2 +- tests/{ => example}/zfs.nix | 2 +- 40 files changed, 57 insertions(+), 42 deletions(-) rename example/{gpt-name-with-whitespace.nix => gpt-name-with-special-chars.nix} (100%) create mode 100644 src/tests/test_disk.py create mode 100644 tests/example/README.md rename tests/{ => example}/bcachefs.nix (89%) rename tests/{ => example}/boot-raid1.nix (89%) rename tests/{ => example}/btrfs-only-root-subvolume.nix (80%) rename tests/{ => example}/btrfs-subvolumes.nix (93%) rename tests/{ => example}/complex.nix (95%) rename tests/{ => example}/f2fs.nix (90%) rename tests/{ => example}/gpt-bios-compat.nix (82%) rename tests/{ => example}/gpt-name-with-special-chars.nix (85%) rename tests/{ => example}/hybrid-mbr.nix (82%) rename tests/{ => example}/hybrid-tmpfs-on-root.nix (83%) rename tests/{ => example}/hybrid.nix (83%) rename tests/{ => example}/legacy-table-with-whitespace.nix (81%) rename tests/{ => example}/legacy-table.nix (82%) rename tests/{ => example}/long-device-name.nix (81%) rename tests/{ => example}/luks-btrfs-raid.nix (86%) rename tests/{ => example}/luks-btrfs-subvolumes.nix (88%) rename tests/{ => example}/luks-interactive-login.nix (85%) rename tests/{ => example}/luks-lvm.nix (86%) rename tests/{ => example}/luks-on-mdadm.nix (89%) rename tests/{ => example}/lvm-raid.nix (91%) rename tests/{ => example}/lvm-sizes-sort.nix (81%) rename tests/{ => example}/lvm-thin.nix (83%) rename tests/{ => example}/mdadm-raid0.nix (85%) rename tests/{ => example}/mdadm.nix (86%) rename tests/{ => example}/multi-device-no-deps.nix (87%) rename tests/{ => example}/negative-size.nix (86%) rename tests/{ => example}/non-root-zfs.nix (97%) rename tests/{ => example}/simple-efi.nix (82%) rename tests/{standalone.nix => example/stand-alone.nix} (72%) rename tests/{ => example}/swap.nix (97%) rename tests/{ => example}/tmpfs.nix (86%) rename tests/{ => example}/with-lib.nix (84%) rename tests/{ => example}/zfs-over-legacy.nix (88%) rename tests/{ => example}/zfs-with-vdevs.nix (96%) rename tests/{ => example}/zfs.nix (97%) diff --git a/example/gpt-name-with-whitespace.nix b/example/gpt-name-with-special-chars.nix similarity index 100% rename from example/gpt-name-with-whitespace.nix rename to example/gpt-name-with-special-chars.nix diff --git a/flake.nix b/flake.nix index e93bfb11..19150b45 100644 --- a/flake.nix +++ b/flake.nix @@ -86,6 +86,7 @@ ps.isort # Import sorter ps.mypy # Static type checker ps.autoflake # Remove unused imports automatically + ps.pytest # Test runner ])) ]; }; diff --git a/src/tests/test_disk.py b/src/tests/test_disk.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/default.nix b/tests/default.nix index af0c9060..61857c8c 100644 --- a/tests/default.nix +++ b/tests/default.nix @@ -4,17 +4,28 @@ }: let lib = pkgs.lib; + fs = lib.fileset; diskoLib = import ../src/disko_lib { inherit lib makeTest eval-config; }; + incompatibleTests = lib.optionals pkgs.buildPlatform.isRiscV64 [ "zfs" "zfs-over-legacy" "cli" "module" "complex" ]; + allTestFilenames = - builtins.map (lib.removeSuffix ".nix") ( - builtins.filter - (x: lib.hasSuffix ".nix" x && x != "default.nix") - (lib.attrNames (builtins.readDir ./.)) + (fs.toList + (fs.difference + (fs.fileFilter + ({ name, hasExt, ... }: + hasExt "nix" + && name != "default.nix" + && !(lib.elem (lib.removeSuffix ".nix" name) incompatibleTests)) + ./.) + (fs.fileFilter ({ ... }: true) ./disko-install)) ); - incompatibleTests = lib.optionals pkgs.buildPlatform.isRiscV64 [ "zfs" "zfs-over-legacy" "cli" "module" "complex" ]; - allCompatibleFilenames = lib.subtractLists incompatibleTests allTestFilenames; - allTests = lib.genAttrs allCompatibleFilenames (test: import (./. + "/${test}.nix") { inherit diskoLib pkgs; }); + allTests = lib.listToAttrs (lib.map + (test: { + name = lib.removeSuffix ".nix" (builtins.baseNameOf test); + value = import test { inherit diskoLib pkgs; }; + }) + allTestFilenames); in allTests diff --git a/tests/example/README.md b/tests/example/README.md new file mode 100644 index 00000000..0412d6b3 --- /dev/null +++ b/tests/example/README.md @@ -0,0 +1,3 @@ +# Tests for all example files + +You can check out the examples tested here in [../../example](../../example/). \ No newline at end of file diff --git a/tests/bcachefs.nix b/tests/example/bcachefs.nix similarity index 89% rename from tests/bcachefs.nix rename to tests/example/bcachefs.nix index 9a9b6c1a..4739cf3e 100644 --- a/tests/bcachefs.nix +++ b/tests/example/bcachefs.nix @@ -4,7 +4,7 @@ diskoLib.testLib.makeDiskoTest { inherit pkgs; name = "bcachefs"; - disko-config = ../example/bcachefs.nix; + disko-config = ../../example/bcachefs.nix; extraTestScript = '' machine.succeed("mountpoint /"); machine.succeed("lsblk >&2"); diff --git a/tests/boot-raid1.nix b/tests/example/boot-raid1.nix similarity index 89% rename from tests/boot-raid1.nix rename to tests/example/boot-raid1.nix index 3ed42a83..b06801dd 100644 --- a/tests/boot-raid1.nix +++ b/tests/example/boot-raid1.nix @@ -4,7 +4,7 @@ diskoLib.testLib.makeDiskoTest { inherit pkgs; name = "boot-raid1"; - disko-config = ../example/boot-raid1.nix; + disko-config = ../../example/boot-raid1.nix; extraTestScript = '' machine.succeed("test -b /dev/md/boot"); machine.succeed("mountpoint /boot"); diff --git a/tests/btrfs-only-root-subvolume.nix b/tests/example/btrfs-only-root-subvolume.nix similarity index 80% rename from tests/btrfs-only-root-subvolume.nix rename to tests/example/btrfs-only-root-subvolume.nix index 33f0d17d..e360715e 100644 --- a/tests/btrfs-only-root-subvolume.nix +++ b/tests/example/btrfs-only-root-subvolume.nix @@ -4,7 +4,7 @@ diskoLib.testLib.makeDiskoTest { inherit pkgs; name = "btrfs-only-root-subvolume"; - disko-config = ../example/btrfs-only-root-subvolume.nix; + disko-config = ../../example/btrfs-only-root-subvolume.nix; extraTestScript = '' machine.succeed("btrfs subvolume list /"); ''; diff --git a/tests/btrfs-subvolumes.nix b/tests/example/btrfs-subvolumes.nix similarity index 93% rename from tests/btrfs-subvolumes.nix rename to tests/example/btrfs-subvolumes.nix index df01a444..d22d271e 100644 --- a/tests/btrfs-subvolumes.nix +++ b/tests/example/btrfs-subvolumes.nix @@ -4,7 +4,7 @@ diskoLib.testLib.makeDiskoTest { inherit pkgs; name = "btrfs-subvolumes"; - disko-config = ../example/btrfs-subvolumes.nix; + disko-config = ../../example/btrfs-subvolumes.nix; extraTestScript = '' machine.succeed("test ! -e /test"); machine.succeed("test -e /home/user"); diff --git a/tests/complex.nix b/tests/example/complex.nix similarity index 95% rename from tests/complex.nix rename to tests/example/complex.nix index 613d5fe6..94580628 100644 --- a/tests/complex.nix +++ b/tests/example/complex.nix @@ -4,7 +4,7 @@ diskoLib.testLib.makeDiskoTest { inherit pkgs; name = "complex"; - disko-config = ../example/complex.nix; + disko-config = ../../example/complex.nix; extraInstallerConfig.networking.hostId = "8425e349"; extraSystemConfig = { networking.hostId = "8425e349"; diff --git a/tests/f2fs.nix b/tests/example/f2fs.nix similarity index 90% rename from tests/f2fs.nix rename to tests/example/f2fs.nix index 98c79de9..fd901be8 100644 --- a/tests/f2fs.nix +++ b/tests/example/f2fs.nix @@ -4,7 +4,7 @@ diskoLib.testLib.makeDiskoTest { inherit pkgs; name = "f2fs"; - disko-config = ../example/f2fs.nix; + disko-config = ../../example/f2fs.nix; extraTestScript = '' machine.succeed("mountpoint /"); machine.succeed("lsblk --fs >&2"); diff --git a/tests/gpt-bios-compat.nix b/tests/example/gpt-bios-compat.nix similarity index 82% rename from tests/gpt-bios-compat.nix rename to tests/example/gpt-bios-compat.nix index 08a6ec55..45a01634 100644 --- a/tests/gpt-bios-compat.nix +++ b/tests/example/gpt-bios-compat.nix @@ -4,7 +4,7 @@ diskoLib.testLib.makeDiskoTest { inherit pkgs; name = "gpt-bios-compat"; - disko-config = ../example/gpt-bios-compat.nix; + disko-config = ../../example/gpt-bios-compat.nix; extraTestScript = '' machine.succeed("mountpoint /"); ''; diff --git a/tests/gpt-name-with-special-chars.nix b/tests/example/gpt-name-with-special-chars.nix similarity index 85% rename from tests/gpt-name-with-special-chars.nix rename to tests/example/gpt-name-with-special-chars.nix index 73a8a1aa..77c1a070 100644 --- a/tests/gpt-name-with-special-chars.nix +++ b/tests/example/gpt-name-with-special-chars.nix @@ -4,7 +4,7 @@ diskoLib.testLib.makeDiskoTest { inherit pkgs; name = "gpt-name-with-whitespace"; - disko-config = ../example/gpt-name-with-whitespace.nix; + disko-config = ../../example/gpt-name-with-whitespace.nix; extraTestScript = '' machine.succeed("mountpoint /"); machine.succeed("mountpoint '/name with spaces'"); diff --git a/tests/hybrid-mbr.nix b/tests/example/hybrid-mbr.nix similarity index 82% rename from tests/hybrid-mbr.nix rename to tests/example/hybrid-mbr.nix index 3559dd48..b86c536c 100644 --- a/tests/hybrid-mbr.nix +++ b/tests/example/hybrid-mbr.nix @@ -4,7 +4,7 @@ diskoLib.testLib.makeDiskoTest { inherit pkgs; name = "hybrid-mbr"; - disko-config = ../example/hybrid-mbr.nix; + disko-config = ../../example/hybrid-mbr.nix; extraTestScript = '' machine.succeed("mountpoint /"); ''; diff --git a/tests/hybrid-tmpfs-on-root.nix b/tests/example/hybrid-tmpfs-on-root.nix similarity index 83% rename from tests/hybrid-tmpfs-on-root.nix rename to tests/example/hybrid-tmpfs-on-root.nix index 0b3e3011..c6e37903 100644 --- a/tests/hybrid-tmpfs-on-root.nix +++ b/tests/example/hybrid-tmpfs-on-root.nix @@ -4,7 +4,7 @@ diskoLib.testLib.makeDiskoTest { inherit pkgs; name = "hybrid-tmpfs-on-root"; - disko-config = ../example/hybrid-tmpfs-on-root.nix; + disko-config = ../../example/hybrid-tmpfs-on-root.nix; extraTestScript = '' machine.succeed("mountpoint /"); machine.succeed("findmnt / --types tmpfs"); diff --git a/tests/hybrid.nix b/tests/example/hybrid.nix similarity index 83% rename from tests/hybrid.nix rename to tests/example/hybrid.nix index b8c2cdd1..40a98c81 100644 --- a/tests/hybrid.nix +++ b/tests/example/hybrid.nix @@ -4,7 +4,7 @@ diskoLib.testLib.makeDiskoTest { inherit pkgs; name = "hybrid"; - disko-config = ../example/hybrid.nix; + disko-config = ../../example/hybrid.nix; extraTestScript = '' machine.succeed("mountpoint /"); ''; diff --git a/tests/legacy-table-with-whitespace.nix b/tests/example/legacy-table-with-whitespace.nix similarity index 81% rename from tests/legacy-table-with-whitespace.nix rename to tests/example/legacy-table-with-whitespace.nix index f053a602..352cfc4d 100644 --- a/tests/legacy-table-with-whitespace.nix +++ b/tests/example/legacy-table-with-whitespace.nix @@ -4,7 +4,7 @@ diskoLib.testLib.makeDiskoTest { inherit pkgs; name = "legacy-table-with-whitespace"; - disko-config = ../example/legacy-table-with-whitespace.nix; + disko-config = ../../example/legacy-table-with-whitespace.nix; extraTestScript = '' machine.succeed("mountpoint /"); machine.succeed("mountpoint /name_with_spaces"); diff --git a/tests/legacy-table.nix b/tests/example/legacy-table.nix similarity index 82% rename from tests/legacy-table.nix rename to tests/example/legacy-table.nix index cd74116f..d369f93d 100644 --- a/tests/legacy-table.nix +++ b/tests/example/legacy-table.nix @@ -4,7 +4,7 @@ diskoLib.testLib.makeDiskoTest { inherit pkgs; name = "legacy-table"; - disko-config = ../example/legacy-table.nix; + disko-config = ../../example/legacy-table.nix; extraTestScript = '' machine.succeed("mountpoint /"); ''; diff --git a/tests/long-device-name.nix b/tests/example/long-device-name.nix similarity index 81% rename from tests/long-device-name.nix rename to tests/example/long-device-name.nix index d3bea86d..25c3a132 100644 --- a/tests/long-device-name.nix +++ b/tests/example/long-device-name.nix @@ -4,7 +4,7 @@ diskoLib.testLib.makeDiskoTest { inherit pkgs; name = "long-device-name"; - disko-config = ../example/long-device-name.nix; + disko-config = ../../example/long-device-name.nix; extraTestScript = '' machine.succeed("mountpoint /"); ''; diff --git a/tests/luks-btrfs-raid.nix b/tests/example/luks-btrfs-raid.nix similarity index 86% rename from tests/luks-btrfs-raid.nix rename to tests/example/luks-btrfs-raid.nix index 33e035a9..54c2710d 100644 --- a/tests/luks-btrfs-raid.nix +++ b/tests/example/luks-btrfs-raid.nix @@ -5,7 +5,7 @@ diskoLib.testLib.makeDiskoTest { inherit pkgs; name = "luks-btrfs-raid"; - disko-config = ../example/luks-btrfs-raid.nix; + disko-config = ../../example/luks-btrfs-raid.nix; extraTestScript = '' machine.succeed("cryptsetup isLuks /dev/vda2"); machine.succeed("cryptsetup isLuks /dev/vdb1"); diff --git a/tests/luks-btrfs-subvolumes.nix b/tests/example/luks-btrfs-subvolumes.nix similarity index 88% rename from tests/luks-btrfs-subvolumes.nix rename to tests/example/luks-btrfs-subvolumes.nix index ccd4418e..dfb0795f 100644 --- a/tests/luks-btrfs-subvolumes.nix +++ b/tests/example/luks-btrfs-subvolumes.nix @@ -4,7 +4,7 @@ diskoLib.testLib.makeDiskoTest { inherit pkgs; name = "luks-btrfs-subvolumes"; - disko-config = ../example/luks-btrfs-subvolumes.nix; + disko-config = ../../example/luks-btrfs-subvolumes.nix; extraTestScript = '' machine.succeed("cryptsetup isLuks /dev/vda2"); machine.succeed("btrfs subvolume list / | grep -qs 'path nix$'"); diff --git a/tests/luks-interactive-login.nix b/tests/example/luks-interactive-login.nix similarity index 85% rename from tests/luks-interactive-login.nix rename to tests/example/luks-interactive-login.nix index 54d06c61..863bc9b1 100644 --- a/tests/luks-interactive-login.nix +++ b/tests/example/luks-interactive-login.nix @@ -4,7 +4,7 @@ diskoLib.testLib.makeDiskoTest { inherit pkgs; name = "luks-interactive-login"; - disko-config = ../example/luks-interactive-login.nix; + disko-config = ../../example/luks-interactive-login.nix; extraTestScript = '' machine.succeed("cryptsetup isLuks /dev/vda2"); ''; diff --git a/tests/luks-lvm.nix b/tests/example/luks-lvm.nix similarity index 86% rename from tests/luks-lvm.nix rename to tests/example/luks-lvm.nix index 4b950854..809d9c1f 100644 --- a/tests/luks-lvm.nix +++ b/tests/example/luks-lvm.nix @@ -4,7 +4,7 @@ diskoLib.testLib.makeDiskoTest { inherit pkgs; name = "luks-lvm"; - disko-config = ../example/luks-lvm.nix; + disko-config = ../../example/luks-lvm.nix; extraTestScript = '' machine.succeed("cryptsetup isLuks /dev/vda2"); machine.succeed("mountpoint /home"); diff --git a/tests/luks-on-mdadm.nix b/tests/example/luks-on-mdadm.nix similarity index 89% rename from tests/luks-on-mdadm.nix rename to tests/example/luks-on-mdadm.nix index 4a23fbf4..184a0397 100644 --- a/tests/luks-on-mdadm.nix +++ b/tests/example/luks-on-mdadm.nix @@ -4,7 +4,7 @@ diskoLib.testLib.makeDiskoTest { inherit pkgs; name = "luks-on-mdadm"; - disko-config = ../example/luks-on-mdadm.nix; + disko-config = ../../example/luks-on-mdadm.nix; extraTestScript = '' machine.succeed("test -b /dev/md/raid1"); machine.succeed("mountpoint /"); diff --git a/tests/lvm-raid.nix b/tests/example/lvm-raid.nix similarity index 91% rename from tests/lvm-raid.nix rename to tests/example/lvm-raid.nix index 5d3debfc..17854a39 100644 --- a/tests/lvm-raid.nix +++ b/tests/example/lvm-raid.nix @@ -4,7 +4,7 @@ diskoLib.testLib.makeDiskoTest { inherit pkgs; name = "lvm-raid"; - disko-config = ../example/lvm-raid.nix; + disko-config = ../../example/lvm-raid.nix; extraTestScript = '' machine.succeed("mountpoint /home"); ''; diff --git a/tests/lvm-sizes-sort.nix b/tests/example/lvm-sizes-sort.nix similarity index 81% rename from tests/lvm-sizes-sort.nix rename to tests/example/lvm-sizes-sort.nix index 88252cbe..fb9207e8 100644 --- a/tests/lvm-sizes-sort.nix +++ b/tests/example/lvm-sizes-sort.nix @@ -4,7 +4,7 @@ diskoLib.testLib.makeDiskoTest { inherit pkgs; name = "lvm-sizes-sort"; - disko-config = ../example/lvm-sizes-sort.nix; + disko-config = ../../example/lvm-sizes-sort.nix; extraTestScript = '' machine.succeed("mountpoint /home"); ''; diff --git a/tests/lvm-thin.nix b/tests/example/lvm-thin.nix similarity index 83% rename from tests/lvm-thin.nix rename to tests/example/lvm-thin.nix index 149efc22..2584132e 100644 --- a/tests/lvm-thin.nix +++ b/tests/example/lvm-thin.nix @@ -4,7 +4,7 @@ diskoLib.testLib.makeDiskoTest { inherit pkgs; name = "lvm-thin"; - disko-config = ../example/lvm-thin.nix; + disko-config = ../../example/lvm-thin.nix; extraTestScript = '' machine.succeed("mountpoint /home"); ''; diff --git a/tests/mdadm-raid0.nix b/tests/example/mdadm-raid0.nix similarity index 85% rename from tests/mdadm-raid0.nix rename to tests/example/mdadm-raid0.nix index 3356cf6a..69192261 100644 --- a/tests/mdadm-raid0.nix +++ b/tests/example/mdadm-raid0.nix @@ -4,7 +4,7 @@ diskoLib.testLib.makeDiskoTest { inherit pkgs; name = "mdadm-raid0"; - disko-config = ../example/mdadm-raid0.nix; + disko-config = ../../example/mdadm-raid0.nix; extraTestScript = '' machine.succeed("test -b /dev/md/raid0"); machine.succeed("mountpoint /"); diff --git a/tests/mdadm.nix b/tests/example/mdadm.nix similarity index 86% rename from tests/mdadm.nix rename to tests/example/mdadm.nix index bfff5e5f..68627d3e 100644 --- a/tests/mdadm.nix +++ b/tests/example/mdadm.nix @@ -4,7 +4,7 @@ diskoLib.testLib.makeDiskoTest { inherit pkgs; name = "mdadm"; - disko-config = ../example/mdadm.nix; + disko-config = ../../example/mdadm.nix; extraTestScript = '' machine.succeed("test -b /dev/md/raid1"); machine.succeed("mountpoint /"); diff --git a/tests/multi-device-no-deps.nix b/tests/example/multi-device-no-deps.nix similarity index 87% rename from tests/multi-device-no-deps.nix rename to tests/example/multi-device-no-deps.nix index 3138f699..7a7b9cc8 100644 --- a/tests/multi-device-no-deps.nix +++ b/tests/example/multi-device-no-deps.nix @@ -5,7 +5,7 @@ diskoLib.testLib.makeDiskoTest { inherit pkgs; name = "multi-device-no-deps"; - disko-config = ../example/multi-device-no-deps.nix; + disko-config = ../../example/multi-device-no-deps.nix; testBoot = false; extraTestScript = '' machine.succeed("mountpoint /mnt/a"); diff --git a/tests/negative-size.nix b/tests/example/negative-size.nix similarity index 86% rename from tests/negative-size.nix rename to tests/example/negative-size.nix index 1d948e5f..5b5ed84e 100644 --- a/tests/negative-size.nix +++ b/tests/example/negative-size.nix @@ -5,7 +5,7 @@ diskoLib.testLib.makeDiskoTest { inherit pkgs; name = "negative-size"; - disko-config = ../example/negative-size.nix; + disko-config = ../../example/negative-size.nix; testBoot = false; extraTestScript = '' machine.succeed("mountpoint /mnt"); diff --git a/tests/non-root-zfs.nix b/tests/example/non-root-zfs.nix similarity index 97% rename from tests/non-root-zfs.nix rename to tests/example/non-root-zfs.nix index 292cdcc5..caf71f72 100644 --- a/tests/non-root-zfs.nix +++ b/tests/example/non-root-zfs.nix @@ -4,7 +4,7 @@ diskoLib.testLib.makeDiskoTest { inherit pkgs; name = "non-root-zfs"; - disko-config = ../example/non-root-zfs.nix; + disko-config = ../../example/non-root-zfs.nix; extraInstallerConfig.networking.hostId = "8425e349"; extraSystemConfig.networking.hostId = "8425e349"; postDisko = '' diff --git a/tests/simple-efi.nix b/tests/example/simple-efi.nix similarity index 82% rename from tests/simple-efi.nix rename to tests/example/simple-efi.nix index b16416b4..43d8f428 100644 --- a/tests/simple-efi.nix +++ b/tests/example/simple-efi.nix @@ -4,7 +4,7 @@ diskoLib.testLib.makeDiskoTest { inherit pkgs; name = "simple-efi"; - disko-config = ../example/simple-efi.nix; + disko-config = ../../example/simple-efi.nix; extraTestScript = '' machine.succeed("mountpoint /"); ''; diff --git a/tests/standalone.nix b/tests/example/stand-alone.nix similarity index 72% rename from tests/standalone.nix rename to tests/example/stand-alone.nix index 5da5cc0f..e80d444b 100644 --- a/tests/standalone.nix +++ b/tests/example/stand-alone.nix @@ -1,5 +1,5 @@ { pkgs ? import { }, ... }: (pkgs.nixos [ - ../example/stand-alone/configuration.nix + ../../example/stand-alone/configuration.nix { documentation.enable = false; } ]).config.system.build.toplevel diff --git a/tests/swap.nix b/tests/example/swap.nix similarity index 97% rename from tests/swap.nix rename to tests/example/swap.nix index 0ae4b238..f5998c0c 100644 --- a/tests/swap.nix +++ b/tests/example/swap.nix @@ -4,7 +4,7 @@ diskoLib.testLib.makeDiskoTest { inherit pkgs; name = "swap"; - disko-config = ../example/swap.nix; + disko-config = ../../example/swap.nix; extraTestScript = '' import json machine.succeed("mountpoint /"); diff --git a/tests/tmpfs.nix b/tests/example/tmpfs.nix similarity index 86% rename from tests/tmpfs.nix rename to tests/example/tmpfs.nix index 6fff602e..a580c26d 100644 --- a/tests/tmpfs.nix +++ b/tests/example/tmpfs.nix @@ -4,7 +4,7 @@ diskoLib.testLib.makeDiskoTest { inherit pkgs; name = "tmpfs"; - disko-config = ../example/tmpfs.nix; + disko-config = ../../example/tmpfs.nix; extraTestScript = '' machine.succeed("mountpoint /"); machine.succeed("mountpoint /tmp"); diff --git a/tests/with-lib.nix b/tests/example/with-lib.nix similarity index 84% rename from tests/with-lib.nix rename to tests/example/with-lib.nix index c78e4fd8..dd4fd208 100644 --- a/tests/with-lib.nix +++ b/tests/example/with-lib.nix @@ -4,7 +4,7 @@ diskoLib.testLib.makeDiskoTest { inherit pkgs; name = "with-lib"; - disko-config = ../example/with-lib.nix; + disko-config = ../../example/with-lib.nix; extraTestScript = '' machine.succeed("mountpoint /"); ''; diff --git a/tests/zfs-over-legacy.nix b/tests/example/zfs-over-legacy.nix similarity index 88% rename from tests/zfs-over-legacy.nix rename to tests/example/zfs-over-legacy.nix index 2cdebc3a..3ac17a24 100644 --- a/tests/zfs-over-legacy.nix +++ b/tests/example/zfs-over-legacy.nix @@ -6,7 +6,7 @@ diskoLib.testLib.makeDiskoTest { name = "zfs-over-legacy"; extraInstallerConfig.networking.hostId = "8425e349"; extraSystemConfig.networking.hostId = "8425e349"; - disko-config = ../example/zfs-over-legacy.nix; + disko-config = ../../example/zfs-over-legacy.nix; extraTestScript = '' machine.succeed("test -e /zfs_fs"); machine.succeed("mountpoint /zfs_fs"); diff --git a/tests/zfs-with-vdevs.nix b/tests/example/zfs-with-vdevs.nix similarity index 96% rename from tests/zfs-with-vdevs.nix rename to tests/example/zfs-with-vdevs.nix index 7dda11aa..29a3c477 100644 --- a/tests/zfs-with-vdevs.nix +++ b/tests/example/zfs-with-vdevs.nix @@ -4,7 +4,7 @@ diskoLib.testLib.makeDiskoTest { inherit pkgs; name = "zfs-with-vdevs"; - disko-config = ../example/zfs-with-vdevs.nix; + disko-config = ../../example/zfs-with-vdevs.nix; extraInstallerConfig.networking.hostId = "8425e349"; extraSystemConfig = { networking.hostId = "8425e349"; diff --git a/tests/zfs.nix b/tests/example/zfs.nix similarity index 97% rename from tests/zfs.nix rename to tests/example/zfs.nix index 81ab7b89..417a7863 100644 --- a/tests/zfs.nix +++ b/tests/example/zfs.nix @@ -4,7 +4,7 @@ diskoLib.testLib.makeDiskoTest { inherit pkgs; name = "zfs"; - disko-config = ../example/zfs.nix; + disko-config = ../../example/zfs.nix; extraInstallerConfig.networking.hostId = "8425e349"; extraSystemConfig = { networking.hostId = "8425e349"; From fb5e63a3de57cf2fffb78e788a99f2af789adde3 Mon Sep 17 00:00:00 2001 From: Felix Uhl Date: Thu, 31 Oct 2024 17:23:19 +0100 Subject: [PATCH 14/37] dev: Add vscode settings for nixd and cSpell Other people might use other tools, but having a known-good configuration is useful. --- .vscode/settings.json | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 .vscode/settings.json diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..59933280 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,18 @@ +{ + "nix.serverSettings": { + "nixd": { + "formatting": { + "command": [ + "nixpkgs-fmt", + "--" + ] + } + } + }, + "cSpell.enabled": true, + "cSpell.words": [ + "Disko", + "nixos", + "nixpkgs" + ], +} \ No newline at end of file From 0cd949092da817e477058531497fec0946992233 Mon Sep 17 00:00:00 2001 From: Felix Uhl Date: Thu, 31 Oct 2024 17:32:44 +0100 Subject: [PATCH 15/37] disko2: Add test for generate_config --- .vscode/settings.json | 3 + pyproject.toml | 4 + src/tests/test_disk.py | 0 tests/disko_lib/generate-result.json | 110 +++++++ tests/disko_lib/lsblk-output.json | 423 +++++++++++++++++++++++++++ tests/disko_lib/test_types_disk.py | 36 +++ 6 files changed, 576 insertions(+) delete mode 100644 src/tests/test_disk.py create mode 100644 tests/disko_lib/generate-result.json create mode 100644 tests/disko_lib/lsblk-output.json create mode 100644 tests/disko_lib/test_types_disk.py diff --git a/.vscode/settings.json b/.vscode/settings.json index 59933280..7f3f6630 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -15,4 +15,7 @@ "nixos", "nixpkgs" ], + "python.analysis.extraPaths": [ + "./src" + ] } \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 9d968b90..3535b5dd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,10 @@ package-dir = { "" = "src" } [tool.mypy] strict = true +mypy_path = "src" + +[tool.pytest.ini_options] +pythonpath = ["src"] [tool.autoflake] remove_all_unused_imports = true diff --git a/src/tests/test_disk.py b/src/tests/test_disk.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/disko_lib/generate-result.json b/tests/disko_lib/generate-result.json new file mode 100644 index 00000000..0f8fc0b2 --- /dev/null +++ b/tests/disko_lib/generate-result.json @@ -0,0 +1,110 @@ +{ + "disks": { + "MODEL:ST2000LM003 HN-M201RAD,SN:S321J9GFC01497": { + "device": "sda", + "type": "disk", + "content": { + "partitions": { + "PARTUUID:c090741b-68e2-4867-96df-2ec00765f2c0": { + "size": "128M", + "content": { + "type": "filesystem", + "format": "", + "mountpoint": "" + } + }, + "UUID:7CA41E5EA41E1B6A": { + "size": "1.8T", + "content": { + "type": "filesystem", + "format": "ntfs", + "mountpoint": "/mnt/g" + } + } + } + } + }, + "MODEL:SanDisk SD8TB8U256G1001,SN:171887425854": { + "device": "sdb", + "type": "disk", + "content": { + "partitions": { + "UUID:01D60069CEED69C0": { + "size": "499.5M", + "content": { + "type": "filesystem", + "format": "ntfs", + "mountpoint": "" + } + }, + "UUID:01D6006B90875FE0": { + "size": "236.8G", + "content": { + "type": "filesystem", + "format": "ntfs", + "mountpoint": "" + } + }, + "UUID:708B-C192": { + "size": "100M", + "content": { + "type": "filesystem", + "format": "vfat", + "mountpoint": "" + }, + "type": "EF00" + }, + "UUID:90B6FB81B6FB65DE": { + "size": "544M", + "content": { + "type": "filesystem", + "format": "ntfs", + "mountpoint": "" + } + } + } + } + }, + "MODEL:CT2000MX500SSD1,SN:2105E4F0DE85": { + "device": "sdd", + "type": "disk", + "content": { + "partitions": { + "UUID:2740-1628": { + "size": "1000M", + "content": { + "type": "filesystem", + "format": "vfat", + "mountpoint": "/boot" + }, + "type": "EF00" + }, + "UUID:ca548f68-4e51-4364-b366-690ecc27590f": { + "size": "588.4G", + "content": { + "type": "filesystem", + "format": "ext4", + "mountpoint": "/" + } + }, + "UUID:879299db-4147-4fac-9f34-5e8e92073efc": { + "size": "588.4G", + "content": { + "type": "filesystem", + "format": "crypto_LUKS", + "mountpoint": "" + } + }, + "UUID:9A48E8C248E89E6F": { + "size": "685.2G", + "content": { + "type": "filesystem", + "format": "ntfs", + "mountpoint": "/mnt/s" + } + } + } + } + } + } +} \ No newline at end of file diff --git a/tests/disko_lib/lsblk-output.json b/tests/disko_lib/lsblk-output.json new file mode 100644 index 00000000..33e70e7d --- /dev/null +++ b/tests/disko_lib/lsblk-output.json @@ -0,0 +1,423 @@ +{ + "blockdevices": [ + { + "id-link": "wwn-0x50004cf20ecb1679", + "fstype": null, + "fssize": null, + "fsuse%": null, + "kname": "sda", + "label": null, + "model": "ST2000LM003 HN-M201RAD", + "partflags": null, + "partlabel": null, + "partn": null, + "parttype": null, + "parttypename": null, + "partuuid": null, + "path": "/dev/sda", + "phy-sec": 4096, + "pttype": "gpt", + "rev": "2BC10001", + "serial": "S321J9GFC01497", + "size": "1.8T", + "start": null, + "mountpoint": null, + "mountpoints": [ + null + ], + "type": "disk", + "uuid": null, + "children": [ + { + "id-link": "wwn-0x50004cf20ecb1679-part1", + "fstype": null, + "fssize": null, + "fsuse%": null, + "kname": "sda1", + "label": null, + "model": null, + "partflags": null, + "partlabel": "Microsoft reserved partition", + "partn": 1, + "parttype": "e3c9e316-0b5c-4db8-817d-f92df00215ae", + "parttypename": "Microsoft reserved", + "partuuid": "c090741b-68e2-4867-96df-2ec00765f2c0", + "path": "/dev/sda1", + "phy-sec": 4096, + "pttype": "gpt", + "rev": null, + "serial": null, + "size": "128M", + "start": 34, + "mountpoint": null, + "mountpoints": [ + null + ], + "type": "part", + "uuid": null + },{ + "id-link": "wwn-0x50004cf20ecb1679-part2", + "fstype": "ntfs", + "fssize": "1.8T", + "fsuse%": "51%", + "kname": "sda2", + "label": "Groß", + "model": null, + "partflags": null, + "partlabel": "Basic data partition", + "partn": 2, + "parttype": "ebd0a0a2-b9e5-4433-87c0-68b6b72699c7", + "parttypename": "Microsoft basic data", + "partuuid": "425b6415-db2b-4684-b619-994bdd0f9b71", + "path": "/dev/sda2", + "phy-sec": 4096, + "pttype": "gpt", + "rev": null, + "serial": null, + "size": "1.8T", + "start": 264192, + "mountpoint": "/mnt/g", + "mountpoints": [ + "/mnt/g" + ], + "type": "part", + "uuid": "7CA41E5EA41E1B6A" + } + ] + },{ + "id-link": "wwn-0x5001b444a63292c3", + "fstype": null, + "fssize": null, + "fsuse%": null, + "kname": "sdb", + "label": null, + "model": "SanDisk SD8TB8U256G1001", + "partflags": null, + "partlabel": null, + "partn": null, + "parttype": null, + "parttypename": null, + "partuuid": null, + "path": "/dev/sdb", + "phy-sec": 512, + "pttype": "gpt", + "rev": "X4133101", + "serial": "171887425854", + "size": "238.5G", + "start": null, + "mountpoint": null, + "mountpoints": [ + null + ], + "type": "disk", + "uuid": null, + "children": [ + { + "id-link": "wwn-0x5001b444a63292c3-part1", + "fstype": "ntfs", + "fssize": null, + "fsuse%": null, + "kname": "sdb1", + "label": "System Reserved", + "model": null, + "partflags": null, + "partlabel": null, + "partn": 1, + "parttype": "ebd0a0a2-b9e5-4433-87c0-68b6b72699c7", + "parttypename": "Microsoft basic data", + "partuuid": "5cbaf771-8ffa-11eb-952d-fcaa14203853", + "path": "/dev/sdb1", + "phy-sec": 512, + "pttype": "gpt", + "rev": null, + "serial": null, + "size": "499.5M", + "start": 64, + "mountpoint": null, + "mountpoints": [ + null + ], + "type": "part", + "uuid": "01D60069CEED69C0" + },{ + "id-link": "wwn-0x5001b444a63292c3-part2", + "fstype": "ntfs", + "fssize": null, + "fsuse%": null, + "kname": "sdb2", + "label": null, + "model": null, + "partflags": null, + "partlabel": null, + "partn": 2, + "parttype": "ebd0a0a2-b9e5-4433-87c0-68b6b72699c7", + "parttypename": "Microsoft basic data", + "partuuid": "5cbaf772-8ffa-11eb-952d-fcaa14203853", + "path": "/dev/sdb2", + "phy-sec": 512, + "pttype": "gpt", + "rev": null, + "serial": null, + "size": "236.8G", + "start": 1022976, + "mountpoint": null, + "mountpoints": [ + null + ], + "type": "part", + "uuid": "01D6006B90875FE0" + },{ + "id-link": "wwn-0x5001b444a63292c3-part3", + "fstype": "vfat", + "fssize": null, + "fsuse%": null, + "kname": "sdb3", + "label": null, + "model": null, + "partflags": "0x8000000000000000", + "partlabel": null, + "partn": 3, + "parttype": "c12a7328-f81f-11d2-ba4b-00a0c93ec93b", + "parttypename": "EFI System", + "partuuid": "5cbaf773-8ffa-11eb-952d-fcaa14203853", + "path": "/dev/sdb3", + "phy-sec": 512, + "pttype": "gpt", + "rev": null, + "serial": null, + "size": "100M", + "start": 497704960, + "mountpoint": null, + "mountpoints": [ + null + ], + "type": "part", + "uuid": "708B-C192" + },{ + "id-link": "wwn-0x5001b444a63292c3-part4", + "fstype": "ntfs", + "fssize": null, + "fsuse%": null, + "kname": "sdb4", + "label": null, + "model": null, + "partflags": "0x8000000000000001", + "partlabel": null, + "partn": 4, + "parttype": "de94bba4-06d1-4d40-a16a-bfd50179d6ac", + "parttypename": "Windows recovery environment", + "partuuid": "5cbaf774-8ffa-11eb-952d-fcaa14203853", + "path": "/dev/sdb4", + "phy-sec": 512, + "pttype": "gpt", + "rev": null, + "serial": null, + "size": "544M", + "start": 497909760, + "mountpoint": null, + "mountpoints": [ + null + ], + "type": "part", + "uuid": "90B6FB81B6FB65DE" + } + ] + },{ + "id-link": "wwn-0x5002538043584d30", + "fstype": null, + "fssize": null, + "fsuse%": null, + "kname": "sdc", + "label": null, + "model": "SAMSUNG SSD PM830 mSATA 256GB", + "partflags": null, + "partlabel": null, + "partn": null, + "parttype": null, + "parttypename": null, + "partuuid": null, + "path": "/dev/sdc", + "phy-sec": 512, + "pttype": "dos", + "rev": "CXM13D1Q", + "serial": "S0XPNYAD407619", + "size": "238.5G", + "start": null, + "mountpoint": null, + "mountpoints": [ + null + ], + "type": "disk", + "uuid": null + },{ + "id-link": "wwn-0x500a0751e4f0de85", + "fstype": null, + "fssize": null, + "fsuse%": null, + "kname": "sdd", + "label": null, + "model": "CT2000MX500SSD1", + "partflags": null, + "partlabel": null, + "partn": null, + "parttype": null, + "parttypename": null, + "partuuid": null, + "path": "/dev/sdd", + "phy-sec": 4096, + "pttype": "gpt", + "rev": "M3CR033", + "serial": "2105E4F0DE85", + "size": "1.8T", + "start": null, + "mountpoint": null, + "mountpoints": [ + null + ], + "type": "disk", + "uuid": null, + "children": [ + { + "id-link": "wwn-0x500a0751e4f0de85-part1", + "fstype": "vfat", + "fssize": "998M", + "fsuse%": "6%", + "kname": "sdd1", + "label": "boot", + "model": null, + "partflags": null, + "partlabel": "boot", + "partn": 1, + "parttype": "c12a7328-f81f-11d2-ba4b-00a0c93ec93b", + "parttypename": "EFI System", + "partuuid": "7f623bea-5891-49ee-9980-6534716f0f50", + "path": "/dev/sdd1", + "phy-sec": 4096, + "pttype": "gpt", + "rev": null, + "serial": null, + "size": "1000M", + "start": 2048, + "mountpoint": "/boot", + "mountpoints": [ + "/boot" + ], + "type": "part", + "uuid": "2740-1628" + },{ + "id-link": "wwn-0x500a0751e4f0de85-part2", + "fstype": "ext4", + "fssize": "578.1G", + "fsuse%": "10%", + "kname": "sdd2", + "label": "root", + "model": null, + "partflags": null, + "partlabel": "root", + "partn": 2, + "parttype": "0fc63daf-8483-4772-8e79-3d69d8477de4", + "parttypename": "Linux filesystem", + "partuuid": "d562416c-1632-40c6-88ed-5095fb921698", + "path": "/dev/sdd2", + "phy-sec": 4096, + "pttype": "gpt", + "rev": null, + "serial": null, + "size": "588.4G", + "start": 2050048, + "mountpoint": "/", + "mountpoints": [ + "/nix/store", "/" + ], + "type": "part", + "uuid": "ca548f68-4e51-4364-b366-690ecc27590f" + },{ + "id-link": "wwn-0x500a0751e4f0de85-part3", + "fstype": "crypto_LUKS", + "fssize": null, + "fsuse%": null, + "kname": "sdd3", + "label": "home", + "model": null, + "partflags": null, + "partlabel": "home", + "partn": 3, + "parttype": "0fc63daf-8483-4772-8e79-3d69d8477de4", + "parttypename": "Linux filesystem", + "partuuid": "0ca34f17-5c80-4f2d-97b5-2a5f559b55b5", + "path": "/dev/sdd3", + "phy-sec": 4096, + "pttype": "gpt", + "rev": null, + "serial": null, + "size": "588.4G", + "start": 1236023296, + "mountpoint": null, + "mountpoints": [ + null + ], + "type": "part", + "uuid": "879299db-4147-4fac-9f34-5e8e92073efc", + "children": [ + { + "id-link": "dm-name-crypt-home", + "fstype": "btrfs", + "fssize": "588.4G", + "fsuse%": "49%", + "kname": "dm-0", + "label": null, + "model": null, + "partflags": null, + "partlabel": null, + "partn": null, + "parttype": null, + "parttypename": null, + "partuuid": null, + "path": "/dev/mapper/crypt-home", + "phy-sec": 4096, + "pttype": null, + "rev": null, + "serial": null, + "size": "588.4G", + "start": null, + "mountpoint": "/home", + "mountpoints": [ + "/home" + ], + "type": "crypt", + "uuid": "1900f25d-5b93-41f1-a3d4-18fdbd70fe8b" + } + ] + },{ + "id-link": "wwn-0x500a0751e4f0de85-part4", + "fstype": "ntfs", + "fssize": "685.2G", + "fsuse%": "90%", + "kname": "sdd4", + "label": "Schnell", + "model": null, + "partflags": null, + "partlabel": "Basic data partition", + "partn": 4, + "parttype": "ebd0a0a2-b9e5-4433-87c0-68b6b72699c7", + "parttypename": "Microsoft basic data", + "partuuid": "1d55f1cf-5a42-4fa1-a0ba-573fbd0d152c", + "path": "/dev/sdd4", + "phy-sec": 4096, + "pttype": "gpt", + "rev": null, + "serial": null, + "size": "685.2G", + "start": 2470027264, + "mountpoint": "/mnt/s", + "mountpoints": [ + "/mnt/s" + ], + "type": "part", + "uuid": "9A48E8C248E89E6F" + } + ] + } + ] +} + diff --git a/tests/disko_lib/test_types_disk.py b/tests/disko_lib/test_types_disk.py new file mode 100644 index 00000000..2be88af8 --- /dev/null +++ b/tests/disko_lib/test_types_disk.py @@ -0,0 +1,36 @@ +import json +from pathlib import Path, PosixPath +import pytest + +from disko_lib.result import DiskoError, DiskoSuccess +from disko_lib.types import disk +from disko_lib.types import device + +CURRENT_DIR = Path(__file__).parent + + +def test_generate_config_partial_failure_dos_table() -> None: + with open(CURRENT_DIR / "lsblk-output.json") as f: + lsblk_result = device.list_block_devices(f.read()) + + assert isinstance(lsblk_result, DiskoSuccess) + + result = disk.generate_config(lsblk_result.value) + + assert isinstance(result, DiskoError) + + assert result.messages[0].code == "ERR_UNSUPPORTED_PTTYPE" + assert result.messages[0].details == { + "pttype": "dos", + "device": PosixPath("/dev/sdc"), + } + + assert result.messages[1].code == "WARN_GENERATE_PARTIAL_FAILURE" + with open(CURRENT_DIR / "generate-result.json") as f: + assert result.messages[1].details["partial_config"] == json.load(f) + assert result.messages[1].details["failed_devices"] == [PosixPath("/dev/sdc")] + assert result.messages[1].details["successful_devices"] == [ + PosixPath("/dev/sda"), + PosixPath("/dev/sdb"), + PosixPath("/dev/sdd"), + ] From 4db452e40a4056e6894b4f4beb01be95657f4d65 Mon Sep 17 00:00:00 2001 From: Felix Uhl Date: Sun, 3 Nov 2024 22:12:00 +0100 Subject: [PATCH 16/37] disko2: Fix entrypoints An oversight from 389235b63f8488229559a1217351826f1d3dd7b3 --- .vscode/launch.json | 15 ++++++++++++--- disko2 | 5 +---- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index 9a5da229..3b37f7d7 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -8,7 +8,10 @@ "name": "disko2 mount disko_file", "type": "debugpy", "request": "launch", - "program": "${workspaceFolder}/disko2", + "module": "disko", + "env": { + "PYTHONPATH": "${workspaceFolder}/src" + }, "console": "integratedTerminal", "args": [ "mount", @@ -19,7 +22,10 @@ "name": "disko2 mount flake", "type": "debugpy", "request": "launch", - "program": "${workspaceFolder}/disko2", + "module": "disko", + "env": { + "PYTHONPATH": "${workspaceFolder}/src" + }, "console": "integratedTerminal", "args": [ "mount", @@ -31,7 +37,10 @@ "name": "disko2 generate", "type": "debugpy", "request": "launch", - "program": "${workspaceFolder}/disko2", + "module": "disko", + "env": { + "PYTHONPATH": "${workspaceFolder}/src" + }, "console": "integratedTerminal", "args": [ "generate", diff --git a/disko2 b/disko2 index 18b1f6b1..167539fc 100755 --- a/disko2 +++ b/disko2 @@ -4,7 +4,4 @@ # Check src/disko/cli.py for the actual entrypoint set -euo pipefail -( - cd "$(dirname "$(realpath "$0")")"/src - python3 -m disko "$@" -) \ No newline at end of file +PYTHONPATH="$(dirname "$(realpath "$0")")"/src python3 -m disko "$@" \ No newline at end of file From a6944c6bea4e1830cf1f0901d3ba5c35b4305bd2 Mon Sep 17 00:00:00 2001 From: Felix Uhl Date: Sun, 3 Nov 2024 22:13:02 +0100 Subject: [PATCH 17/37] disko2: Make messages type-safe and modular Using untyped dicts was not a safe way to pass arguments to error message rendering. Also, having one giant match statement wouldn't scale well. Using function pointers and kwargs, mypy can now properly check whether all required arguments are passed to DiskoMessage(). As a bonus, it's now easy to use the "Go to definition" LSP command for error codes, which wasn't possible before. --- src/disko/cli.py | 8 +- src/disko_lib/eval_config.py | 17 ++- src/disko_lib/logging.py | 206 +++++------------------------ src/disko_lib/messages/__init__.py | 3 + src/disko_lib/messages/bugs.py | 30 +++++ src/disko_lib/messages/colors.py | 14 ++ src/disko_lib/messages/msgs.py | 117 ++++++++++++++++ src/disko_lib/result.py | 17 ++- src/disko_lib/run_cmd.py | 12 +- src/disko_lib/types/disk.py | 20 +-- tests/disko_lib/test_types_disk.py | 5 +- 11 files changed, 245 insertions(+), 204 deletions(-) create mode 100644 src/disko_lib/messages/__init__.py create mode 100644 src/disko_lib/messages/bugs.py create mode 100644 src/disko_lib/messages/colors.py create mode 100644 src/disko_lib/messages/msgs.py diff --git a/src/disko/cli.py b/src/disko/cli.py index 3adc8d56..36398946 100644 --- a/src/disko/cli.py +++ b/src/disko/cli.py @@ -8,6 +8,8 @@ from disko_lib.ansi import disko_dev_ansi from disko_lib.eval_config import eval_disko_file, eval_flake from disko_lib.logging import LOGGER, debug, info +from disko_lib.messages import err_missing_arguments +from disko_lib.messages.msgs import err_missing_mode, err_too_many_arguments from disko_lib.result import DiskoError, DiskoSuccess, DiskoResult, exit_on_error from disko_lib.types.disk import generate_config from disko_lib.types.device import disko_dev_lsblk @@ -49,13 +51,13 @@ def run_apply( ) -> DiskoResult[dict[str, Any]]: # match would be nicer, but mypy doesn't understand type narrowing in tuples if not disko_file and not flake: - return DiskoError.single_message("ERR_MISSING_ARGUMENTS", {}, "validate args") + return DiskoError.single_message(err_missing_arguments, "validate args") if not disko_file and flake: return eval_flake(flake) if disko_file and not flake: return eval_disko_file(Path(disko_file)) - return DiskoError.single_message("ERR_TOO_MANY_ARGUMENTS", {}, "validate args") + return DiskoError.single_message(err_too_many_arguments, "validate args") def run_generate() -> DiskoResult[dict[str, Any]]: @@ -82,7 +84,7 @@ def run( match args.mode: case None: return DiskoError.single_message( - "ERR_MISSING_MODE", {"valid_modes": ALL_MODES}, "select mode" + err_missing_mode, "select mode", valid_modes=[str(m) for m in ALL_MODES] ) case "generate": return run_generate() diff --git a/src/disko_lib/eval_config.py b/src/disko_lib/eval_config.py index b3a81fe3..dbfaae7e 100644 --- a/src/disko_lib/eval_config.py +++ b/src/disko_lib/eval_config.py @@ -3,6 +3,12 @@ import re from typing import Any +from disko_lib.messages.msgs import ( + err_eval_config_failed, + err_file_not_found, + err_flake_uri_no_attr, +) + from .run_cmd import run from .result import DiskoError, DiskoResult, DiskoSuccess @@ -32,9 +38,10 @@ def eval_config(args: dict[str, str]) -> DiskoResult[dict[str, Any]]: if isinstance(result, DiskoError): return DiskoError.single_message( - "ERR_EVAL_CONFIG_FAILED", - {"args": args, "stderr": result.messages[0].details["stderr"]}, + err_eval_config_failed, "evaluate disko configuration", + args=args, + stderr=result.messages[0].details["stderr"], ) return result @@ -47,9 +54,9 @@ def eval_disko_file(config_file: Path) -> DiskoResult[dict[str, Any]]: if not abs_path.exists(): return DiskoError.single_message( - "ERR_FILE_NOT_FOUND", - {"path": abs_path}, + err_file_not_found, "evaluate disko_file", + path=abs_path, ) return eval_config({"diskoFile": str(abs_path)}) @@ -69,7 +76,7 @@ def eval_flake(flake_uri: str) -> DiskoResult[dict[str, Any]]: if not flake_attr: return DiskoError.single_message( - "ERR_FLAKE_URI_NO_ATTR", {"flake_uri": flake_uri}, "evaluate flake" + err_flake_uri_no_attr, "evaluate flake", flake_uri=flake_uri ) flake_path = Path(flake) diff --git a/src/disko_lib/logging.py b/src/disko_lib/logging.py index 3609f592..caeb46e4 100644 --- a/src/disko_lib/logging.py +++ b/src/disko_lib/logging.py @@ -1,190 +1,55 @@ # Logging functionality and global logging configuration -from dataclasses import dataclass, field -import json +from dataclasses import dataclass import logging import re -from typing import Any, Literal, assert_never +from typing import ( + Any, + Callable, + Generic, + Literal, + ParamSpec, + TypeAlias, +) from .ansi import Colors +from .messages.colors import RESET logging.basicConfig(format="%(message)s", level=logging.INFO) LOGGER = logging.getLogger("disko_logger") -# Color definitions. Note: Sort them alphabetically when adding new ones! -COMMAND = Colors.CYAN_ITALIC # Commands that were run or can be run -EM = Colors.WHITE_ITALIC # Emphasized text -EM_WARN = Colors.YELLOW_ITALIC # Emphasized text that is a warning -FILE = Colors.BLUE # File paths -FLAG = Colors.GREEN # Command line flags (like --version or -f) -INVALID = Colors.RED # Invalid values -PLACEHOLDER = Colors.MAGENTA_ITALIC # Values that need to be replaced -VALUE = Colors.GREEN # Values that are allowed - -RESET = Colors.RESET # Shortcut to reset the color - - @dataclass class ReadableMessage: type: Literal["bug", "error", "warning", "info", "help", "debug"] msg: str -# Codes for every single message that disko can print -# Note: Sort them alphabetically when adding new ones! -MessageCode = Literal[ - "BUG_SUCCESS_WITHOUT_CONTEXT", - "ERR_COMMAND_FAILED", - "ERR_EVAL_CONFIG_FAILED", - "ERR_FILE_NOT_FOUND", - "ERR_FLAKE_URI_NO_ATTR", - "ERR_MISSING_ARGUMENTS", - "ERR_MISSING_MODE", - "ERR_TOO_MANY_ARGUMENTS", - "ERR_UNSUPPORTED_PTTYPE", - "WARN_GENERATE_PARTIAL_FAILURE", -] +P = ParamSpec("P") + +MessageFactory: TypeAlias = Callable[P, ReadableMessage | list[ReadableMessage]] @dataclass -class DiskoMessage: - code: MessageCode - details: dict[str, Any] = field(default_factory=dict) - - -ERR_ARGUMENTS_HELP_TXT = f"Provide either {PLACEHOLDER}disko_file{RESET} as the second argument or \ -{FLAG}--flake{RESET}/{FLAG}-f{RESET} {PLACEHOLDER}flake-uri{RESET}." - - -def bug_help_message(error_code: MessageCode) -> ReadableMessage: - return ReadableMessage( - "help", - f""" - Please report this bug! - First, check if has already been reported at - https://github.com/nix-community/disko/issues?q=is%3Aissue+{error_code} - If not, open a new issue at - https://github.com/nix-community/disko/issues/new?title={error_code} - and include the full logs printed above! - """, - ) - - -def to_readable(message: DiskoMessage) -> list[ReadableMessage]: - match message.code: - case "BUG_SUCCESS_WITHOUT_CONTEXT": - return [ - ReadableMessage( - "bug", - f""" - Success message without context! - Returned value: - {message.details['value']} - """, - ), - bug_help_message(message.code), - ] - case "ERR_COMMAND_FAILED": - return [ - ReadableMessage( - "error", - f""" - Command failed: {COMMAND}{message.details['command']}{COMMAND} - Exit code: {INVALID}{message.details['exit_code']}{RESET} - stderr: {message.details['stderr']} - """, - ) - ] - case "ERR_EVAL_CONFIG_FAILED": - return [ - ReadableMessage( - "error", - f""" - Failed to evaluate disko config with args {INVALID}{message.details['args']}{RESET}! - Stderr from {COMMAND}nix eval{RESET}:\n{message.details['stderr']} - """, - ) - ] - case "ERR_FILE_NOT_FOUND": - return [ - ReadableMessage( - "error", f"File not found: {FILE}{message.details['path']}{RESET}" - ) - ] - case "ERR_FLAKE_URI_NO_ATTR": - return [ - ReadableMessage( - "error", - f"Flake URI {INVALID}{message.details['flake_uri']}{RESET} has no attribute.", - ), - ReadableMessage( - "help", - f"Append an attribute like {VALUE}#{PLACEHOLDER}foo{RESET} to the flake URI.", - ), - ] - case "ERR_MISSING_ARGUMENTS": - return [ - ReadableMessage( - "error", - f"Missing arguments!", - ), - ReadableMessage("help", ERR_ARGUMENTS_HELP_TXT), - ] - case "ERR_TOO_MANY_ARGUMENTS": - return [ - ReadableMessage( - "error", - f"Too many arguments!", - ), - ReadableMessage("help", ERR_ARGUMENTS_HELP_TXT), - ] - case "ERR_MISSING_MODE": - modes_list = "\n".join( - [f" - {VALUE}{m}{RESET}" for m in message.details["valid_modes"]] - ) - return [ - ReadableMessage("error", "Missing mode!"), - ReadableMessage("help", "Allowed modes are:\n" + modes_list), - ] - case "ERR_UNSUPPORTED_PTTYPE": - return [ - ReadableMessage( - "error", - f"Device {FILE}{message.details['device']}{RESET} has unsupported partition type {INVALID}{message.details['pttype']}{RESET}!", - ), - ] - case "WARN_GENERATE_PARTIAL_FAILURE": - return [ - ReadableMessage( - "info", - f""" - Successfully generated config for {EM}some{RESET} devices. - Errors are printed above. The generated partial config is: - {json.dumps(message.details["partial_config"], indent=2)} - """, - ), - ReadableMessage( - "warning", - f""" - Successfully generated config for {EM}some{RESET} devices, {EM_WARN}but not all{RESET}! - Failed devices: {", ".join(f"{INVALID}{d}{RESET}" for d in message.details["failed_devices"])} - Successful devices: {", ".join(f"{VALUE}{d}{RESET}" for d in message.details["successful_devices"])} - """, - ), - ReadableMessage( - "help", - f""" - The {INVALID}ERROR{RESET} messages are printed {EM}above the generated config{RESET}. - Take a look at them and see if you can fix or safely ignore them. - If you can't, but you need a solution now, you can try to use the generated config, - but there is {EM_WARN}no guarantee{RESET} that it will work! - """, - ), - ] - - # We could also remove these two lines, but assert_never emits a better error message - case _ as c: - assert_never(c) +class DiskoMessage(Generic[P]): + factory: MessageFactory[P] + # Can't infer a TypedDict from a ParamSpec yet (mypy 1.10.1, python 3.12.5) + # This is only safe to use because the type of __init__ ensures that the + # keys in details are the same as the keys in the factory kwargs + details: dict[str, Any] + + def __init__(self, factory: MessageFactory[P], **details: P.kwargs) -> None: + self.factory = factory + self.details = details + + def to_readable(self) -> list[ReadableMessage]: + result = self.factory(**self.details) + if isinstance(result, list): + return result + return [result] + + def print(self) -> None: + for msg in self.to_readable(): + render_message(msg) # Dedent lines based on the indent of the first line until a non-indented line is hit. @@ -273,11 +138,6 @@ def render_message(message: ReadableMessage) -> None: log_msg(f"{decor_color}╰───────────{RESET}") # Exactly as long as the heading -def print_msg(code: MessageCode, details: dict[str, Any]) -> None: - for msg in to_readable(DiskoMessage(code, details)): - render_message(msg) - - def debug(msg: str) -> None: # Check debug level immediately to avoid unnecessary formatting if LOGGER.isEnabledFor(logging.DEBUG): diff --git a/src/disko_lib/messages/__init__.py b/src/disko_lib/messages/__init__.py new file mode 100644 index 00000000..f62e4017 --- /dev/null +++ b/src/disko_lib/messages/__init__.py @@ -0,0 +1,3 @@ +# Re-export all messages +from .msgs import * # noqa +from .bugs import * # noqa diff --git a/src/disko_lib/messages/bugs.py b/src/disko_lib/messages/bugs.py new file mode 100644 index 00000000..982810eb --- /dev/null +++ b/src/disko_lib/messages/bugs.py @@ -0,0 +1,30 @@ +from typing import Any +from disko_lib.logging import ReadableMessage + + +def __bug_help_message(error_code: str) -> ReadableMessage: + return ReadableMessage( + "help", + f""" + Please report this bug! + First, check if has already been reported at + https://github.com/nix-community/disko/issues?q=is%3Aissue+{error_code} + If not, open a new issue at + https://github.com/nix-community/disko/issues/new?title={error_code} + and include the full logs printed above! + """, + ) + + +def bug_success_without_context(*, value: Any) -> list[ReadableMessage]: + return [ + ReadableMessage( + "bug", + f""" + Success message without context! + Returned value: + {value} + """, + ), + __bug_help_message("bug_success_without_context"), + ] diff --git a/src/disko_lib/messages/colors.py b/src/disko_lib/messages/colors.py new file mode 100644 index 00000000..e8ea8b74 --- /dev/null +++ b/src/disko_lib/messages/colors.py @@ -0,0 +1,14 @@ +from disko_lib.ansi import Colors + + +# Color definitions. Note: Sort them alphabetically when adding new ones! +COMMAND = Colors.CYAN_ITALIC # Commands that were run or can be run +EM = Colors.WHITE_ITALIC # Emphasized text +EM_WARN = Colors.YELLOW_ITALIC # Emphasized text that is a warning +FILE = Colors.BLUE # File paths +FLAG = Colors.GREEN # Command line flags (like --version or -f) +INVALID = Colors.RED # Invalid values +PLACEHOLDER = Colors.MAGENTA_ITALIC # Values that need to be replaced +VALUE = Colors.GREEN # Values that are allowed + +RESET = Colors.RESET # Shortcut to reset the color diff --git a/src/disko_lib/messages/msgs.py b/src/disko_lib/messages/msgs.py new file mode 100644 index 00000000..1f6c2730 --- /dev/null +++ b/src/disko_lib/messages/msgs.py @@ -0,0 +1,117 @@ +import json +from pathlib import Path +from typing import Any +from disko_lib.logging import ReadableMessage +from .colors import * + +ERR_ARGUMENTS_HELP_TXT = f"Provide either {PLACEHOLDER}disko_file{RESET} as the second argument or \ +{FLAG}--flake{RESET}/{FLAG}-f{RESET} {PLACEHOLDER}flake-uri{RESET}." + + +def err_command_failed(*, command: str, exit_code: int, stderr: str) -> ReadableMessage: + return ReadableMessage( + "error", + f""" + Command failed: {COMMAND}{command}{COMMAND} + Exit code: {INVALID}{exit_code}{RESET} + stderr: {stderr} + """, + ) + + +def err_eval_config_failed(*, args: dict[str, Any], stderr: str) -> ReadableMessage: + + return ReadableMessage( + "error", + f""" + Failed to evaluate disko config with args {INVALID}{args}{RESET}! + Stderr from {COMMAND}nix eval{RESET}:\n{stderr} + """, + ) + + +def err_file_not_found(*, path: Path) -> ReadableMessage: + return ReadableMessage("error", f"File not found: {FILE}{path}{RESET}") + + +def err_flake_uri_no_attr(*, flake_uri: str) -> list[ReadableMessage]: + return [ + ReadableMessage( + "error", + f"Flake URI {INVALID}{flake_uri}{RESET} has no attribute.", + ), + ReadableMessage( + "help", + f"Append an attribute like {VALUE}#{PLACEHOLDER}foo{RESET} to the flake URI.", + ), + ] + + +def err_missing_arguments() -> list[ReadableMessage]: + return [ + ReadableMessage( + "error", + f"Missing arguments!", + ), + ReadableMessage("help", ERR_ARGUMENTS_HELP_TXT), + ] + + +def err_too_many_arguments() -> list[ReadableMessage]: + return [ + ReadableMessage( + "error", + f"Too many arguments!", + ), + ReadableMessage("help", ERR_ARGUMENTS_HELP_TXT), + ] + + +def err_missing_mode(*, valid_modes: list[str]) -> list[ReadableMessage]: + modes_list = "\n".join([f" - {VALUE}{m}{RESET}" for m in valid_modes]) + return [ + ReadableMessage("error", "Missing mode!"), + ReadableMessage("help", "Allowed modes are:\n" + modes_list), + ] + + +def err_unsupported_pttype(*, device: Path, pttype: str) -> ReadableMessage: + return ReadableMessage( + "error", + f"Device {FILE}{device}{RESET} has unsupported partition type {INVALID}{pttype}{RESET}!", + ) + + +def warn_generate_partial_failure( + *, + partial_config: dict[Any, str], + failed_devices: list[str], + successful_devices: list[str], +) -> list[ReadableMessage]: + return [ + ReadableMessage( + "info", + f""" + Successfully generated config for {EM}some{RESET} devices. + Errors are printed above. The generated partial config is: + {json.dumps(partial_config, indent=2)} + """, + ), + ReadableMessage( + "warning", + f""" + Successfully generated config for {EM}some{RESET} devices, {EM_WARN}but not all{RESET}! + Failed devices: {", ".join(f"{INVALID}{d}{RESET}" for d in failed_devices)} + Successful devices: {", ".join(f"{VALUE}{d}{RESET}" for d in successful_devices)} + """, + ), + ReadableMessage( + "help", + f""" + The {INVALID}ERROR{RESET} messages are printed {EM}above the generated config{RESET}. + Take a look at them and see if you can fix or safely ignore them. + If you can't, but you need a solution now, you can try to use the generated config, + but there is {EM_WARN}no guarantee{RESET} that it will work! + """, + ), + ] diff --git a/src/disko_lib/result.py b/src/disko_lib/result.py index 49b4e21b..ea4a9e78 100644 --- a/src/disko_lib/result.py +++ b/src/disko_lib/result.py @@ -1,9 +1,12 @@ from dataclasses import dataclass -from typing import Any, Generic, Literal, TypeVar +from typing import Any, Generic, Literal, ParamSpec, TypeVar -from .logging import DiskoMessage, debug, print_msg, MessageCode +from disko_lib.messages.bugs import bug_success_without_context + +from .logging import DiskoMessage, debug, MessageFactory T = TypeVar("T", covariant=True) +P = ParamSpec("P") @dataclass @@ -15,15 +18,15 @@ class DiskoSuccess(Generic[T]): @dataclass class DiskoError: - messages: list[DiskoMessage] + messages: list[DiskoMessage[Any]] context: str success: Literal[False] = False @classmethod def single_message( - cls, code: MessageCode, details: dict[str, Any], context: str + cls, factory: MessageFactory[P], context: str, *_: P.args, **details: P.kwargs ) -> "DiskoError": - return cls([DiskoMessage(code, details)], context) + return cls([DiskoMessage(factory, **details)], context) DiskoResult = DiskoSuccess[T] | DiskoError @@ -32,13 +35,13 @@ def single_message( def exit_on_error(result: DiskoResult[T]) -> T: if isinstance(result, DiskoSuccess): if result.context is None: - print_msg("BUG_SUCCESS_WITHOUT_CONTEXT", {"value": result.value}) + DiskoMessage(bug_success_without_context, value=result.value).print() else: debug(f"Success in '{result.context}'") debug(f"Returned value: {result.value}") return result.value for message in result.messages: - print_msg(message.code, message.details) + message.print() exit(1) diff --git a/src/disko_lib/run_cmd.py b/src/disko_lib/run_cmd.py index 308a16c4..c9eb90f5 100644 --- a/src/disko_lib/run_cmd.py +++ b/src/disko_lib/run_cmd.py @@ -1,5 +1,7 @@ import subprocess +from disko_lib.messages.msgs import err_command_failed + from .logging import debug from .result import DiskoError, DiskoResult, DiskoSuccess @@ -23,11 +25,9 @@ def run(args: list[str]) -> DiskoResult[str]: return DiskoSuccess(result.stdout, "run command") return DiskoError.single_message( - "ERR_COMMAND_FAILED", - { - "command": command, - "stderr": result.stderr, - "exit_code": result.returncode, - }, + err_command_failed, "run command", + command=command, + stderr=result.stderr, + exit_code=result.returncode, ) diff --git a/src/disko_lib/types/disk.py b/src/disko_lib/types/disk.py index 2b74cef8..ea604f14 100644 --- a/src/disko_lib/types/disk.py +++ b/src/disko_lib/types/disk.py @@ -1,5 +1,10 @@ from typing import Any +from disko_lib.messages.msgs import ( + err_unsupported_pttype, + warn_generate_partial_failure, +) + from ..logging import DiskoMessage, debug from ..result import DiskoError, DiskoResult, DiskoSuccess from ..types.device import BlockDevice, list_block_devices @@ -12,9 +17,10 @@ def _generate_content(device: BlockDevice) -> DiskoResult[dict[str, Any]]: return gpt.generate_config(device) case _: return DiskoError.single_message( - "ERR_UNSUPPORTED_PTTYPE", - {"device": device.path, "pttype": device.pttype}, + err_unsupported_pttype, "generate disk content", + device=device.path, + pttype=device.pttype, ) @@ -63,12 +69,10 @@ def generate_config(devices: list[BlockDevice] = []) -> DiskoResult[dict[str, An error_messages + [ DiskoMessage( - "WARN_GENERATE_PARTIAL_FAILURE", - { - "partial_config": {"disks": disks}, - "failed_devices": failed_devices, - "successful_devices": successful_devices, - }, + warn_generate_partial_failure, + partial_config={"disks": disks}, + failed_devices=failed_devices, + successful_devices=successful_devices, ) ], "generate disk config", diff --git a/tests/disko_lib/test_types_disk.py b/tests/disko_lib/test_types_disk.py index 2be88af8..dc29a3fe 100644 --- a/tests/disko_lib/test_types_disk.py +++ b/tests/disko_lib/test_types_disk.py @@ -2,6 +2,7 @@ from pathlib import Path, PosixPath import pytest +from disko_lib.messages import err_unsupported_pttype, warn_generate_partial_failure from disko_lib.result import DiskoError, DiskoSuccess from disko_lib.types import disk from disko_lib.types import device @@ -19,13 +20,13 @@ def test_generate_config_partial_failure_dos_table() -> None: assert isinstance(result, DiskoError) - assert result.messages[0].code == "ERR_UNSUPPORTED_PTTYPE" + assert result.messages[0].factory == err_unsupported_pttype assert result.messages[0].details == { "pttype": "dos", "device": PosixPath("/dev/sdc"), } - assert result.messages[1].code == "WARN_GENERATE_PARTIAL_FAILURE" + assert result.messages[1].factory == warn_generate_partial_failure with open(CURRENT_DIR / "generate-result.json") as f: assert result.messages[1].details["partial_config"] == json.load(f) assert result.messages[1].details["failed_devices"] == [PosixPath("/dev/sdc")] From c5da562d0d9a6945f0faa20dda601c42331d4e66 Mon Sep 17 00:00:00 2001 From: Felix Uhl Date: Sun, 3 Nov 2024 22:53:12 +0100 Subject: [PATCH 18/37] dev: Move colors to constants --- src/disko_lib/logging.py | 76 +++++++++++++++++++++------------------- 1 file changed, 40 insertions(+), 36 deletions(-) diff --git a/src/disko_lib/logging.py b/src/disko_lib/logging.py index caeb46e4..9f3e8ef7 100644 --- a/src/disko_lib/logging.py +++ b/src/disko_lib/logging.py @@ -17,10 +17,45 @@ logging.basicConfig(format="%(message)s", level=logging.INFO) LOGGER = logging.getLogger("disko_logger") +MessageTypes = Literal["bug", "error", "warning", "info", "help", "debug"] + +BG_COLOR_MAP = { + "bug": Colors.BG_RED, + "error": Colors.BG_RED, + "warning": Colors.BG_YELLOW, + "info": Colors.BG_GREEN, + "help": Colors.BG_LIGHT_MAGENTA, + "debug": Colors.BG_LIGHT_CYAN, +} +DECOR_COLOR_MAP = { + "bug": Colors.RED, + "error": Colors.RED, + "warning": Colors.YELLOW, + "info": Colors.GREEN, + "help": Colors.LIGHT_MAGENTA, + "debug": Colors.LIGHT_CYAN, +} +TITLE_RAW_MAP = { + "bug": "BUG", + "error": "ERROR", + "warning": "WARNING", + "info": "INFO", + "help": "HELP", + "debug": "DEBUG", +} +LOG_MSG_FUNCTION_MAP = { + "bug": LOGGER.error, + "error": LOGGER.error, + "warning": LOGGER.warning, + "info": LOGGER.info, + "help": LOGGER.info, + "debug": LOGGER.debug, +} + @dataclass class ReadableMessage: - type: Literal["bug", "error", "warning", "info", "help", "debug"] + type: MessageTypes msg: str @@ -83,41 +118,10 @@ def dedent_start_lines(lines: list[str]) -> list[str]: def render_message(message: ReadableMessage) -> None: - bg_color = { - "bug": Colors.BG_RED, - "error": Colors.BG_RED, - "warning": Colors.BG_YELLOW, - "info": Colors.BG_GREEN, - "help": Colors.BG_LIGHT_MAGENTA, - "debug": Colors.BG_LIGHT_CYAN, - }[message.type] - - decor_color = { - "bug": Colors.RED, - "error": Colors.RED, - "warning": Colors.YELLOW, - "info": Colors.GREEN, - "help": Colors.LIGHT_MAGENTA, - "debug": Colors.LIGHT_CYAN, - }[message.type] - - title_raw = { - "bug": "BUG", - "error": "ERROR", - "warning": "WARNING", - "info": "INFO", - "help": "HELP", - "debug": "DEBUG", - }[message.type] - - log_msg = { - "bug": LOGGER.error, - "error": LOGGER.error, - "warning": LOGGER.warning, - "info": LOGGER.info, - "help": LOGGER.info, - "debug": LOGGER.debug, - }[message.type] + bg_color = BG_COLOR_MAP[message.type] + decor_color = DECOR_COLOR_MAP[message.type] + title_raw = TITLE_RAW_MAP[message.type] + log_msg = LOG_MSG_FUNCTION_MAP[message.type] msg_lines = message.msg.strip("\n").rstrip(" \n").splitlines() From fa44467f82fa97d7e87ee65ed002312905fcc82d Mon Sep 17 00:00:00 2001 From: Felix Uhl Date: Sun, 3 Nov 2024 22:53:43 +0100 Subject: [PATCH 19/37] disko2: Refactor dev mode, add disko dev eval --- src/disko/cli.py | 45 ++++++++++++------------------ src/disko/mode_dev.py | 52 +++++++++++++++++++++++++++++++++++ src/disko_lib/ansi.py | 10 ------- src/disko_lib/eval_config.py | 27 ++++++++++++++---- src/disko_lib/types/device.py | 13 ++------- 5 files changed, 92 insertions(+), 55 deletions(-) create mode 100644 src/disko/mode_dev.py diff --git a/src/disko/cli.py b/src/disko/cli.py index 36398946..e7db6a00 100644 --- a/src/disko/cli.py +++ b/src/disko/cli.py @@ -2,17 +2,14 @@ import argparse import json -from pathlib import Path -from typing import Any, Literal, assert_never +from typing import Any, Literal -from disko_lib.ansi import disko_dev_ansi -from disko_lib.eval_config import eval_disko_file, eval_flake +from disko.mode_dev import run_dev +from disko_lib.eval_config import eval_config from disko_lib.logging import LOGGER, debug, info -from disko_lib.messages import err_missing_arguments -from disko_lib.messages.msgs import err_missing_mode, err_too_many_arguments -from disko_lib.result import DiskoError, DiskoSuccess, DiskoResult, exit_on_error +from disko_lib.messages.msgs import err_missing_mode +from disko_lib.result import DiskoError, DiskoResult, exit_on_error from disko_lib.types.disk import generate_config -from disko_lib.types.device import disko_dev_lsblk Mode = Literal[ "destroy", @@ -49,31 +46,13 @@ def run_apply( *, mode: str, disko_file: str | None, flake: str | None, **_kwargs: dict[str, Any] ) -> DiskoResult[dict[str, Any]]: - # match would be nicer, but mypy doesn't understand type narrowing in tuples - if not disko_file and not flake: - return DiskoError.single_message(err_missing_arguments, "validate args") - if not disko_file and flake: - return eval_flake(flake) - if disko_file and not flake: - return eval_disko_file(Path(disko_file)) - - return DiskoError.single_message(err_too_many_arguments, "validate args") + return eval_config(disko_file=disko_file, flake=flake) def run_generate() -> DiskoResult[dict[str, Any]]: return generate_config() -def run_dev(args: argparse.Namespace) -> DiskoResult[None]: - match args.dev_command: - case "lsblk": - return disko_dev_lsblk() - case "ansi": - return DiskoSuccess(None, disko_dev_ansi()) - case _: - assert_never(args.dev_command) - - def run( args: argparse.Namespace, ) -> DiskoResult[None | dict[str, Any]]: @@ -115,6 +94,9 @@ def create_apply_parser(mode: Mode) -> argparse.ArgumentParser: mode, help=MODE_DESCRIPTION[mode], ) + return parser + + def add_common_apply_args(parser: argparse.ArgumentParser) -> None: parser.add_argument( "disko_file", nargs="?", @@ -126,10 +108,11 @@ def create_apply_parser(mode: Mode) -> argparse.ArgumentParser: "-f", help="Flake to fetch the disko configuration from", ) - return parser # Commands to apply an existing configuration apply_parsers = [create_apply_parser(mode) for mode in APPLY_MODES] + for parser in apply_parsers: + add_common_apply_args(parser) # Other commands generate_parser = mode_parsers.add_parser( @@ -144,6 +127,12 @@ def create_apply_parser(mode: Mode) -> argparse.ArgumentParser: ).add_subparsers(dest="dev_command") dev_parsers.add_parser("lsblk", help="List block devices the way disko sees them") dev_parsers.add_parser("ansi", help="Print defined ansi color codes") + + dev_eval_parser = dev_parsers.add_parser( + "eval", help="Evaluate a disko configuration and print the result as JSON" + ) + add_common_apply_args(dev_eval_parser) + return root_parser.parse_args() diff --git a/src/disko/mode_dev.py b/src/disko/mode_dev.py new file mode 100644 index 00000000..9b541dcc --- /dev/null +++ b/src/disko/mode_dev.py @@ -0,0 +1,52 @@ +import argparse +import json +from logging import info +from typing import Any, assert_never + +from disko_lib.ansi import Colors +from disko_lib.eval_config import eval_config +from disko_lib.result import DiskoError, DiskoSuccess, DiskoResult +from disko_lib.types.device import run_lsblk + + +def run_dev_lsblk() -> DiskoResult[None]: + output = run_lsblk() + if isinstance(output, DiskoError): + return output + + print(output.value) + return DiskoSuccess(None, "run disko dev lsblk") + + +def run_dev_ansi() -> DiskoResult[None]: + import inspect + + for name, value in inspect.getmembers(Colors): + if value != "_" and not name.startswith("_") and name != "RESET": + print("{:>30} {}".format(name, value + name + Colors.RESET)) + + return DiskoSuccess(None, "run disko dev ansi") + + +def run_dev_eval( + *, disko_file: str | None, flake: str | None, **_: Any +) -> DiskoResult[None]: + result = eval_config(disko_file=disko_file, flake=flake) + + if isinstance(result, DiskoError): + return result + + print(json.dumps(result.value, indent=2)) + return DiskoSuccess(None, "run disko dev eval") + + +def run_dev(args: argparse.Namespace) -> DiskoResult[None]: + match args.dev_command: + case "lsblk": + return run_dev_lsblk() + case "ansi": + return run_dev_ansi() + case "eval": + return run_dev_eval(**vars(args)) + case _: + assert_never(args.dev_command) diff --git a/src/disko_lib/ansi.py b/src/disko_lib/ansi.py index e031f107..f39448da 100644 --- a/src/disko_lib/ansi.py +++ b/src/disko_lib/ansi.py @@ -173,13 +173,3 @@ class Colors: kernel32 = __import__("ctypes").windll.kernel32 kernel32.SetConsoleMode(kernel32.GetStdHandle(-11), 7) del kernel32 - - -def disko_dev_ansi() -> str: - import inspect - - for name, value in inspect.getmembers(Colors): - if value != "_" and not name.startswith("_") and name != "RESET": - print("{:>30} {}".format(name, value + name + Colors.RESET)) - - return "run disko dev ansi" diff --git a/src/disko_lib/eval_config.py b/src/disko_lib/eval_config.py index dbfaae7e..1251f3c6 100644 --- a/src/disko_lib/eval_config.py +++ b/src/disko_lib/eval_config.py @@ -7,6 +7,8 @@ err_eval_config_failed, err_file_not_found, err_flake_uri_no_attr, + err_missing_arguments, + err_too_many_arguments, ) from .run_cmd import run @@ -28,7 +30,7 @@ ), f"Can't find `eval-config.nix`, expected it next to {__file__}" -def eval_config(args: dict[str, str]) -> DiskoResult[dict[str, Any]]: +def _eval_config(args: dict[str, str]) -> DiskoResult[dict[str, Any]]: args_as_json = json.dumps(args) result = run( @@ -43,13 +45,12 @@ def eval_config(args: dict[str, str]) -> DiskoResult[dict[str, Any]]: args=args, stderr=result.messages[0].details["stderr"], ) - return result # We trust the output of `nix eval` to be valid JSON return DiskoSuccess(json.loads(result.value), "evaluate disko config") -def eval_disko_file(config_file: Path) -> DiskoResult[dict[str, Any]]: +def _eval_disko_file(config_file: Path) -> DiskoResult[dict[str, Any]]: abs_path = config_file.absolute() if not abs_path.exists(): @@ -59,10 +60,10 @@ def eval_disko_file(config_file: Path) -> DiskoResult[dict[str, Any]]: path=abs_path, ) - return eval_config({"diskoFile": str(abs_path)}) + return _eval_config({"diskoFile": str(abs_path)}) -def eval_flake(flake_uri: str) -> DiskoResult[dict[str, Any]]: +def _eval_flake(flake_uri: str) -> DiskoResult[dict[str, Any]]: # arg parser should not allow empty strings assert len(flake_uri) > 0 @@ -83,4 +84,18 @@ def eval_flake(flake_uri: str) -> DiskoResult[dict[str, Any]]: if flake_path.exists(): flake = str(flake_path.absolute()) - return eval_config({"flake": flake, "flakeAttr": flake_attr}) + return _eval_config({"flake": flake, "flakeAttr": flake_attr}) + + +def eval_config( + *, disko_file: str | None, flake: str | None +) -> DiskoResult[dict[str, Any]]: + # match would be nicer, but mypy doesn't understand type narrowing in tuples + if not disko_file and not flake: + return DiskoError.single_message(err_missing_arguments, "validate args") + if not disko_file and flake: + return _eval_flake(flake) + if disko_file and not flake: + return _eval_disko_file(Path(disko_file)) + + return DiskoError.single_message(err_too_many_arguments, "validate args") diff --git a/src/disko_lib/types/device.py b/src/disko_lib/types/device.py index db3a15ed..d36b819c 100644 --- a/src/disko_lib/types/device.py +++ b/src/disko_lib/types/device.py @@ -110,13 +110,13 @@ def from_json_dict(cls, json_dict: dict[str, Any]) -> "BlockDevice": ) -def _run_lsblk() -> DiskoResult[str]: +def run_lsblk() -> DiskoResult[str]: return run(["lsblk", "--json", "--tree", "--output", ",".join(LSBLK_OUTPUT_FIELDS)]) def list_block_devices(lsblk_output: str = "") -> DiskoResult[list[BlockDevice]]: if not lsblk_output: - lsblk_result = _run_lsblk() + lsblk_result = run_lsblk() if isinstance(lsblk_result, DiskoError): return lsblk_result @@ -129,12 +129,3 @@ def list_block_devices(lsblk_output: str = "") -> DiskoResult[list[BlockDevice]] blockdevices = [BlockDevice.from_json_dict(dev) for dev in lsblk_json] return DiskoSuccess(blockdevices, "list block devices") - - -def disko_dev_lsblk() -> DiskoResult[None]: - output = _run_lsblk() - if isinstance(output, DiskoError): - return output - - print(output.value) - return DiskoSuccess(None, "run disko dev lsblk") From 0d54c7ad3f2f9c2f6519223ae60fcbee7d5e24c8 Mon Sep 17 00:00:00 2001 From: Felix Uhl Date: Mon, 4 Nov 2024 21:11:39 +0100 Subject: [PATCH 20/37] disko2: Make generate write output to nix file --- .gitignore | 2 ++ src/disko/cli.py | 5 +-- src/disko/mode_generate.py | 69 ++++++++++++++++++++++++++++++++++++++ src/disko_lib/result.py | 8 +++++ 4 files changed, 80 insertions(+), 4 deletions(-) create mode 100644 src/disko/mode_generate.py diff --git a/.gitignore b/.gitignore index 363b3019..e1f7c788 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ result +disko-config.nix + .direnv __pycache__/ diff --git a/src/disko/cli.py b/src/disko/cli.py index e7db6a00..011eaefc 100644 --- a/src/disko/cli.py +++ b/src/disko/cli.py @@ -5,6 +5,7 @@ from typing import Any, Literal from disko.mode_dev import run_dev +from disko.mode_generate import run_generate from disko_lib.eval_config import eval_config from disko_lib.logging import LOGGER, debug, info from disko_lib.messages.msgs import err_missing_mode @@ -49,10 +50,6 @@ def run_apply( return eval_config(disko_file=disko_file, flake=flake) -def run_generate() -> DiskoResult[dict[str, Any]]: - return generate_config() - - def run( args: argparse.Namespace, ) -> DiskoResult[None | dict[str, Any]]: diff --git a/src/disko/mode_generate.py b/src/disko/mode_generate.py new file mode 100644 index 00000000..30ef4852 --- /dev/null +++ b/src/disko/mode_generate.py @@ -0,0 +1,69 @@ +import json +import re +from typing import Any +from disko_lib.logging import info +from disko_lib.messages.msgs import warn_generate_partial_failure +from disko_lib.result import DiskoResult, DiskoError +from disko_lib.types.disk import generate_config +from disko_lib.run_cmd import run + +DEFAULT_CONFIG_FILE = "disko-config.nix" + +HEADER_COMMENT = """ +# This file was generated by disko generate +# Some disk and partition names were auto-generated from device attributes +# to be unique, but might not be very descriptive. Feel free to change them +# to something more meaningful. + +""" + +PARTIAL_FAILURE_COMMENT = """ +############################################################################### +# WARNING: This file is incomplete! Some devices failed to generate a config. # +# Check the logs of the 'disko generate' command for more information. # +############################################################################### +""" + + +def run_generate() -> DiskoResult[dict[str, Any]]: + generated_config_result = generate_config() + generated_config = None + + if isinstance(generated_config_result, DiskoError): + partial_failure_warning = generated_config_result.find_message( + warn_generate_partial_failure + ) + if not partial_failure_warning: + return generated_config_result + + generated_config = partial_failure_warning.details["partial_config"] + else: + generated_config = generated_config_result.value + + config_as_nix = run( + [ + "nix", + "eval", + "--expr", + "{ disko.devices = (" + f"builtins.fromJSON(''{json.dumps(generated_config)}'')" + "); }", + ] + ) + if isinstance(config_as_nix, DiskoError): + return config_as_nix + + # Contract the main attribute path to a single line to match all the examples + nix_code = re.sub(r"^\{ disko = \{ devices", "{ disko.devices", config_as_nix.value) + nix_code = re.sub(r"\}; \}$", "}", nix_code) + + with open(DEFAULT_CONFIG_FILE, "w") as f: + f.write(HEADER_COMMENT) + if isinstance(generated_config_result, DiskoError): + f.write(PARTIAL_FAILURE_COMMENT) + f.write(nix_code) + info(f"Wrote generated config to {DEFAULT_CONFIG_FILE}") + + run(["nixfmt", DEFAULT_CONFIG_FILE]) + + return generated_config_result diff --git a/src/disko_lib/result.py b/src/disko_lib/result.py index ea4a9e78..d647e9d9 100644 --- a/src/disko_lib/result.py +++ b/src/disko_lib/result.py @@ -28,6 +28,14 @@ def single_message( ) -> "DiskoError": return cls([DiskoMessage(factory, **details)], context) + def find_message( + self, message_factory: MessageFactory[P] + ) -> None | DiskoMessage[P]: + for message in self.messages: + if message.factory == message_factory: + return message + return None + DiskoResult = DiskoSuccess[T] | DiskoError From da8a99739791ebdb6e6628e345a7e9808e4210ff Mon Sep 17 00:00:00 2001 From: Felix Uhl Date: Thu, 7 Nov 2024 21:15:27 +0100 Subject: [PATCH 21/37] dev: Switch to ruff for python linting & formatting It combines multiple tools and is much faster. --- .vscode/extensions.json | 5 ++--- flake.nix | 13 ++++++++----- src/disko/cli.py | 2 +- src/disko/mode_dev.py | 1 - src/disko_lib/messages/msgs.py | 9 ++++----- tests/disko_lib/test_types_disk.py | 1 - 6 files changed, 15 insertions(+), 16 deletions(-) diff --git a/.vscode/extensions.json b/.vscode/extensions.json index e36dd5d7..c568ac05 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -4,8 +4,7 @@ "jnoortheen.nix-ide", "ms-python.python", "ms-python.debugpy", - "ms-python.black-formatter", - "ms-python.isort", - "ms-python.mypy-type-checker" + "ms-python.mypy-type-checker", + "charliermarsh.ruff" ] } \ No newline at end of file diff --git a/flake.nix b/flake.nix index 19150b45..4b795b4c 100644 --- a/flake.nix +++ b/flake.nix @@ -80,15 +80,13 @@ { default = pkgs.mkShell { name = "disko-dev"; - packages = with pkgs; [ + packages = (with pkgs; [ + ruff # Formatter and linter (python3.withPackages (ps: [ - ps.black # Code formatter - ps.isort # Import sorter ps.mypy # Static type checker - ps.autoflake # Remove unused imports automatically ps.pytest # Test runner ])) - ]; + ]); }; }); @@ -110,6 +108,7 @@ nixpkgs-fmt deno deadnix + ruff ]; text = '' showUsage() { @@ -154,12 +153,16 @@ nixpkgs-fmt -- "''${files[@]}" deno fmt -- "''${files[@]}" deadnix --edit -- "''${files[@]}" + ruff check --fix + ruff format else set -o xtrace nixpkgs-fmt --check -- "''${files[@]}" deno fmt --check -- "''${files[@]}" deadnix -- "''${files[@]}" + ruff check + ruff format --check fi } diff --git a/src/disko/cli.py b/src/disko/cli.py index 011eaefc..1ec6791f 100644 --- a/src/disko/cli.py +++ b/src/disko/cli.py @@ -112,7 +112,7 @@ def add_common_apply_args(parser: argparse.ArgumentParser) -> None: add_common_apply_args(parser) # Other commands - generate_parser = mode_parsers.add_parser( + _generate_parser = mode_parsers.add_parser( "generate", help=MODE_DESCRIPTION["generate"], ) diff --git a/src/disko/mode_dev.py b/src/disko/mode_dev.py index 9b541dcc..869ac683 100644 --- a/src/disko/mode_dev.py +++ b/src/disko/mode_dev.py @@ -1,6 +1,5 @@ import argparse import json -from logging import info from typing import Any, assert_never from disko_lib.ansi import Colors diff --git a/src/disko_lib/messages/msgs.py b/src/disko_lib/messages/msgs.py index 1f6c2730..4bebac03 100644 --- a/src/disko_lib/messages/msgs.py +++ b/src/disko_lib/messages/msgs.py @@ -2,7 +2,7 @@ from pathlib import Path from typing import Any from disko_lib.logging import ReadableMessage -from .colors import * +from .colors import PLACEHOLDER, RESET, FLAG, COMMAND, INVALID, FILE, VALUE, EM, EM_WARN ERR_ARGUMENTS_HELP_TXT = f"Provide either {PLACEHOLDER}disko_file{RESET} as the second argument or \ {FLAG}--flake{RESET}/{FLAG}-f{RESET} {PLACEHOLDER}flake-uri{RESET}." @@ -12,7 +12,7 @@ def err_command_failed(*, command: str, exit_code: int, stderr: str) -> Readable return ReadableMessage( "error", f""" - Command failed: {COMMAND}{command}{COMMAND} + Command failed: {COMMAND}{command}{RESET} Exit code: {INVALID}{exit_code}{RESET} stderr: {stderr} """, @@ -20,7 +20,6 @@ def err_command_failed(*, command: str, exit_code: int, stderr: str) -> Readable def err_eval_config_failed(*, args: dict[str, Any], stderr: str) -> ReadableMessage: - return ReadableMessage( "error", f""" @@ -51,7 +50,7 @@ def err_missing_arguments() -> list[ReadableMessage]: return [ ReadableMessage( "error", - f"Missing arguments!", + "Missing arguments!", ), ReadableMessage("help", ERR_ARGUMENTS_HELP_TXT), ] @@ -61,7 +60,7 @@ def err_too_many_arguments() -> list[ReadableMessage]: return [ ReadableMessage( "error", - f"Too many arguments!", + "Too many arguments!", ), ReadableMessage("help", ERR_ARGUMENTS_HELP_TXT), ] diff --git a/tests/disko_lib/test_types_disk.py b/tests/disko_lib/test_types_disk.py index dc29a3fe..9afcc79e 100644 --- a/tests/disko_lib/test_types_disk.py +++ b/tests/disko_lib/test_types_disk.py @@ -1,6 +1,5 @@ import json from pathlib import Path, PosixPath -import pytest from disko_lib.messages import err_unsupported_pttype, warn_generate_partial_failure from disko_lib.result import DiskoError, DiskoSuccess From 963b49d314743f719b9c15f62aa02644fe12c2a3 Mon Sep 17 00:00:00 2001 From: Felix Uhl Date: Fri, 8 Nov 2024 19:56:12 +0100 Subject: [PATCH 22/37] lib: Add dict_diff This allows to create plans only based on the things that changed in the configuration. --- src/disko_lib/dict_diff.py | 23 +++++ tests/disko_lib/test_dict_diff.py | 138 ++++++++++++++++++++++++++++++ 2 files changed, 161 insertions(+) create mode 100644 src/disko_lib/dict_diff.py create mode 100644 tests/disko_lib/test_dict_diff.py diff --git a/src/disko_lib/dict_diff.py b/src/disko_lib/dict_diff.py new file mode 100644 index 00000000..9f031457 --- /dev/null +++ b/src/disko_lib/dict_diff.py @@ -0,0 +1,23 @@ +from typing import Any + + +# Returns a dict that only contains the keys from `right` that have different values in `left` +def dict_diff(left: dict[str, Any], right: dict[str, Any]) -> dict[str, Any]: + new_dict: dict[str, Any] = {} + + for k, right_val in right.items(): + left_val = left.get(k) + if left_val == right_val: + continue + + if isinstance(right_val, dict): + new_dict[k] = dict_diff(left.get(k, {}), right[k]) + else: + new_dict[k] = right[k] + + for k, left_val in left.items(): + if k not in right: + # Do not recurse, even if left_val is a dict! + new_dict[k] = None + + return new_dict diff --git a/tests/disko_lib/test_dict_diff.py b/tests/disko_lib/test_dict_diff.py new file mode 100644 index 00000000..24e94c96 --- /dev/null +++ b/tests/disko_lib/test_dict_diff.py @@ -0,0 +1,138 @@ +from disko_lib.dict_diff import dict_diff + + +def test_dict_diff_basic() -> None: + left = { + "a": 1, + "b": 2, + "c": 3, + "d": 4, + } + right = { + "a": 1, + "b": 3, + "c": 4, + "e": 5, + } + assert dict_diff(left, right) == { + "b": 3, + "c": 4, + "d": None, + "e": 5, + } + assert dict_diff(right, left) == { + "b": 2, + "c": 3, + "d": 4, + "e": None, + } + assert dict_diff(left, left) == {} + assert dict_diff(right, right) == {} + assert dict_diff({}, {}) == {} + assert dict_diff(left, {}) == { + "a": None, + "b": None, + "c": None, + "d": None, + } + assert dict_diff({}, right) == right + + +def test_dict_diff_arrays() -> None: + left = { + "a": [1, 2, 3], + "b": [4, 5, 6], + "c": [7, 8, 9], + } + right = { + "a": [1, 2, 3], + "b": [4, 5, 7], + "c": [7, 8, 9], + "d": [10, 11, 12], + } + assert dict_diff(left, right) == { + "b": [4, 5, 7], + "d": [10, 11, 12], + } + assert dict_diff(right, left) == { + "b": [4, 5, 6], + "d": None, + } + assert dict_diff(left, left) == {} + assert dict_diff(right, right) == {} + assert dict_diff(left, {}) == { + "a": None, + "b": None, + "c": None, + } + assert dict_diff({}, right) == right + + +def test_dict_diff_nested() -> None: + left = { + "a": { + "b": { + "c": 1, + "d": 2, + }, + "e": 3, + "f": 4, + }, + "g": { + "h": 4, + }, + "k": { + "l": { + "m": 5, + }, + }, + } + right = { + "a": { + "b": { + "c": 1, + "d": 3, + }, + "e": 3, + }, + "g": { + "h": 4, + "i": 5, + }, + } + assert dict_diff(left, right) == { + "a": { + "b": { + "d": 3, + }, + "f": None, + }, + "g": { + "i": 5, + }, + "k": None, + } + assert dict_diff(right, left) == { + "a": { + "b": { + "d": 2, + }, + "f": 4, + }, + "g": { + "i": None, + }, + "k": { + "l": { + "m": 5, + }, + }, + } + assert dict_diff(left, left) == {} + assert dict_diff(right, right) == {} + assert dict_diff(left, {}) == { + "a": None, + "g": None, + "k": None, + } + assert dict_diff({}, right) == right From 6b360a2534d0d9de4edb0b1741340e948c1f26aa Mon Sep 17 00:00:00 2001 From: Felix Uhl Date: Fri, 8 Nov 2024 20:22:46 +0100 Subject: [PATCH 23/37] tests: Add simple tests for eval_config.py --- tests/disko_lib/test_eval_config.py | 196 ++++++++++++++++++++++++++++ 1 file changed, 196 insertions(+) create mode 100644 tests/disko_lib/test_eval_config.py diff --git a/tests/disko_lib/test_eval_config.py b/tests/disko_lib/test_eval_config.py new file mode 100644 index 00000000..710c20e2 --- /dev/null +++ b/tests/disko_lib/test_eval_config.py @@ -0,0 +1,196 @@ +from disko_lib.eval_config import eval_config +from disko_lib.messages.msgs import err_missing_arguments, err_too_many_arguments +from disko_lib.result import DiskoError, DiskoSuccess + + +def test_eval_config_missing_arguments() -> None: + result = eval_config(disko_file=None, flake=None) + assert isinstance(result, DiskoError) + assert result.messages[0].factory == err_missing_arguments + assert result.context == "validate args" + + +def test_eval_config_too_many_arguments() -> None: + result = eval_config(disko_file="foo", flake="bar") + assert isinstance(result, DiskoError) + assert result.messages[0].factory == err_too_many_arguments + assert result.context == "validate args" + + +def test_eval_config_disk_file_example_simple_efi() -> None: + result = eval_config(disko_file="example/simple-efi.nix", flake=None) + assert isinstance(result, DiskoSuccess) + assert result.value == { + "disk": { + "main": { + "content": { + "device": "/dev/disk/by-id/some-disk-id", + "efiGptPartitionFirst": True, + "partitions": { + "ESP": { + "alignment": 0, + "content": { + "device": "/dev/disk/by-partlabel/disk-main-ESP", + "extraArgs": [], + "format": "vfat", + "mountOptions": ["umask=0077"], + "mountpoint": "/boot", + "postCreateHook": "", + "postMountHook": "", + "preCreateHook": "", + "preMountHook": "", + "type": "filesystem", + }, + "device": "/dev/disk/by-partlabel/disk-main-ESP", + "end": "+500M", + "hybrid": None, + "label": "disk-main-ESP", + "name": "ESP", + "priority": 1000, + "size": "500M", + "start": "0", + "type": "EF00", + }, + "root": { + "alignment": 0, + "content": { + "device": "/dev/disk/by-partlabel/disk-main-root", + "extraArgs": [], + "format": "ext4", + "mountOptions": ["defaults"], + "mountpoint": "/", + "postCreateHook": "", + "postMountHook": "", + "preCreateHook": "", + "preMountHook": "", + "type": "filesystem", + }, + "device": "/dev/disk/by-partlabel/disk-main-root", + "end": "-0", + "hybrid": None, + "label": "disk-main-root", + "name": "root", + "priority": 9001, + "size": "100%", + "start": "0", + "type": "8300", + }, + }, + "postCreateHook": "", + "postMountHook": "", + "preCreateHook": "", + "preMountHook": "", + "type": "gpt", + }, + "device": "/dev/disk/by-id/some-disk-id", + "imageName": "main", + "imageSize": "2G", + "name": "main", + "postCreateHook": "", + "postMountHook": "", + "preCreateHook": "", + "preMountHook": "", + "type": "disk", + } + }, + "lvm_vg": {}, + "mdadm": {}, + "nodev": {}, + "zpool": {}, + } + + +def test_eval_config_flake_testmachine() -> None: + result = eval_config(disko_file=None, flake=".#testmachine") + assert isinstance(result, DiskoSuccess) + assert result.value == { + "disk": { + "main": { + "content": { + "device": "/dev/disk/by-id/ata-Samsung_SSD_850_EVO_250GB_S21PNXAGB12345", + "efiGptPartitionFirst": True, + "partitions": { + "ESP": { + "alignment": 0, + "content": { + "device": "/dev/disk/by-partlabel/disk-main-ESP", + "extraArgs": [], + "format": "vfat", + "mountOptions": ["umask=0077"], + "mountpoint": "/boot", + "postCreateHook": "", + "postMountHook": "", + "preCreateHook": "", + "preMountHook": "", + "type": "filesystem", + }, + "device": "/dev/disk/by-partlabel/disk-main-ESP", + "end": "+512M", + "hybrid": None, + "label": "disk-main-ESP", + "name": "ESP", + "priority": 1000, + "size": "512M", + "start": "0", + "type": "EF00", + }, + "boot": { + "alignment": 0, + "content": None, + "device": "/dev/disk/by-partlabel/disk-main-boot", + "end": "+1M", + "hybrid": None, + "label": "disk-main-boot", + "name": "boot", + "priority": 100, + "size": "1M", + "start": "0", + "type": "EF02", + }, + "root": { + "alignment": 0, + "content": { + "device": "/dev/disk/by-partlabel/disk-main-root", + "extraArgs": [], + "format": "ext4", + "mountOptions": ["defaults"], + "mountpoint": "/", + "postCreateHook": "", + "postMountHook": "", + "preCreateHook": "", + "preMountHook": "", + "type": "filesystem", + }, + "device": "/dev/disk/by-partlabel/disk-main-root", + "end": "-0", + "hybrid": None, + "label": "disk-main-root", + "name": "root", + "priority": 9001, + "size": "100%", + "start": "0", + "type": "8300", + }, + }, + "postCreateHook": "", + "postMountHook": "", + "preCreateHook": "", + "preMountHook": "", + "type": "gpt", + }, + "device": "/dev/disk/by-id/ata-Samsung_SSD_850_EVO_250GB_S21PNXAGB12345", + "imageName": "main", + "imageSize": "2G", + "name": "main", + "postCreateHook": "", + "postMountHook": "", + "preCreateHook": "", + "preMountHook": "", + "type": "disk", + } + }, + "lvm_vg": {}, + "mdadm": {}, + "nodev": {}, + "zpool": {}, + } From c5d1a42af70d76e6eff8a604491d612f731d5127 Mon Sep 17 00:00:00 2001 From: Felix Uhl Date: Fri, 8 Nov 2024 21:39:34 +0100 Subject: [PATCH 24/37] tests: Put tests with snapshots into directories This makes the tests more readable and easier to browse. Making sure the tests always run fine, no matter from which directory they're started is a little detail we should either test for or document. --- tests/disko_lib/eval_config/README.md | 17 ++ .../eval_config/file-simple-efi-result.json | 82 ++++++++ .../eval_config/flake-testmachine-result.json | 95 +++++++++ .../disko_lib/eval_config/test_eval_config.py | 41 ++++ tests/disko_lib/test_eval_config.py | 196 ------------------ ...al_failure_dos_table-generate-result.json} | 0 ...rtial_failure_dos_table-lsblk-output.json} | 0 .../{ => types_disk}/test_types_disk.py | 4 +- 8 files changed, 237 insertions(+), 198 deletions(-) create mode 100644 tests/disko_lib/eval_config/README.md create mode 100644 tests/disko_lib/eval_config/file-simple-efi-result.json create mode 100644 tests/disko_lib/eval_config/flake-testmachine-result.json create mode 100644 tests/disko_lib/eval_config/test_eval_config.py delete mode 100644 tests/disko_lib/test_eval_config.py rename tests/disko_lib/{generate-result.json => types_disk/partial_failure_dos_table-generate-result.json} (100%) rename tests/disko_lib/{lsblk-output.json => types_disk/partial_failure_dos_table-lsblk-output.json} (100%) rename tests/disko_lib/{ => types_disk}/test_types_disk.py (87%) diff --git a/tests/disko_lib/eval_config/README.md b/tests/disko_lib/eval_config/README.md new file mode 100644 index 00000000..02aa0306 --- /dev/null +++ b/tests/disko_lib/eval_config/README.md @@ -0,0 +1,17 @@ +# eval_config.py tests + +If you change something about the evaluation and need to update one of the +result files here, you can run a command like this: + + ./disko2 dev eval example/simple-efi.nix > tests/disko_lib/eval_config/file-simple-efi-result.json + +Change the paths depending on the example whose evaluation result changed. + +If you're thinking "this sounds like snapshots to me" and "isn't there a pytest plugin for this?", +then you'd be correct, but [pytest-insta](https://github.com/vberlier/pytest-insta) is not packaged +in nixpkgs at the time of writing (2024-11-08). + +If you're reading this, and you +[search nixpkgs for "pytest-insta"](https://search.nixos.org/packages?channel=unstable&query=pytest-insta) +AND this returns the `pytest-insta` package (or there is a new, better snapshotting plugin for pytest), +please open an issue so we can replace this manual process with it! \ No newline at end of file diff --git a/tests/disko_lib/eval_config/file-simple-efi-result.json b/tests/disko_lib/eval_config/file-simple-efi-result.json new file mode 100644 index 00000000..5f07782d --- /dev/null +++ b/tests/disko_lib/eval_config/file-simple-efi-result.json @@ -0,0 +1,82 @@ +{ + "disk": { + "main": { + "content": { + "device": "/dev/disk/by-id/some-disk-id", + "efiGptPartitionFirst": true, + "partitions": { + "ESP": { + "alignment": 0, + "content": { + "device": "/dev/disk/by-partlabel/disk-main-ESP", + "extraArgs": [], + "format": "vfat", + "mountOptions": [ + "umask=0077" + ], + "mountpoint": "/boot", + "postCreateHook": "", + "postMountHook": "", + "preCreateHook": "", + "preMountHook": "", + "type": "filesystem" + }, + "device": "/dev/disk/by-partlabel/disk-main-ESP", + "end": "+500M", + "hybrid": null, + "label": "disk-main-ESP", + "name": "ESP", + "priority": 1000, + "size": "500M", + "start": "0", + "type": "EF00" + }, + "root": { + "alignment": 0, + "content": { + "device": "/dev/disk/by-partlabel/disk-main-root", + "extraArgs": [], + "format": "ext4", + "mountOptions": [ + "defaults" + ], + "mountpoint": "/", + "postCreateHook": "", + "postMountHook": "", + "preCreateHook": "", + "preMountHook": "", + "type": "filesystem" + }, + "device": "/dev/disk/by-partlabel/disk-main-root", + "end": "-0", + "hybrid": null, + "label": "disk-main-root", + "name": "root", + "priority": 9001, + "size": "100%", + "start": "0", + "type": "8300" + } + }, + "postCreateHook": "", + "postMountHook": "", + "preCreateHook": "", + "preMountHook": "", + "type": "gpt" + }, + "device": "/dev/disk/by-id/some-disk-id", + "imageName": "main", + "imageSize": "2G", + "name": "main", + "postCreateHook": "", + "postMountHook": "", + "preCreateHook": "", + "preMountHook": "", + "type": "disk" + } + }, + "lvm_vg": {}, + "mdadm": {}, + "nodev": {}, + "zpool": {} +} diff --git a/tests/disko_lib/eval_config/flake-testmachine-result.json b/tests/disko_lib/eval_config/flake-testmachine-result.json new file mode 100644 index 00000000..00abc610 --- /dev/null +++ b/tests/disko_lib/eval_config/flake-testmachine-result.json @@ -0,0 +1,95 @@ +{ + "disk": { + "main": { + "content": { + "device": "/dev/disk/by-id/ata-Samsung_SSD_850_EVO_250GB_S21PNXAGB12345", + "efiGptPartitionFirst": true, + "partitions": { + "ESP": { + "alignment": 0, + "content": { + "device": "/dev/disk/by-partlabel/disk-main-ESP", + "extraArgs": [], + "format": "vfat", + "mountOptions": [ + "umask=0077" + ], + "mountpoint": "/boot", + "postCreateHook": "", + "postMountHook": "", + "preCreateHook": "", + "preMountHook": "", + "type": "filesystem" + }, + "device": "/dev/disk/by-partlabel/disk-main-ESP", + "end": "+512M", + "hybrid": null, + "label": "disk-main-ESP", + "name": "ESP", + "priority": 1000, + "size": "512M", + "start": "0", + "type": "EF00" + }, + "boot": { + "alignment": 0, + "content": null, + "device": "/dev/disk/by-partlabel/disk-main-boot", + "end": "+1M", + "hybrid": null, + "label": "disk-main-boot", + "name": "boot", + "priority": 100, + "size": "1M", + "start": "0", + "type": "EF02" + }, + "root": { + "alignment": 0, + "content": { + "device": "/dev/disk/by-partlabel/disk-main-root", + "extraArgs": [], + "format": "ext4", + "mountOptions": [ + "defaults" + ], + "mountpoint": "/", + "postCreateHook": "", + "postMountHook": "", + "preCreateHook": "", + "preMountHook": "", + "type": "filesystem" + }, + "device": "/dev/disk/by-partlabel/disk-main-root", + "end": "-0", + "hybrid": null, + "label": "disk-main-root", + "name": "root", + "priority": 9001, + "size": "100%", + "start": "0", + "type": "8300" + } + }, + "postCreateHook": "", + "postMountHook": "", + "preCreateHook": "", + "preMountHook": "", + "type": "gpt" + }, + "device": "/dev/disk/by-id/ata-Samsung_SSD_850_EVO_250GB_S21PNXAGB12345", + "imageName": "main", + "imageSize": "2G", + "name": "main", + "postCreateHook": "", + "postMountHook": "", + "preCreateHook": "", + "preMountHook": "", + "type": "disk" + } + }, + "lvm_vg": {}, + "mdadm": {}, + "nodev": {}, + "zpool": {} +} diff --git a/tests/disko_lib/eval_config/test_eval_config.py b/tests/disko_lib/eval_config/test_eval_config.py new file mode 100644 index 00000000..249439a1 --- /dev/null +++ b/tests/disko_lib/eval_config/test_eval_config.py @@ -0,0 +1,41 @@ +import json +from pathlib import Path + +from disko_lib.eval_config import eval_config +from disko_lib.messages.msgs import err_missing_arguments, err_too_many_arguments +from disko_lib.result import DiskoError, DiskoSuccess + +CURRENT_DIR = Path(__file__).parent +ROOT_DIR = CURRENT_DIR.parent.parent.parent +assert (ROOT_DIR / "flake.nix").exists() + + +def test_eval_config_missing_arguments() -> None: + result = eval_config(disko_file=None, flake=None) + assert isinstance(result, DiskoError) + assert result.messages[0].factory == err_missing_arguments + assert result.context == "validate args" + + +def test_eval_config_too_many_arguments() -> None: + result = eval_config(disko_file="foo", flake="bar") + assert isinstance(result, DiskoError) + assert result.messages[0].factory == err_too_many_arguments + assert result.context == "validate args" + + +def test_eval_config_disk_file() -> None: + disko_file_path = ROOT_DIR / "example" / "simple-efi.nix" + result = eval_config(disko_file=str(disko_file_path), flake=None) + assert isinstance(result, DiskoSuccess) + with open(CURRENT_DIR / "file-simple-efi-result.json") as f: + expected_result = json.load(f) + assert result.value == expected_result + + +def test_eval_config_flake_testmachine() -> None: + result = eval_config(disko_file=None, flake=f"{ROOT_DIR}#testmachine") + assert isinstance(result, DiskoSuccess) + with open(CURRENT_DIR / "flake-testmachine-result.json") as f: + expected_result = json.load(f) + assert result.value == expected_result diff --git a/tests/disko_lib/test_eval_config.py b/tests/disko_lib/test_eval_config.py deleted file mode 100644 index 710c20e2..00000000 --- a/tests/disko_lib/test_eval_config.py +++ /dev/null @@ -1,196 +0,0 @@ -from disko_lib.eval_config import eval_config -from disko_lib.messages.msgs import err_missing_arguments, err_too_many_arguments -from disko_lib.result import DiskoError, DiskoSuccess - - -def test_eval_config_missing_arguments() -> None: - result = eval_config(disko_file=None, flake=None) - assert isinstance(result, DiskoError) - assert result.messages[0].factory == err_missing_arguments - assert result.context == "validate args" - - -def test_eval_config_too_many_arguments() -> None: - result = eval_config(disko_file="foo", flake="bar") - assert isinstance(result, DiskoError) - assert result.messages[0].factory == err_too_many_arguments - assert result.context == "validate args" - - -def test_eval_config_disk_file_example_simple_efi() -> None: - result = eval_config(disko_file="example/simple-efi.nix", flake=None) - assert isinstance(result, DiskoSuccess) - assert result.value == { - "disk": { - "main": { - "content": { - "device": "/dev/disk/by-id/some-disk-id", - "efiGptPartitionFirst": True, - "partitions": { - "ESP": { - "alignment": 0, - "content": { - "device": "/dev/disk/by-partlabel/disk-main-ESP", - "extraArgs": [], - "format": "vfat", - "mountOptions": ["umask=0077"], - "mountpoint": "/boot", - "postCreateHook": "", - "postMountHook": "", - "preCreateHook": "", - "preMountHook": "", - "type": "filesystem", - }, - "device": "/dev/disk/by-partlabel/disk-main-ESP", - "end": "+500M", - "hybrid": None, - "label": "disk-main-ESP", - "name": "ESP", - "priority": 1000, - "size": "500M", - "start": "0", - "type": "EF00", - }, - "root": { - "alignment": 0, - "content": { - "device": "/dev/disk/by-partlabel/disk-main-root", - "extraArgs": [], - "format": "ext4", - "mountOptions": ["defaults"], - "mountpoint": "/", - "postCreateHook": "", - "postMountHook": "", - "preCreateHook": "", - "preMountHook": "", - "type": "filesystem", - }, - "device": "/dev/disk/by-partlabel/disk-main-root", - "end": "-0", - "hybrid": None, - "label": "disk-main-root", - "name": "root", - "priority": 9001, - "size": "100%", - "start": "0", - "type": "8300", - }, - }, - "postCreateHook": "", - "postMountHook": "", - "preCreateHook": "", - "preMountHook": "", - "type": "gpt", - }, - "device": "/dev/disk/by-id/some-disk-id", - "imageName": "main", - "imageSize": "2G", - "name": "main", - "postCreateHook": "", - "postMountHook": "", - "preCreateHook": "", - "preMountHook": "", - "type": "disk", - } - }, - "lvm_vg": {}, - "mdadm": {}, - "nodev": {}, - "zpool": {}, - } - - -def test_eval_config_flake_testmachine() -> None: - result = eval_config(disko_file=None, flake=".#testmachine") - assert isinstance(result, DiskoSuccess) - assert result.value == { - "disk": { - "main": { - "content": { - "device": "/dev/disk/by-id/ata-Samsung_SSD_850_EVO_250GB_S21PNXAGB12345", - "efiGptPartitionFirst": True, - "partitions": { - "ESP": { - "alignment": 0, - "content": { - "device": "/dev/disk/by-partlabel/disk-main-ESP", - "extraArgs": [], - "format": "vfat", - "mountOptions": ["umask=0077"], - "mountpoint": "/boot", - "postCreateHook": "", - "postMountHook": "", - "preCreateHook": "", - "preMountHook": "", - "type": "filesystem", - }, - "device": "/dev/disk/by-partlabel/disk-main-ESP", - "end": "+512M", - "hybrid": None, - "label": "disk-main-ESP", - "name": "ESP", - "priority": 1000, - "size": "512M", - "start": "0", - "type": "EF00", - }, - "boot": { - "alignment": 0, - "content": None, - "device": "/dev/disk/by-partlabel/disk-main-boot", - "end": "+1M", - "hybrid": None, - "label": "disk-main-boot", - "name": "boot", - "priority": 100, - "size": "1M", - "start": "0", - "type": "EF02", - }, - "root": { - "alignment": 0, - "content": { - "device": "/dev/disk/by-partlabel/disk-main-root", - "extraArgs": [], - "format": "ext4", - "mountOptions": ["defaults"], - "mountpoint": "/", - "postCreateHook": "", - "postMountHook": "", - "preCreateHook": "", - "preMountHook": "", - "type": "filesystem", - }, - "device": "/dev/disk/by-partlabel/disk-main-root", - "end": "-0", - "hybrid": None, - "label": "disk-main-root", - "name": "root", - "priority": 9001, - "size": "100%", - "start": "0", - "type": "8300", - }, - }, - "postCreateHook": "", - "postMountHook": "", - "preCreateHook": "", - "preMountHook": "", - "type": "gpt", - }, - "device": "/dev/disk/by-id/ata-Samsung_SSD_850_EVO_250GB_S21PNXAGB12345", - "imageName": "main", - "imageSize": "2G", - "name": "main", - "postCreateHook": "", - "postMountHook": "", - "preCreateHook": "", - "preMountHook": "", - "type": "disk", - } - }, - "lvm_vg": {}, - "mdadm": {}, - "nodev": {}, - "zpool": {}, - } diff --git a/tests/disko_lib/generate-result.json b/tests/disko_lib/types_disk/partial_failure_dos_table-generate-result.json similarity index 100% rename from tests/disko_lib/generate-result.json rename to tests/disko_lib/types_disk/partial_failure_dos_table-generate-result.json diff --git a/tests/disko_lib/lsblk-output.json b/tests/disko_lib/types_disk/partial_failure_dos_table-lsblk-output.json similarity index 100% rename from tests/disko_lib/lsblk-output.json rename to tests/disko_lib/types_disk/partial_failure_dos_table-lsblk-output.json diff --git a/tests/disko_lib/test_types_disk.py b/tests/disko_lib/types_disk/test_types_disk.py similarity index 87% rename from tests/disko_lib/test_types_disk.py rename to tests/disko_lib/types_disk/test_types_disk.py index 9afcc79e..47ac6bb8 100644 --- a/tests/disko_lib/test_types_disk.py +++ b/tests/disko_lib/types_disk/test_types_disk.py @@ -10,7 +10,7 @@ def test_generate_config_partial_failure_dos_table() -> None: - with open(CURRENT_DIR / "lsblk-output.json") as f: + with open(CURRENT_DIR / "partial_failure_dos_table-lsblk-output.json") as f: lsblk_result = device.list_block_devices(f.read()) assert isinstance(lsblk_result, DiskoSuccess) @@ -26,7 +26,7 @@ def test_generate_config_partial_failure_dos_table() -> None: } assert result.messages[1].factory == warn_generate_partial_failure - with open(CURRENT_DIR / "generate-result.json") as f: + with open(CURRENT_DIR / "partial_failure_dos_table-generate-result.json") as f: assert result.messages[1].details["partial_config"] == json.load(f) assert result.messages[1].details["failed_devices"] == [PosixPath("/dev/sdc")] assert result.messages[1].details["successful_devices"] == [ From b30e5ced5e4b3dfd23596df84bbc8c249726eb72 Mon Sep 17 00:00:00 2001 From: Felix Uhl Date: Fri, 8 Nov 2024 21:45:09 +0100 Subject: [PATCH 25/37] disko2 dev: Print better error message --- src/disko/mode_dev.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/disko/mode_dev.py b/src/disko/mode_dev.py index 869ac683..f8b05854 100644 --- a/src/disko/mode_dev.py +++ b/src/disko/mode_dev.py @@ -1,9 +1,10 @@ import argparse import json -from typing import Any, assert_never +from typing import Any from disko_lib.ansi import Colors from disko_lib.eval_config import eval_config +from disko_lib.messages.msgs import err_missing_mode from disko_lib.result import DiskoError, DiskoSuccess, DiskoResult from disko_lib.types.device import run_lsblk @@ -48,4 +49,6 @@ def run_dev(args: argparse.Namespace) -> DiskoResult[None]: case "eval": return run_dev_eval(**vars(args)) case _: - assert_never(args.dev_command) + return DiskoError.single_message( + err_missing_mode, "select mode", valid_modes=["lsblk", "ansi", "eval"] + ) From 4f16850ca477559bcf5d823371473c5e0a7d377d Mon Sep 17 00:00:00 2001 From: Felix Uhl Date: Sun, 10 Nov 2024 17:31:44 +0100 Subject: [PATCH 26/37] lib: Detect and show new keys in dict_diff --- src/disko_lib/dict_diff.py | 33 +++++++++++++++++++++++++++---- tests/disko_lib/test_dict_diff.py | 31 ++++++++++++++++++++++++++++- 2 files changed, 59 insertions(+), 5 deletions(-) diff --git a/src/disko_lib/dict_diff.py b/src/disko_lib/dict_diff.py index 9f031457..5bd4e488 100644 --- a/src/disko_lib/dict_diff.py +++ b/src/disko_lib/dict_diff.py @@ -1,8 +1,29 @@ from typing import Any -# Returns a dict that only contains the keys from `right` that have different values in `left` def dict_diff(left: dict[str, Any], right: dict[str, Any]) -> dict[str, Any]: + """Return a dict that only contains the keys and values of `right` + that are different from those in `left`. + + >>> dict_diff({"a": 1, "b": 2}, {"a": 1, "b": 3}) + {'b': 3} + + Keys that are in `left` but not in `right` get the value `None`. + + >>> dict_diff({"a": 1, "b": 2}, {"a": 1}) + {'b': None} + + Dicts are compared recursively. + + >>> dict_diff({"a": {"b": 2}}, {"a": {"b": 3}}) + {'a': {'b': 3}} + + If a dict is missing in `left`, it gets the special key "_new" set + to True to differentiate it from a dict that was present but changed. + + >>> dict_diff({"a": {"b": 1}}, {"a": {"b": 3}, "c": {"d": 4}}) + {'a': {'b': 3}, 'c': {'d': 4, '_new': True}} + """ new_dict: dict[str, Any] = {} for k, right_val in right.items(): @@ -10,14 +31,18 @@ def dict_diff(left: dict[str, Any], right: dict[str, Any]) -> dict[str, Any]: if left_val == right_val: continue - if isinstance(right_val, dict): - new_dict[k] = dict_diff(left.get(k, {}), right[k]) - else: + if not isinstance(right_val, dict): new_dict[k] = right[k] + continue + + new_dict[k] = dict_diff(left.get(k, {}), right[k]) + if not left_val: + new_dict[k]["_new"] = True for k, left_val in left.items(): if k not in right: # Do not recurse, even if left_val is a dict! + # Recursion is already done in the first loop. new_dict[k] = None return new_dict diff --git a/tests/disko_lib/test_dict_diff.py b/tests/disko_lib/test_dict_diff.py index 24e94c96..7cf0aef0 100644 --- a/tests/disko_lib/test_dict_diff.py +++ b/tests/disko_lib/test_dict_diff.py @@ -99,6 +99,9 @@ def test_dict_diff_nested() -> None: "h": 4, "i": 5, }, + "o": { + "p": 6, + }, } assert dict_diff(left, right) == { "a": { @@ -111,6 +114,10 @@ def test_dict_diff_nested() -> None: "i": 5, }, "k": None, + "o": { + "p": 6, + "_new": True, + }, } assert dict_diff(right, left) == { "a": { @@ -125,8 +132,11 @@ def test_dict_diff_nested() -> None: "k": { "l": { "m": 5, + "_new": True, }, + "_new": True, }, + "o": None, } assert dict_diff(left, left) == {} assert dict_diff(right, right) == {} @@ -135,4 +145,23 @@ def test_dict_diff_nested() -> None: "g": None, "k": None, } - assert dict_diff({}, right) == right + assert dict_diff({}, right) == { + "a": { + "b": { + "c": 1, + "d": 3, + "_new": True, + }, + "e": 3, + "_new": True, + }, + "g": { + "h": 4, + "i": 5, + "_new": True, + }, + "o": { + "p": 6, + "_new": True, + }, + } From 70efb0a6584efe0d68962328ee4fee61aea82084 Mon Sep 17 00:00:00 2001 From: Felix Uhl Date: Sun, 10 Nov 2024 17:39:39 +0100 Subject: [PATCH 27/37] lib: Evaluate partition's _index parameter This is required to be able to generate sgdisk invocations that are equivalent to what's currently generated in nix. --- src/disko_lib/eval-config.nix | 3 ++- tests/disko_lib/eval_config/file-simple-efi-result.json | 2 ++ tests/disko_lib/eval_config/flake-testmachine-result.json | 3 +++ 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/disko_lib/eval-config.nix b/src/disko_lib/eval-config.nix index 9225f2c6..eebdbe7e 100644 --- a/src/disko_lib/eval-config.nix +++ b/src/disko_lib/eval-config.nix @@ -44,6 +44,7 @@ let diskoConfig = evaluatedConfig.config.disko.devices; - finalConfig = lib.filterAttrsRecursive (name: value: !lib.hasPrefix "_" name) diskoConfig; + shouldBeEvaluated = name: (!lib.hasPrefix "_" name) || (name == "_index"); + finalConfig = lib.filterAttrsRecursive (name: value: shouldBeEvaluated name) diskoConfig; in finalConfig diff --git a/tests/disko_lib/eval_config/file-simple-efi-result.json b/tests/disko_lib/eval_config/file-simple-efi-result.json index 5f07782d..40ae4614 100644 --- a/tests/disko_lib/eval_config/file-simple-efi-result.json +++ b/tests/disko_lib/eval_config/file-simple-efi-result.json @@ -6,6 +6,7 @@ "efiGptPartitionFirst": true, "partitions": { "ESP": { + "_index": 1, "alignment": 0, "content": { "device": "/dev/disk/by-partlabel/disk-main-ESP", @@ -32,6 +33,7 @@ "type": "EF00" }, "root": { + "_index": 2, "alignment": 0, "content": { "device": "/dev/disk/by-partlabel/disk-main-root", diff --git a/tests/disko_lib/eval_config/flake-testmachine-result.json b/tests/disko_lib/eval_config/flake-testmachine-result.json index 00abc610..426f4e52 100644 --- a/tests/disko_lib/eval_config/flake-testmachine-result.json +++ b/tests/disko_lib/eval_config/flake-testmachine-result.json @@ -6,6 +6,7 @@ "efiGptPartitionFirst": true, "partitions": { "ESP": { + "_index": 2, "alignment": 0, "content": { "device": "/dev/disk/by-partlabel/disk-main-ESP", @@ -32,6 +33,7 @@ "type": "EF00" }, "boot": { + "_index": 1, "alignment": 0, "content": null, "device": "/dev/disk/by-partlabel/disk-main-boot", @@ -45,6 +47,7 @@ "type": "EF02" }, "root": { + "_index": 3, "alignment": 0, "content": { "device": "/dev/disk/by-partlabel/disk-main-root", From 2bd9c4f8c74036da95c457d07a60488f759ae38c Mon Sep 17 00:00:00 2001 From: Felix Uhl Date: Sun, 10 Nov 2024 18:45:18 +0100 Subject: [PATCH 28/37] dev,docs: Improve and document DX with Python --- CONTRIBUTING.md | 3 ++ docs/INDEX.md | 1 + docs/dev-setup.md | 75 ++++++++++++++++++++++++++++ docs/img/vscode-direnv-prompt.png | Bin 0 -> 7402 bytes docs/img/vscode-recommended-ext.png | Bin 0 -> 52059 bytes docs/img/vscode-select-python.png | Bin 0 -> 64711 bytes docs/testing.md | 23 +++++++-- flake.nix | 14 +++++- pyproject.toml | 1 + 9 files changed, 111 insertions(+), 6 deletions(-) create mode 100644 docs/dev-setup.md create mode 100644 docs/img/vscode-direnv-prompt.png create mode 100644 docs/img/vscode-recommended-ext.png create mode 100644 docs/img/vscode-select-python.png diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e8540b5e..b68b7154 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,6 +12,9 @@ way to help us fix issues quickly. Check out For more information on how to run and debug tests, check out [Running and debugging tests](./docs/testing.md). +Also refer to [Setting up your development environment](./dev-setup.md) to get +the best possible development experience. + ## How to find issues to work on If you're looking for a low-hanging fruit, check out diff --git a/docs/INDEX.md b/docs/INDEX.md index 2c1b0270..1e78b9a2 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -19,4 +19,5 @@ ### For contributors +- [Setting up your development environment](./dev-setup.md) - [Running and debugging tests](./testing.md) diff --git a/docs/dev-setup.md b/docs/dev-setup.md new file mode 100644 index 00000000..a8c8dc9b --- /dev/null +++ b/docs/dev-setup.md @@ -0,0 +1,75 @@ +# Setting up your development environment + +**This guide assumes you have flakes enabled.** + +disko uses Nix flake's `devShells` output and [direnv](https://direnv.net/) to +set up the development environment in two ways. + +The quickest way to get started is to run: + +``` +nix develop +``` + +However, if you use a shell other than bash, working inside `nix develop` might +get annoying quickly. An alternative is to use direnv, which sets up the +environment in your current shell session: + +```console +# nix shell nixpkgs#direnv +direnv: error /home/felix/repos-new/temp/disko/.envrc is blocked. Run `direnv allow` to approve its content +# direnv allow +direnv: loading ~/repos-new/temp/disko/.envrc +direnv: using flake +direnv: export +AR +AS +CC +CONFIG_SHELL +CXX +HOST_PATH +IN_NIX_SHELL +LD +NIX_BINTOOLS +NIX_BINTOOLS_WRAPPER_TARGET_HOST_x86_64_unknown_linux_gnu +NIX_BUILD_CORES +NIX_BUILD_TOP +NIX_CC +NIX_CC_WRAPPER_TARGET_HOST_x86_64_unknown_linux_gnu +NIX_CFLAGS_COMPILE +NIX_ENFORCE_NO_NATIVE +NIX_HARDENING_ENABLE +NIX_LDFLAGS +NIX_STORE +NM +OBJCOPY +OBJDUMP +RANLIB +READELF +SIZE +SOURCE_DATE_EPOCH +STRINGS +STRIP +TEMP +TEMPDIR +TMP +TMPDIR +__structuredAttrs +buildInputs +buildPhase +builder +cmakeFlags +configureFlags +depsBuildBuild +depsBuildBuildPropagated +depsBuildTarget +depsBuildTargetPropagated +depsHostHost +depsHostHostPropagated +depsTargetTarget +depsTargetTargetPropagated +doCheck +doInstallCheck +dontAddDisableDepTrack +mesonFlags +name +nativeBuildInputs +out +outputs +patches +phases +preferLocalBuild +propagatedBuildInputs +propagatedNativeBuildInputs +shell +shellHook +stdenv +strictDeps +system ~PATH ~XDG_DATA_DIRS +``` + +You can now run `./disko2 dev --help` or `pytest` to confirm everything is working. + +If you're working exclusively in the terminal, you're all set. + +## IDE Integration + +### VSCode + +If you're using VSCode or any of its forks, you should install the recommended +extensions. You can find them in .vscode/extensions.json, searching for +`@recommended` in the Extensions Panel, or by opening the +command pallette and searching "Show Recommended Extensions". + +You can then install all extensions in one click: + +![VSCode button "Install workspace recommended extensions](./img/vscode-recommended-ext.png) + +When you do this (and every time you open the repository again), the +[direnv extension](https://marketplace.visualstudio.com/items?itemName=mkhl.direnv) +will prompt you in the bottom right corner to restart all extensions once it +has loaded the development environment: + +![Direnv extension asking "Environment updated. Restart extensions?"](./img/vscode-direnv-prompt.png) + +Click "Restart" to make all dependencies available to VSCode. + +Afterwards, open the command pallette, search "Python: Select Interpreter" and +press Enter. The "Select Interpreter" dialog will open: + +![VSCode Python "Select Interpreter" dialog](./img/vscode-select-python.png) + +Do not select the interpreters tagged "Recommended" or "Global"! These will be +the ones installed on your system or currently selected in the extension. +Instead, pick one of the ones below them, this will be the latest available +package that includes the python packages specified in the `devShell`! + +Now you're all set! You might want to also enable the "Format on Save" settings +and run the "Format Document" command once to select a formatter for each file +type. + +Remember to go through these steps again whenever updating the flake. + +### Other IDEs + +These are just notes. Feel free to submit a PR that adds support and +documentation for other IDEs! + +[Jetbrains Marketplace has two direnv extensions available](https://plugins.jetbrains.com/search?excludeTags=internal&search=direnv) + diff --git a/docs/img/vscode-direnv-prompt.png b/docs/img/vscode-direnv-prompt.png new file mode 100644 index 0000000000000000000000000000000000000000..dfa1f83e71ab53bc204246980b2fab2c520f5276 GIT binary patch literal 7402 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%0DC6M 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 G{Qnnby6V^f literal 0 HcmV?d00001 diff --git a/docs/testing.md b/docs/testing.md index 152270a5..8d96ca1f 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -1,5 +1,20 @@ # Running and debugging tests +This assumes you've already set up your development environment! + +See [Setting up your development environment](./dev-setup.md) + +## Python tests + +Big parts of disko are written in Python. You can test all Python +functionality by simply running + +``` +pytest +``` + +## VM tests + Disko makes extensive use of VM tests. All examples you can find in [the example directory](../example) have a respective test suite that verifies the example is working in [the tests directory](../tests/). They utilize the @@ -14,7 +29,7 @@ However, you don't need to know about all of the inner workings to interact with the tests effectively. For some of the most common operations, see the sections below. -## Run just one of the tests +### Run just one of the tests ```sh nix build --no-link .#checks.x86_64-linux.simple-efi @@ -27,7 +42,7 @@ virtual devices, run disko to format them, reboot, verify the VM boots properly, and then run the code specified in `extraTestScript` to validate that the partitions have been created and were mounted as expected. -### How `extraTestScript` works +#### How `extraTestScript` works This is written in Python. The most common lines you'll see look something like this: @@ -45,7 +60,7 @@ Disko currently (as of 2024-10-16) doesn't have any tests that utilize multiple VMs at once, so the only machine available in these scripts is always just the default `machine`. -## Debugging tests +### Debugging tests If you make changes to disko, you might break a test, or you may want to modify a test to prevent regressions. In these cases, running the full test with @@ -131,7 +146,7 @@ vdb 253:16 0 4G 0 disk You can find some additional details in [the NixOS manual's section on interactive testing](https://nixos.org/manual/nixos/stable/#sec-running-nixos-tests-interactively). -## Running all tests at once +### Running all tests at once If you have a bit of experience, you might be inclined to run `nix flake check` to run all tests at once. However, we instead recommend using diff --git a/flake.nix b/flake.nix index 4b795b4c..70dcb2af 100644 --- a/flake.nix +++ b/flake.nix @@ -60,6 +60,14 @@ diskoVersion = version; }; + # TODO: Add a CI pipeline instead that runs nix run .#pytest inside nix develop + pytest-ci-only = pkgs.runCommand "pytest" { nativeBuildInputs = [ pkgs.python3Packages.pytest ]; } '' + cd ${./.} + # eval_config runs nix, which is forbidden inside of nix derivations by default + pytest -vv --doctest-modules -p no:cacheprovider --ignore=tests/disko_lib/eval_config + touch $out + ''; + shellcheck = pkgs.runCommand "shellcheck" { nativeBuildInputs = [ pkgs.shellcheck ]; } '' cd ${./.} shellcheck src/disk-deactivate/disk-deactivate disko disko2 @@ -69,7 +77,7 @@ # FIXME: aarch64-linux seems to hang on boot lib.optionalAttrs pkgs.hostPlatform.isx86_64 (nixosTests // { inherit disko-install; }) // pkgs.lib.optionalAttrs (!pkgs.buildPlatform.isRiscV64 && !pkgs.hostPlatform.isx86_32) { - inherit shellcheck; + inherit pytest-ci-only shellcheck; inherit (self.packages.${system}) disko-doc; }); @@ -81,7 +89,9 @@ default = pkgs.mkShell { name = "disko-dev"; packages = (with pkgs; [ - ruff # Formatter and linter + nixpkgs-fmt # Formatter for Nix code + shellcheck # Linter for shell scripts + ruff # Formatter and linter for Python (python3.withPackages (ps: [ ps.mypy # Static type checker ps.pytest # Test runner diff --git a/pyproject.toml b/pyproject.toml index 3535b5dd..a0120eea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,7 @@ mypy_path = "src" [tool.pytest.ini_options] pythonpath = ["src"] +addopts = ["--doctest-modules"] [tool.autoflake] remove_all_unused_imports = true From a5c646bd93c956d41d532dce4c8ff95573c3d2a0 Mon Sep 17 00:00:00 2001 From: Felix Uhl Date: Wed, 13 Nov 2024 00:08:21 +0100 Subject: [PATCH 29/37] lib: Fix jsonTypes evaluation This will be very useful for generating documentation and python type definitions. --- flake.nix | 12 ++++-- src/disko_lib/default.nix | 81 ++++++++++++++++++++++++++--------- src/disko_lib/types/gpt.nix | 18 ++++++++ src/disko_lib/types/luks.nix | 2 + src/disko_lib/types/table.nix | 2 + 5 files changed, 91 insertions(+), 24 deletions(-) diff --git a/flake.nix b/flake.nix index 70dcb2af..f7b62828 100644 --- a/flake.nix +++ b/flake.nix @@ -19,13 +19,15 @@ versionInfo = import ./version.nix; version = versionInfo.version + (lib.optionalString (!versionInfo.released) "-dirty"); + + diskoLib = import ./src/disko_lib { + inherit (nixpkgs) lib; + }; in { + lib = diskoLib; nixosModules.default = self.nixosModules.disko; # convention nixosModules.disko.imports = [ ./module.nix ]; - lib = import ./src/disko_lib { - inherit (nixpkgs) lib; - }; packages = forAllSystems (system: let pkgs = nixpkgs.legacyPackages.${system}; @@ -73,11 +75,13 @@ shellcheck src/disk-deactivate/disk-deactivate disko disko2 touch $out ''; + + jsonTypes = pkgs.writeTextFile { name = "jsonTypes"; text = (builtins.toJSON diskoLib.jsonTypes); }; in # FIXME: aarch64-linux seems to hang on boot lib.optionalAttrs pkgs.hostPlatform.isx86_64 (nixosTests // { inherit disko-install; }) // pkgs.lib.optionalAttrs (!pkgs.buildPlatform.isRiscV64 && !pkgs.hostPlatform.isx86_32) { - inherit pytest-ci-only shellcheck; + inherit pytest-ci-only shellcheck jsonTypes; inherit (self.packages.${system}) disko-doc; }); diff --git a/src/disko_lib/default.nix b/src/disko_lib/default.nix index 40143342..70653cf3 100644 --- a/src/disko_lib/default.nix +++ b/src/disko_lib/default.nix @@ -22,9 +22,10 @@ let }; # option for valid contents of partitions (basically like devices, but without tables) + _partitionTypes = { inherit (diskoLib.types) btrfs filesystem zfs mdraid luks lvm_pv swap; }; partitionType = extraArgs: lib.mkOption { type = lib.types.nullOr (diskoLib.subType { - types = { inherit (diskoLib.types) btrfs filesystem zfs mdraid luks lvm_pv swap; }; + types = diskoLib.partitionTypes; inherit extraArgs; }); default = null; @@ -32,9 +33,10 @@ let }; # option for valid contents of devices + _deviceTypes = { inherit (diskoLib.types) table gpt btrfs filesystem zfs mdraid luks lvm_pv swap; }; deviceType = extraArgs: lib.mkOption { type = lib.types.nullOr (diskoLib.subType { - types = { inherit (diskoLib.types) table gpt btrfs filesystem zfs mdraid luks lvm_pv swap; }; + types = diskoLib.deviceTypes; inherit extraArgs; }); default = null; @@ -608,11 +610,22 @@ let typesSerializerLib = { rootMountPoint = ""; options = null; - config._module.args.name = "self.name"; - lib = { + config = { + _module = { + args.name = ""; + args._parent.name = ""; + args._parent.type = ""; + }; + name = ""; + }; + parent = { }; + device = "/dev/"; + # Spoof part of nixpkgs/lib to analyze the types + lib = lib // { mkOption = option: { - inherit (option) type description; - default = option.default or null; + inherit (option) type; + description = option.description or null; + default = option.defaultText or option.default or null; }; types = { attrsOf = subType: { @@ -627,35 +640,63 @@ let type = "nullOr"; inherit subType; }; + oneOf = types: { + type = "oneOf"; + inherit types; + }; + either = t1: t2: { + type = "oneOf"; + types = [ t1 t2 ]; + }; enum = choices: { type = "enum"; inherit choices; }; + anything = "anything"; + nonEmptyStr = "str"; + strMatching = _: "str"; str = "str"; bool = "bool"; int = "int"; - submodule = x: x { inherit (diskoLib.typesSerializerLib) lib config options; }; + submodule = x: x { + inherit (diskoLib.typesSerializerLib) lib config options; + name = ""; + }; }; }; diskoLib = { optionTypes.absolute-pathname = "absolute-pathname"; - deviceType = "devicetype"; - partitionType = "partitiontype"; - subType = types: "onOf ${toString (lib.attrNames types)}"; + # Spoof these tyeps + deviceType = _: ""; + partitionType = _: ""; + subType = { types, ... }: { + type = "oneOf"; + types = lib.attrNames types; + }; + mkCreateOption = option: "_create"; }; }; - jsonTypes = lib.listToAttrs ( - map - (file: lib.nameValuePair - (lib.removeSuffix ".nix" file) - (diskoLib.serializeType (import ./types/${file} diskoLib.typesSerializerLib)) - ) - (lib.attrNames (builtins.readDir ./types)) - ); - - + jsonTypes = lib.listToAttrs + ( + map + (file: lib.nameValuePair + (lib.removeSuffix ".nix" file) + (diskoLib.serializeType (import ./types/${file} diskoLib.typesSerializerLib)) + ) + (lib.filter (name: lib.hasSuffix ".nix" name) (lib.attrNames (builtins.readDir ./types))) + ) // { + partitionType = { + type = "oneOf"; + types = lib.attrNames diskoLib._partitionTypes; + }; + deviceType = { + type = "oneOf"; + types = lib.attrNames diskoLib._deviceTypes; + }; + }; }; + outputs = { lib ? import , rootMountPoint ? "/mnt" diff --git a/src/disko_lib/types/gpt.nix b/src/disko_lib/types/gpt.nix index c52ef8c8..b547b4dd 100644 --- a/src/disko_lib/types/gpt.nix +++ b/src/disko_lib/types/gpt.nix @@ -42,6 +42,13 @@ in "/dev/disk/by-id/md-name-any:${config._parent.name}-part${toString partition.config._index}" else "/dev/disk/by-partlabel/${diskoLib.hexEscapeUdevSymlink partition.config.label}"; + defaultText = '' + if the parent is an mdadm device: + /dev/disk/by-id/md-name-any:''${config._parent.name}-part''${toString partition.config._index} + + otherwise: + /dev/disk/by-partlabel/''${diskoLib.hexEscapeUdevSymlink partition.config.label} + ''; description = "Device to use for the partition"; }; priority = lib.mkOption { @@ -80,6 +87,11 @@ in builtins.substring 0 limit (builtins.hashString "sha256" label) else label; + defaultText = '' + ''${config._parent.type}-''${config._parent.name}-''${partition.config.name} + + or a truncated hash of the above if it is longer than 36 characters + ''; }; size = lib.mkOption { type = lib.types.either (lib.types.enum [ "100%" ]) (lib.types.strMatching "[0-9]+[KMGTP]?"); @@ -93,6 +105,7 @@ in alignment = lib.mkOption { type = lib.types.int; default = if (builtins.substring (builtins.stringLength partition.config.start - 1) 1 partition.config.start == "s" || (builtins.substring (builtins.stringLength partition.config.end - 1) 1 partition.config.end == "s")) then 1 else 0; + defaultText = "1 if the unit of start or end is sectors, 0 otherwise"; description = "Alignment of the partition, if sectors are used as start or end it can be aligned to 1"; }; start = lib.mkOption { @@ -103,6 +116,9 @@ in end = lib.mkOption { type = lib.types.str; default = if partition.config.size == "100%" then "-0" else "+${partition.config.size}"; + defaultText = '' + if partition.config.size == "100%" then "-0" else "+''${partition.config.size}"; + ''; description = '' End of the partition, in sgdisk format. Use + for relative sizes from the partitions start @@ -142,8 +158,10 @@ in description = "Entry to add to the Hybrid MBR table"; }; _index = lib.mkOption { + type = lib.types.int; internal = true; default = diskoLib.indexOf (x: x.name == partition.config.name) sortedPartitions 0; + defaultText = null; }; }; })); diff --git a/src/disko_lib/types/luks.nix b/src/disko_lib/types/luks.nix index 8056b35d..637dc3ef 100644 --- a/src/disko_lib/types/luks.nix +++ b/src/disko_lib/types/luks.nix @@ -59,9 +59,11 @@ in askPassword = lib.mkOption { type = lib.types.bool; default = config.keyFile == null && config.passwordFile == null && (! config.settings ? "keyFile"); + defaultText = "true if neither keyFile nor passwordFile are set"; description = "Whether to ask for a password for initial encryption"; }; settings = lib.mkOption { + type = lib.types.attrsOf lib.types.anything; default = { }; description = "LUKS settings (as defined in configuration.nix in boot.initrd.luks.devices.)"; example = ''{ diff --git a/src/disko_lib/types/table.nix b/src/disko_lib/types/table.nix index 8aec03d8..249a5265 100644 --- a/src/disko_lib/types/table.nix +++ b/src/disko_lib/types/table.nix @@ -63,8 +63,10 @@ }; content = diskoLib.partitionType { parent = config; device = diskoLib.deviceNumbering config.device partition.config._index; }; _index = lib.mkOption { + type = lib.types.int; internal = true; default = lib.toInt (lib.head (builtins.match ".*entry ([[:digit:]]+)]" name)); + defaultText = null; }; }; })); From d382a3cccf4547ec61503db46ff7a316bebda581 Mon Sep 17 00:00:00 2001 From: Felix Uhl Date: Fri, 15 Nov 2024 16:27:53 +0100 Subject: [PATCH 30/37] tests: Make mypy a lot stricter The default `strict = true` is too permissive for my liking, especially how it allows using `Any` in many places and doesn't warn about them at all. --- pyproject.toml | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index a0120eea..86f4fe62 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,8 +16,39 @@ package-dir = { "" = "src" } "*" = ["*.nix"] [tool.mypy] -strict = true mypy_path = "src" +warn_unused_configs = true +# We don't use `strict = true` but write out all individual flags because +# strict is not strict enough and its meaning may change in the future. +# See https://mypy.readthedocs.io/en/stable/config_file.html#confval-strict +# Disallow dynamic typing +disallow_any_unimported = true +disallow_any_expr = true +disallow_any_decorated = true +disallow_any_explicit = false # We need to be able to use `Any` as an input parameter to some functions +disallow_any_generics = true +disallow_subclassing_any = true +# Untyped definitions and calls +disallow_untyped_calls = true +disallow_untyped_defs = true +disallow_incomplete_defs = true +check_untyped_defs = true +disallow_untyped_decorators = true +# Warnings +warn_redundant_casts = true +warn_unused_ignores = true +warn_no_return = true +warn_return_any = true +warn_unreachable = true +# Miscellaneous strictness flags +no_implicit_reexport = true +strict_concatenate = true +strict_equality = true +extra_checks = true +# Error message control +show_error_context = true +show_error_code_links = true +pretty = true [tool.pytest.ini_options] pythonpath = ["src"] From 08ec11ae1b57564fd15140805303c5bb00343877 Mon Sep 17 00:00:00 2001 From: Felix Uhl Date: Wed, 13 Nov 2024 00:08:21 +0100 Subject: [PATCH 31/37] lib: Fix jsonTypes evaluation This will be very useful for generating documentation and python type definitions. --- src/disko_lib/default.nix | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/disko_lib/default.nix b/src/disko_lib/default.nix index 70653cf3..c7659b8f 100644 --- a/src/disko_lib/default.nix +++ b/src/disko_lib/default.nix @@ -25,7 +25,7 @@ let _partitionTypes = { inherit (diskoLib.types) btrfs filesystem zfs mdraid luks lvm_pv swap; }; partitionType = extraArgs: lib.mkOption { type = lib.types.nullOr (diskoLib.subType { - types = diskoLib.partitionTypes; + types = diskoLib._partitionTypes; inherit extraArgs; }); default = null; @@ -36,7 +36,7 @@ let _deviceTypes = { inherit (diskoLib.types) table gpt btrfs filesystem zfs mdraid luks lvm_pv swap; }; deviceType = extraArgs: lib.mkOption { type = lib.types.nullOr (diskoLib.subType { - types = diskoLib.deviceTypes; + types = diskoLib._deviceTypes; inherit extraArgs; }); default = null; @@ -666,9 +666,9 @@ let }; diskoLib = { optionTypes.absolute-pathname = "absolute-pathname"; - # Spoof these tyeps - deviceType = _: ""; - partitionType = _: ""; + # Spoof these types to avoid infinite recursion + deviceType = _: "deviceType"; + partitionType = _: "partitionType"; subType = { types, ... }: { type = "oneOf"; types = lib.attrNames types; From a0e8f7f587432aa9a22702acd286010fe468f148 Mon Sep 17 00:00:00 2001 From: Felix Uhl Date: Thu, 14 Nov 2024 22:48:26 +0100 Subject: [PATCH 32/37] disko2: Add code generator for disko config type There are still two issues: The type of "topology" in zpool is not created, and gpt_partitions_options_hybrid_options for some reason contains a `_create: "_create"` entry. This is an issue with the nix evaluation, though, not with the code generator. I'm fixing these issues manually to have some state I can start working from. --- flake.nix | 3 + scripts/generate_python_types.py | 274 +++++++++++++++++++++++++++++++ src/disko_lib/config_type.py | 257 +++++++++++++++++++++++++++++ src/disko_lib/default.nix | 9 + 4 files changed, 543 insertions(+) create mode 100755 scripts/generate_python_types.py create mode 100644 src/disko_lib/config_type.py diff --git a/flake.nix b/flake.nix index f7b62828..88937273 100644 --- a/flake.nix +++ b/flake.nix @@ -99,6 +99,9 @@ (python3.withPackages (ps: [ ps.mypy # Static type checker ps.pytest # Test runner + + # Actual runtime depedencies + ps.pydantic # Validation of nixos configuration ])) ]); }; diff --git a/scripts/generate_python_types.py b/scripts/generate_python_types.py new file mode 100755 index 00000000..aa2243e8 --- /dev/null +++ b/scripts/generate_python_types.py @@ -0,0 +1,274 @@ +#!/usr/bin/env python3 + +import io +import json +import sys +from typing import Any, Callable, Literal, Mapping, TypeGuard + + +def is_type( + type_field: dict[str, Any] +) -> TypeGuard[dict[Literal["default", "description", "type"], Any]]: + if not isinstance(type_field, dict): + return False + return set(type_field.keys()) == {"default", "description", "type"} + + +def parse_type( + containing_class: str, field_name: str, type_field: str | dict[str, Any] +) -> tuple[str, io.StringIO | None]: + """Parse a type field into a Python type annotation. + + If the type is a class itself, the second element of the tuple + will be a buffer containing the class definition. + """ + + if isinstance(type_field, str): + return _parse_simple_type(type_field), None + elif isinstance(type_field, dict): + if type_field.get("__isCompositeType"): + return _parse_composite_type(containing_class, field_name, type_field) + else: + class_name = f"{containing_class}_{field_name}" + class_code, inner_types_code = generate_class(class_name, type_field) + + if not inner_types_code: + inner_types_code = io.StringIO() + + inner_types_code.write("\n\n") + inner_types_code.write(class_code) + + return class_name, inner_types_code + + else: + raise ValueError(f"Invalid type field: {type_field}") + + +def _parse_composite_type( + containing_class: str, field_name: str, type_dict: dict[str, Any] +) -> tuple[str, io.StringIO | None]: + assert isinstance(type_dict["type"], str) + + type_name, type_code = None, None + if "subType" in type_dict: + try: + type_name, type_code = parse_type( + containing_class, field_name, type_dict["subType"] + ) + except Exception as e: + e.add_note(f"Error in subType {type_dict["subType"]}") + raise e + + match type_dict["type"]: + case "attrsOf": + return f"dict[str, {type_name}]", type_code + case "listOf": + return f"list[{type_name}]", type_code + case "nullOr": + return f"None | {type_name}", type_code + case "oneOf": + type_code = io.StringIO() + type_names = [] + for sub_type in type_dict["types"]: + try: + sub_type_name, sub_type_code = parse_type( + containing_class, field_name, sub_type + ) + except Exception as e: + e.add_note(f"Error in subType {sub_type}") + raise e + + type_names.append(sub_type_name) + if sub_type_code: + type_code.write(sub_type_code.getvalue()) + + # Can't use | syntax in all cases, Union always works + return f'Union[{", ".join(type_names)}]', type_code + case "enum": + return ( + f'Literal[{", ".join(f"{repr(value)}" for value in type_dict["choices"])}]', + None, + ) + case _: + return _parse_simple_type(type_dict["type"]), None + + +def _parse_simple_type(type_str: str) -> str: + match type_str: + case "str": + return "str" + case "absolute-pathname": + return "str" + case "bool": + return "bool" + case "int": + return "int" + case "anything": + return "Any" + case _: + # Probably a type alias, needs to be quoted in case the type is defined later + return f'"{type_str}"' + + +def parse_field( + containing_class: str, field_name: str, field: dict[str, Any] +) -> tuple[str, io.StringIO | None]: + if isinstance(field, str): + return _parse_simple_type(field), None + + if is_type(field): + return parse_type(containing_class, field_name, field["type"]) + + class_name = f"{containing_class}_{field_name}" + class_code, inner_types_code = generate_class(class_name, field) + + if not inner_types_code: + inner_types_code = io.StringIO() + + inner_types_code.write("\n\n") + inner_types_code.write(class_code) + + return class_name, inner_types_code + + +def generate_type_alias( + name: str, type_spec: str | dict[str, Any] +) -> io.StringIO | None: + buffer = io.StringIO() + + try: + type_code, sub_type_code = parse_type(name, "", type_spec) + except ValueError: + return None + + if sub_type_code: + buffer.write(sub_type_code.getvalue()) + buffer.write("\n\n") + + buffer.write(f"{name} = {type_code}") + buffer.write("\n\n") + + return buffer + + +def generate_class(name: str, fields: dict[str, Any]) -> tuple[str, io.StringIO | None]: + assert isinstance(fields, dict) + + contained_classes_buffer = io.StringIO() + + buffer = io.StringIO() + buffer.write(f"class {name}(BaseModel):\n") + + for field_name, field in fields.items(): + try: + type_name, type_code = parse_field(name, field_name, field) + except Exception as e: + e.add_note(f"Error in field {field_name}: {field}") + raise e + + if type_code: + contained_classes_buffer.write(type_code.getvalue()) + + buffer.write(f" {field_name}: {type_name}\n") + + if contained_classes_buffer.tell() == 0: + return buffer.getvalue(), None + + return buffer.getvalue(), contained_classes_buffer + + +def transform_dict_keys( + d: dict[str, Any], transform_fn: Callable[[str], str] +) -> dict[str, Any]: + if not isinstance(d, Mapping): + return d + + return {transform_fn(k): transform_dict_keys(v, transform_fn) for k, v in d.items()} + + +def generate_python_code(schema: dict[str, dict[str, Any]]) -> io.StringIO: + assert isinstance(schema, dict) + + # Convert disallowed characters in Python identifiers + schema = transform_dict_keys(schema, lambda k: k.replace("-", "_")) + + buffer = io.StringIO() + + buffer.write( + """ +from typing import Any, Literal, Union +from pydantic import BaseModel + + +""" + ) + + for type_name, fields in schema.items(): + if "__isCompositeType" in fields: + try: + alias_content, type_code = _parse_composite_type(type_name, "", fields) + except Exception as e: + e.add_note(f"Error in composite type {type_name}") + raise e + + if type_code: + buffer.write(type_code.getvalue()) + buffer.write("\n\n") + + buffer.write(f"{type_name} = {alias_content}") + buffer.write("\n\n") + continue + + try: + class_code, inner_types_code = generate_class(type_name, fields) + except Exception as e: + e.add_note(f"Error in class {type_name}") + raise e + + if inner_types_code: + buffer.write(inner_types_code.getvalue()) + buffer.write("\n\n") + + buffer.write(class_code) + buffer.write("\n\n") + + buffer.write( + """ +class DiskoConfig(BaseModel): + disk: dict[str, disk] + lvm_vg: dict[str, lvm_vg] + mdadm: dict[str, mdadm] + nodev: dict[str, nodev] + zpool: dict[str, zpool] +""" + ) + + return buffer + + +def main(in_file: str, out_file: str) -> None: + with open(in_file) as f: + schema = json.load(f) + + code_buffer = generate_python_code(schema) + + with open(out_file, "w") as f: + f.write(code_buffer.getvalue()) + + +if __name__ == "__main__": + if len(sys.argv) != 3: + print( + """Usage: generate_python_types.py +Recommendation: Go to the root of this repository and run + + nix build .#checks.x86_64-linux.jsonTypes + +to generate the JSON schema file first, then run + + ./scripts/generate_python_types.py result src/disko_lib/config_types.py +""" + ) + sys.exit(1) + + main(sys.argv[1], sys.argv[2]) diff --git a/src/disko_lib/config_type.py b/src/disko_lib/config_type.py new file mode 100644 index 00000000..09197b12 --- /dev/null +++ b/src/disko_lib/config_type.py @@ -0,0 +1,257 @@ + +from typing import Any, Literal, Union +from pydantic import BaseModel + + + + +class btrfs_subvolumes_options_swap_options(BaseModel): + options: list[str] + path: str + priority: None | int + size: str + + +class btrfs_subvolumes_options_swap(BaseModel): + options: btrfs_subvolumes_options_swap_options + + +class btrfs_subvolumes_options(BaseModel): + extraArgs: list[str] + mountOptions: list[str] + mountpoint: None | str + name: str + swap: dict[str, btrfs_subvolumes_options_swap] + type: Literal['btrfs_subvol'] + + +class btrfs_subvolumes(BaseModel): + options: btrfs_subvolumes_options + + +class btrfs_swap_options(BaseModel): + options: list[str] + path: str + priority: None | int + size: str + + +class btrfs_swap(BaseModel): + options: btrfs_swap_options + + +class btrfs(BaseModel): + device: str + extraArgs: list[str] + mountOptions: list[str] + mountpoint: None | str + subvolumes: dict[str, btrfs_subvolumes] + swap: dict[str, btrfs_swap] + type: Literal['btrfs'] + + + + +deviceType = Union["btrfs", "filesystem", "gpt", "luks", "lvm_pv", "mdraid", "swap", "table", "zfs"] + +class disk(BaseModel): + content: "deviceType" + device: str + imageName: str + imageSize: str + name: str + type: Literal['disk'] + + +class filesystem(BaseModel): + device: str + extraArgs: list[str] + format: str + mountOptions: list[str] + mountpoint: None | str + type: Literal['filesystem'] + + + + +class gpt_partitions_options_hybrid_options(BaseModel): + mbrBootableFlag: bool + mbrPartitionType: None | str + + +class gpt_partitions_options_hybrid(BaseModel): + options: gpt_partitions_options_hybrid_options + + +class gpt_partitions_options(BaseModel): + _index: int + alignment: int + content: "partitionType" + device: str + end: str + hybrid: None | gpt_partitions_options_hybrid + label: str + name: str + priority: int + size: Union[Literal['100%'], str] + start: str + type: Union[str, str] + + +class gpt_partitions(BaseModel): + options: gpt_partitions_options + + +class gpt(BaseModel): + device: str + efiGptPartitionFirst: bool + partitions: dict[str, gpt_partitions] + type: Literal['gpt'] + + +class luks(BaseModel): + additionalKeyFiles: list[str] + askPassword: bool + content: "deviceType" + device: str + extraFormatArgs: list[str] + extraOpenArgs: list[str] + initrdUnlock: bool + keyFile: None | str + name: str + passwordFile: None | str + settings: dict[str, Any] + type: Literal['luks'] + + +class lvm_pv(BaseModel): + device: str + type: Literal['lvm_pv'] + vg: str + + + + +class lvm_vg_lvs_options(BaseModel): + content: "partitionType" + extraArgs: list[str] + lvm_type: None | Literal['mirror', 'raid0', 'raid1', 'raid4', 'raid5', 'raid6', 'thin-pool', 'thinlv'] + name: str + pool: None | str + priority: int + size: str + + +class lvm_vg_lvs(BaseModel): + options: lvm_vg_lvs_options + + +class lvm_vg(BaseModel): + lvs: dict[str, lvm_vg_lvs] + name: str + type: Literal['lvm_vg'] + + +class mdadm(BaseModel): + content: "deviceType" + level: int + metadata: Literal['1', '1.0', '1.1', '1.2', 'default', 'ddf', 'imsm'] + name: str + type: Literal['mdadm'] + + +class mdraid(BaseModel): + device: str + name: str + type: Literal['mdraid'] + + +class nodev(BaseModel): + device: str + fsType: str + mountOptions: list[str] + mountpoint: None | str + type: Literal['nodev'] + + + + +partitionType = Union["btrfs", "filesystem", "luks", "lvm_pv", "mdraid", "swap", "zfs"] + +class swap(BaseModel): + device: str + discardPolicy: None | Literal['once', 'pages', 'both'] + extraArgs: list[str] + mountOptions: list[str] + priority: None | int + randomEncryption: bool + resumeDevice: bool + type: Literal['swap'] + + + + +class table_partitions_options(BaseModel): + _index: int + bootable: bool + content: "partitionType" + end: str + flags: list[str] + fs_type: None | Literal['btrfs', 'ext2', 'ext3', 'ext4', 'fat16', 'fat32', 'hfs', 'hfs+', 'linux-swap', 'ntfs', 'reiserfs', 'udf', 'xfs'] + name: None | str + part_type: Literal['primary', 'logical', 'extended'] + start: str + + +class table_partitions(BaseModel): + options: table_partitions_options + + +class table(BaseModel): + device: str + format: Literal['gpt', 'msdos'] + partitions: list[table_partitions] + type: Literal['table'] + + +class zfs(BaseModel): + device: str + pool: str + type: Literal['zfs'] + + +class zfs_fs(BaseModel): + mountOptions: list[str] + mountpoint: None | str + name: str + options: dict[str, str] + type: Literal['zfs_fs'] + + +class zfs_volume(BaseModel): + content: "partitionType" + mountOptions: list[str] + name: str + options: dict[str, str] + size: None | str + type: Literal['zfs_volume'] + + +class zpool(BaseModel): + datasets: dict[str, Union["zfs_fs", "zfs_volume"]] + mode: Union[Literal['', 'mirror', 'raidz', 'raidz1', 'raidz2', 'raidz3'], dict[str, Any]] + mountOptions: list[str] + mountpoint: None | str + name: str + options: dict[str, str] + rootFsOptions: dict[str, str] + type: Literal['zpool'] + + + +class DiskoConfig(BaseModel): + disk: dict[str, disk] + lvm_vg: dict[str, lvm_vg] + mdadm: dict[str, mdadm] + nodev: dict[str, nodev] + zpool: dict[str, zpool] diff --git a/src/disko_lib/default.nix b/src/disko_lib/default.nix index c7659b8f..779b2236 100644 --- a/src/disko_lib/default.nix +++ b/src/disko_lib/default.nix @@ -631,26 +631,32 @@ let attrsOf = subType: { type = "attrsOf"; inherit subType; + "__isCompositeType" = true; }; listOf = subType: { type = "listOf"; inherit subType; + "__isCompositeType" = true; }; nullOr = subType: { type = "nullOr"; inherit subType; + "__isCompositeType" = true; }; oneOf = types: { type = "oneOf"; inherit types; + "__isCompositeType" = true; }; either = t1: t2: { type = "oneOf"; types = [ t1 t2 ]; + "__isCompositeType" = true; }; enum = choices: { type = "enum"; inherit choices; + "__isCompositeType" = true; }; anything = "anything"; nonEmptyStr = "str"; @@ -672,6 +678,7 @@ let subType = { types, ... }: { type = "oneOf"; types = lib.attrNames types; + "__isCompositeType" = true; }; mkCreateOption = option: "_create"; }; @@ -689,10 +696,12 @@ let partitionType = { type = "oneOf"; types = lib.attrNames diskoLib._partitionTypes; + "__isCompositeType" = true; }; deviceType = { type = "oneOf"; types = lib.attrNames diskoLib._deviceTypes; + "__isCompositeType" = true; }; }; }; From ae7891e21f062ef3dff75cf309df3968c3af73c4 Mon Sep 17 00:00:00 2001 From: Felix Uhl Date: Sun, 17 Nov 2024 18:14:59 +0100 Subject: [PATCH 33/37] disko2: Fix all mypy type checking errors Belongs to d382a3cccf4547ec61503db46ff7a316bebda581, but I authored that on another machine. Making sure vscode uses mypy from the environment is very important now, because some of these errors get triggered in different ways depending on the version. --- .vscode/settings.json | 3 +- pyproject.toml | 2 +- scripts/generate_python_types.py | 38 ++++++++--- src/disko/cli.py | 14 ++-- src/disko/mode_dev.py | 10 +-- src/disko/mode_generate.py | 4 +- src/disko_lib/ansi.py | 16 +++-- src/disko_lib/config_type.py | 6 +- src/disko_lib/dict_diff.py | 17 +++-- src/disko_lib/eval_config.py | 25 +++---- src/disko_lib/json_types.py | 2 + src/disko_lib/logging.py | 15 +++-- src/disko_lib/messages/bugs.py | 3 +- src/disko_lib/messages/msgs.py | 6 +- src/disko_lib/result.py | 17 +++-- src/disko_lib/types/device.py | 67 ++++++++++--------- src/disko_lib/types/disk.py | 39 +++++------ src/disko_lib/types/filesystem.py | 4 +- src/disko_lib/types/gpt.py | 18 +++-- .../disko_lib/eval_config/test_eval_config.py | 10 +-- tests/disko_lib/test_dict_diff.py | 13 ++-- tests/disko_lib/types_disk/test_types_disk.py | 6 +- 22 files changed, 190 insertions(+), 145 deletions(-) create mode 100644 src/disko_lib/json_types.py diff --git a/.vscode/settings.json b/.vscode/settings.json index 7f3f6630..af62cb7c 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -17,5 +17,6 @@ ], "python.analysis.extraPaths": [ "./src" - ] + ], + "mypy-type-checker.importStrategy": "fromEnvironment" } \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 86f4fe62..9d74b8f3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,9 +42,9 @@ warn_return_any = true warn_unreachable = true # Miscellaneous strictness flags no_implicit_reexport = true -strict_concatenate = true strict_equality = true extra_checks = true +enable_error_code = "ignore-without-code" # Error message control show_error_context = true show_error_code_links = true diff --git a/scripts/generate_python_types.py b/scripts/generate_python_types.py index aa2243e8..6c356121 100755 --- a/scripts/generate_python_types.py +++ b/scripts/generate_python_types.py @@ -3,12 +3,19 @@ import io import json import sys -from typing import Any, Callable, Literal, Mapping, TypeGuard +from typing import Any, Callable, Mapping, TypeGuard, TypeVar, TypedDict, cast +JsonDict = dict[str, "JsonValue"] +JsonValue = str | int | float | bool | None | list["JsonValue"] | JsonDict -def is_type( - type_field: dict[str, Any] -) -> TypeGuard[dict[Literal["default", "description", "type"], Any]]: + +class TypeDefinition(TypedDict): + type: str | JsonDict + default: JsonValue + description: str + + +def is_type(type_field: JsonValue) -> TypeGuard[TypeDefinition]: if not isinstance(type_field, dict): return False return set(type_field.keys()) == {"default", "description", "type"} @@ -111,7 +118,7 @@ def _parse_simple_type(type_str: str) -> str: def parse_field( - containing_class: str, field_name: str, field: dict[str, Any] + containing_class: str, field_name: str, field: str | JsonDict ) -> tuple[str, io.StringIO | None]: if isinstance(field, str): return _parse_simple_type(field), None @@ -177,16 +184,20 @@ def generate_class(name: str, fields: dict[str, Any]) -> tuple[str, io.StringIO return buffer.getvalue(), contained_classes_buffer -def transform_dict_keys( - d: dict[str, Any], transform_fn: Callable[[str], str] -) -> dict[str, Any]: +T = TypeVar("T", bound=JsonValue) + + +def transform_dict_keys(d: T, transform_fn: Callable[[str], str]) -> T: if not isinstance(d, Mapping): return d - return {transform_fn(k): transform_dict_keys(v, transform_fn) for k, v in d.items()} + return cast( + T, + {transform_fn(k): transform_dict_keys(v, transform_fn) for k, v in d.items()}, + ) -def generate_python_code(schema: dict[str, dict[str, Any]]) -> io.StringIO: +def generate_python_code(schema: JsonDict) -> io.StringIO: assert isinstance(schema, dict) # Convert disallowed characters in Python identifiers @@ -195,7 +206,11 @@ def generate_python_code(schema: dict[str, dict[str, Any]]) -> io.StringIO: buffer = io.StringIO() buffer.write( - """ + """# File generated by scripts/generate_python_types.py +# Ignore warnings that decorators contain Any +# mypy: disable-error-code="misc" +# Disable auto-formatting for this file +# fmt: off from typing import Any, Literal, Union from pydantic import BaseModel @@ -204,6 +219,7 @@ def generate_python_code(schema: dict[str, dict[str, Any]]) -> io.StringIO: ) for type_name, fields in schema.items(): + assert isinstance(fields, dict) if "__isCompositeType" in fields: try: alias_content, type_code = _parse_composite_type(type_name, "", fields) diff --git a/src/disko/cli.py b/src/disko/cli.py index 1ec6791f..fb76ba73 100644 --- a/src/disko/cli.py +++ b/src/disko/cli.py @@ -2,7 +2,7 @@ import argparse import json -from typing import Any, Literal +from typing import Any, Literal, cast from disko.mode_dev import run_dev from disko.mode_generate import run_generate @@ -10,7 +10,7 @@ from disko_lib.logging import LOGGER, debug, info from disko_lib.messages.msgs import err_missing_mode from disko_lib.result import DiskoError, DiskoResult, exit_on_error -from disko_lib.types.disk import generate_config +from disko_lib.json_types import JsonDict Mode = Literal[ "destroy", @@ -46,18 +46,18 @@ def run_apply( *, mode: str, disko_file: str | None, flake: str | None, **_kwargs: dict[str, Any] -) -> DiskoResult[dict[str, Any]]: +) -> DiskoResult[JsonDict]: return eval_config(disko_file=disko_file, flake=flake) def run( args: argparse.Namespace, -) -> DiskoResult[None | dict[str, Any]]: - if args.verbose: +) -> DiskoResult[None | JsonDict]: + if cast(bool, args.verbose): LOGGER.setLevel("DEBUG") debug("Enabled debug logging.") - match args.mode: + match cast(Mode | None, args.mode): case None: return DiskoError.single_message( err_missing_mode, "select mode", valid_modes=[str(m) for m in ALL_MODES] @@ -67,7 +67,7 @@ def run( case "dev": return run_dev(args) case _: - return run_apply(**vars(args)) + return run_apply(**vars(args)) # type: ignore[misc] def parse_args() -> argparse.Namespace: diff --git a/src/disko/mode_dev.py b/src/disko/mode_dev.py index f8b05854..e8bf4fa4 100644 --- a/src/disko/mode_dev.py +++ b/src/disko/mode_dev.py @@ -1,6 +1,6 @@ import argparse import json -from typing import Any +from typing import Any, cast from disko_lib.ansi import Colors from disko_lib.eval_config import eval_config @@ -22,8 +22,8 @@ def run_dev_ansi() -> DiskoResult[None]: import inspect for name, value in inspect.getmembers(Colors): - if value != "_" and not name.startswith("_") and name != "RESET": - print("{:>30} {}".format(name, value + name + Colors.RESET)) + if value != "_" and not name.startswith("_") and name != "RESET": # type: ignore[misc] + print("{:>30} {}".format(name, value + name + Colors.RESET)) # type: ignore[misc] return DiskoSuccess(None, "run disko dev ansi") @@ -41,13 +41,13 @@ def run_dev_eval( def run_dev(args: argparse.Namespace) -> DiskoResult[None]: - match args.dev_command: + match cast(str | None, args.dev_command): case "lsblk": return run_dev_lsblk() case "ansi": return run_dev_ansi() case "eval": - return run_dev_eval(**vars(args)) + return run_dev_eval(**vars(args)) # type: ignore[misc] case _: return DiskoError.single_message( err_missing_mode, "select mode", valid_modes=["lsblk", "ansi", "eval"] diff --git a/src/disko/mode_generate.py b/src/disko/mode_generate.py index 30ef4852..9f971d01 100644 --- a/src/disko/mode_generate.py +++ b/src/disko/mode_generate.py @@ -1,11 +1,11 @@ import json import re -from typing import Any from disko_lib.logging import info from disko_lib.messages.msgs import warn_generate_partial_failure from disko_lib.result import DiskoResult, DiskoError from disko_lib.types.disk import generate_config from disko_lib.run_cmd import run +from disko_lib.json_types import JsonDict DEFAULT_CONFIG_FILE = "disko-config.nix" @@ -25,7 +25,7 @@ """ -def run_generate() -> DiskoResult[dict[str, Any]]: +def run_generate() -> DiskoResult[JsonDict]: generated_config_result = generate_config() generated_config = None diff --git a/src/disko_lib/ansi.py b/src/disko_lib/ansi.py index f39448da..7d0c5774 100644 --- a/src/disko_lib/ansi.py +++ b/src/disko_lib/ansi.py @@ -3,6 +3,8 @@ # Inspired by rene-d's colors.py, published in 2018 # See https://gist.github.com/rene-d/9e584a7dd2935d0f461904b9f2950007 +import sys + class Colors: """ @@ -163,13 +165,17 @@ class Colors: ATTR_STRIKE = "\033[9m" # cancel SGR codes if we don't write to a terminal - if not __import__("sys").stdout.isatty(): + if not sys.stdout.isatty(): for _ in dir(): if isinstance(_, str) and _[0] != "_": - locals()[_] = "" + locals()[_] = "" # type: ignore[misc] else: + import platform + # set Windows console in VT mode - if __import__("platform").system() == "Windows": - kernel32 = __import__("ctypes").windll.kernel32 - kernel32.SetConsoleMode(kernel32.GetStdHandle(-11), 7) + if platform.system() == "Windows": + import ctypes + + kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined, misc] + kernel32.SetConsoleMode(kernel32.GetStdHandle(-11), 7) # type: ignore[misc] del kernel32 diff --git a/src/disko_lib/config_type.py b/src/disko_lib/config_type.py index 09197b12..c8e80dd1 100644 --- a/src/disko_lib/config_type.py +++ b/src/disko_lib/config_type.py @@ -1,4 +1,8 @@ - +# File generated by scripts/generate_python_types.py +# Ignore warnings that decorators contain Any +# mypy: disable-error-code="misc" +# Disable auto-formatting for this file +# fmt: off from typing import Any, Literal, Union from pydantic import BaseModel diff --git a/src/disko_lib/dict_diff.py b/src/disko_lib/dict_diff.py index 5bd4e488..ac90dfee 100644 --- a/src/disko_lib/dict_diff.py +++ b/src/disko_lib/dict_diff.py @@ -1,7 +1,7 @@ -from typing import Any +from .json_types import JsonDict -def dict_diff(left: dict[str, Any], right: dict[str, Any]) -> dict[str, Any]: +def dict_diff(left: JsonDict, right: JsonDict) -> JsonDict: """Return a dict that only contains the keys and values of `right` that are different from those in `left`. @@ -24,7 +24,7 @@ def dict_diff(left: dict[str, Any], right: dict[str, Any]) -> dict[str, Any]: >>> dict_diff({"a": {"b": 1}}, {"a": {"b": 3}, "c": {"d": 4}}) {'a': {'b': 3}, 'c': {'d': 4, '_new': True}} """ - new_dict: dict[str, Any] = {} + new_dict: JsonDict = {} for k, right_val in right.items(): left_val = left.get(k) @@ -32,12 +32,17 @@ def dict_diff(left: dict[str, Any], right: dict[str, Any]) -> dict[str, Any]: continue if not isinstance(right_val, dict): - new_dict[k] = right[k] + new_dict[k] = right_val continue - new_dict[k] = dict_diff(left.get(k, {}), right[k]) + if not isinstance(left_val, dict): + left_val = {} + + diffed_right_val = dict_diff(left_val, right_val) if not left_val: - new_dict[k]["_new"] = True + diffed_right_val["_new"] = True + + new_dict[k] = diffed_right_val for k, left_val in left.items(): if k not in right: diff --git a/src/disko_lib/eval_config.py b/src/disko_lib/eval_config.py index 1251f3c6..6eed241a 100644 --- a/src/disko_lib/eval_config.py +++ b/src/disko_lib/eval_config.py @@ -1,7 +1,9 @@ import json from pathlib import Path import re -from typing import Any +from typing import cast + +from .json_types import JsonDict from disko_lib.messages.msgs import ( err_eval_config_failed, @@ -30,7 +32,7 @@ ), f"Can't find `eval-config.nix`, expected it next to {__file__}" -def _eval_config(args: dict[str, str]) -> DiskoResult[dict[str, Any]]: +def _eval_config(args: dict[str, str]) -> DiskoResult[JsonDict]: args_as_json = json.dumps(args) result = run( @@ -43,14 +45,16 @@ def _eval_config(args: dict[str, str]) -> DiskoResult[dict[str, Any]]: err_eval_config_failed, "evaluate disko configuration", args=args, - stderr=result.messages[0].details["stderr"], + stderr=cast(str, result.messages[0].details["stderr"]), ) # We trust the output of `nix eval` to be valid JSON - return DiskoSuccess(json.loads(result.value), "evaluate disko config") + return DiskoSuccess( + cast(JsonDict, json.loads(result.value)), "evaluate disko config" + ) -def _eval_disko_file(config_file: Path) -> DiskoResult[dict[str, Any]]: +def _eval_disko_file(config_file: Path) -> DiskoResult[JsonDict]: abs_path = config_file.absolute() if not abs_path.exists(): @@ -63,7 +67,7 @@ def _eval_disko_file(config_file: Path) -> DiskoResult[dict[str, Any]]: return _eval_config({"diskoFile": str(abs_path)}) -def _eval_flake(flake_uri: str) -> DiskoResult[dict[str, Any]]: +def _eval_flake(flake_uri: str) -> DiskoResult[JsonDict]: # arg parser should not allow empty strings assert len(flake_uri) > 0 @@ -71,9 +75,8 @@ def _eval_flake(flake_uri: str) -> DiskoResult[dict[str, Any]]: # Match can't be none if we receive at least one character assert flake_match is not None - - flake = flake_match.group(1) - flake_attr = flake_match.group(2) + flake = cast(str, flake_match.group(1)) + flake_attr = cast(str, flake_match.group(2)) if not flake_attr: return DiskoError.single_message( @@ -87,9 +90,7 @@ def _eval_flake(flake_uri: str) -> DiskoResult[dict[str, Any]]: return _eval_config({"flake": flake, "flakeAttr": flake_attr}) -def eval_config( - *, disko_file: str | None, flake: str | None -) -> DiskoResult[dict[str, Any]]: +def eval_config(*, disko_file: str | None, flake: str | None) -> DiskoResult[JsonDict]: # match would be nicer, but mypy doesn't understand type narrowing in tuples if not disko_file and not flake: return DiskoError.single_message(err_missing_arguments, "validate args") diff --git a/src/disko_lib/json_types.py b/src/disko_lib/json_types.py new file mode 100644 index 00000000..0e62f532 --- /dev/null +++ b/src/disko_lib/json_types.py @@ -0,0 +1,2 @@ +JsonDict = dict[str, "JsonValue"] +JsonValue = str | int | float | bool | None | list["JsonValue"] | JsonDict diff --git a/src/disko_lib/logging.py b/src/disko_lib/logging.py index 9f3e8ef7..93c694e5 100644 --- a/src/disko_lib/logging.py +++ b/src/disko_lib/logging.py @@ -9,6 +9,7 @@ Literal, ParamSpec, TypeAlias, + cast, ) from .ansi import Colors @@ -68,16 +69,16 @@ class ReadableMessage: class DiskoMessage(Generic[P]): factory: MessageFactory[P] # Can't infer a TypedDict from a ParamSpec yet (mypy 1.10.1, python 3.12.5) - # This is only safe to use because the type of __init__ ensures that the - # keys in details are the same as the keys in the factory kwargs - details: dict[str, Any] + details: dict[str, object] def __init__(self, factory: MessageFactory[P], **details: P.kwargs) -> None: self.factory = factory self.details = details def to_readable(self) -> list[ReadableMessage]: - result = self.factory(**self.details) + # This is only safe because the type of __init__ ensures that the + # keys in details are the same as the keys in the factory kwargs + result = self.factory(**self.details) # type: ignore[arg-type] if isinstance(result, list): return result return [result] @@ -86,6 +87,9 @@ def print(self) -> None: for msg in self.to_readable(): render_message(msg) + def is_message(self, factory: MessageFactory[Any]) -> bool: + return self.factory == factory # type: ignore[misc] + # Dedent lines based on the indent of the first line until a non-indented line is hit. # This will dedent the lines written in multiline f-strigns without breaking @@ -93,8 +97,7 @@ def print(self) -> None: def dedent_start_lines(lines: list[str]) -> list[str]: spaces_prefix_match = re.match(r"^( *)", lines[0]) # Regex will even match an empty string, match can't be none - assert spaces_prefix_match is not None - dedent_width = len(spaces_prefix_match.group(1)) + dedent_width = len(spaces_prefix_match.group(1)) # type: ignore[union-attr, misc] if dedent_width == 0: return lines diff --git a/src/disko_lib/messages/bugs.py b/src/disko_lib/messages/bugs.py index 982810eb..67d8cf34 100644 --- a/src/disko_lib/messages/bugs.py +++ b/src/disko_lib/messages/bugs.py @@ -1,4 +1,3 @@ -from typing import Any from disko_lib.logging import ReadableMessage @@ -16,7 +15,7 @@ def __bug_help_message(error_code: str) -> ReadableMessage: ) -def bug_success_without_context(*, value: Any) -> list[ReadableMessage]: +def bug_success_without_context(*, value: object) -> list[ReadableMessage]: return [ ReadableMessage( "bug", diff --git a/src/disko_lib/messages/msgs.py b/src/disko_lib/messages/msgs.py index 4bebac03..39518e84 100644 --- a/src/disko_lib/messages/msgs.py +++ b/src/disko_lib/messages/msgs.py @@ -1,8 +1,8 @@ import json from pathlib import Path -from typing import Any from disko_lib.logging import ReadableMessage from .colors import PLACEHOLDER, RESET, FLAG, COMMAND, INVALID, FILE, VALUE, EM, EM_WARN +from ..json_types import JsonDict ERR_ARGUMENTS_HELP_TXT = f"Provide either {PLACEHOLDER}disko_file{RESET} as the second argument or \ {FLAG}--flake{RESET}/{FLAG}-f{RESET} {PLACEHOLDER}flake-uri{RESET}." @@ -19,7 +19,7 @@ def err_command_failed(*, command: str, exit_code: int, stderr: str) -> Readable ) -def err_eval_config_failed(*, args: dict[str, Any], stderr: str) -> ReadableMessage: +def err_eval_config_failed(*, args: dict[str, str], stderr: str) -> ReadableMessage: return ReadableMessage( "error", f""" @@ -83,7 +83,7 @@ def err_unsupported_pttype(*, device: Path, pttype: str) -> ReadableMessage: def warn_generate_partial_failure( *, - partial_config: dict[Any, str], + partial_config: JsonDict, failed_devices: list[str], successful_devices: list[str], ) -> list[ReadableMessage]: diff --git a/src/disko_lib/result.py b/src/disko_lib/result.py index d647e9d9..ba76be05 100644 --- a/src/disko_lib/result.py +++ b/src/disko_lib/result.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import Any, Generic, Literal, ParamSpec, TypeVar +from typing import Any, Generic, Literal, ParamSpec, TypeVar, cast from disko_lib.messages.bugs import bug_success_without_context @@ -18,7 +18,7 @@ class DiskoSuccess(Generic[T]): @dataclass class DiskoError: - messages: list[DiskoMessage[Any]] + messages: list[DiskoMessage[object]] context: str success: Literal[False] = False @@ -26,18 +26,25 @@ class DiskoError: def single_message( cls, factory: MessageFactory[P], context: str, *_: P.args, **details: P.kwargs ) -> "DiskoError": - return cls([DiskoMessage(factory, **details)], context) + _factory = cast(MessageFactory[object], factory) + return cls([DiskoMessage(_factory, **details)], context) def find_message( self, message_factory: MessageFactory[P] ) -> None | DiskoMessage[P]: for message in self.messages: if message.factory == message_factory: - return message + return cast(DiskoMessage[P], message) return None + def append(self, message: DiskoMessage[Any]) -> None: + self.messages.append(message) # type: ignore[misc] -DiskoResult = DiskoSuccess[T] | DiskoError + def extend(self, other_error: "DiskoError") -> None: + self.messages.extend(other_error.messages) + + +DiskoResult = DiskoSuccess[T] | DiskoError # type: ignore[misc, unused-ignore] def exit_on_error(result: DiskoResult[T]) -> T: diff --git a/src/disko_lib/types/device.py b/src/disko_lib/types/device.py index d36b819c..08c91c30 100644 --- a/src/disko_lib/types/device.py +++ b/src/disko_lib/types/device.py @@ -1,10 +1,12 @@ from dataclasses import dataclass import json from pathlib import Path -from typing import Any +from typing import Any, cast +import typing from ..result import DiskoError, DiskoResult, DiskoSuccess from ..run_cmd import run +from ..json_types import JsonDict # To see what other fields are available in the lsblk output and what # sort of values you can expect from them, run: @@ -68,14 +70,16 @@ class BlockDevice: children: list["BlockDevice"] @classmethod - def from_json_dict(cls, json_dict: dict[str, Any]) -> "BlockDevice": - children = [ - cls.from_json_dict(child_dict) - for child_dict in json_dict.get("children", []) - ] + def from_json_dict(cls, json_dict: JsonDict) -> "BlockDevice": + children_list = json_dict.get("children", []) + assert isinstance(children_list, list) + children = [] + for child_dict in children_list: + assert isinstance(child_dict, dict) + children.append(cls.from_json_dict(child_dict)) # The mountpoints field will be a list containing a single null if there are no mountpoints - mountpoints = json_dict["mountpoints"] or [] + mountpoints = cast(list[str], json_dict["mountpoints"]) or [] if not any(mountpoints): mountpoints = [] @@ -83,30 +87,30 @@ def from_json_dict(cls, json_dict: dict[str, Any]) -> "BlockDevice": # but some might be null. Set a default value for the fields we have observed to be optional. return cls( children=children, - id_link=json_dict["id-link"], - fstype=json_dict["fstype"] or "", - fssize=json_dict["fssize"] or "", - fsuse_pct=json_dict["fsuse%"] or "", - kname=json_dict["kname"], - label=json_dict["label"] or "", - model=json_dict["model"] or "", - partflags=json_dict["partflags"] or "", - partlabel=json_dict["partlabel"] or "", - partn=json_dict["partn"], - parttype=json_dict["parttype"] or "", - parttypename=json_dict["parttypename"] or "", - partuuid=json_dict["partuuid"] or "", - path=Path(json_dict["path"]), - phy_sec=json_dict["phy-sec"], - pttype=json_dict["pttype"], - rev=json_dict["rev"] or "", - serial=json_dict["serial"] or "", - size=json_dict["size"], - start=json_dict["start"] or "", - mountpoint=json_dict["mountpoint"] or "", + id_link=cast(str, json_dict["id-link"]), + fstype=cast(str, json_dict["fstype"]) or "", + fssize=cast(str, json_dict["fssize"]) or "", + fsuse_pct=cast(str, json_dict["fsuse%"]) or "", + kname=cast(str, json_dict["kname"]), + label=cast(str, json_dict["label"]) or "", + model=cast(str, json_dict["model"]) or "", + partflags=cast(str, json_dict["partflags"]) or "", + partlabel=cast(str, json_dict["partlabel"]) or "", + partn=cast(int, json_dict["partn"]), + parttype=cast(str, json_dict["parttype"]) or "", + parttypename=cast(str, json_dict["parttypename"]) or "", + partuuid=cast(str, json_dict["partuuid"]) or "", + path=Path(cast(str, json_dict["path"])), + phy_sec=cast(int, json_dict["phy-sec"]), + pttype=cast(str, json_dict["pttype"]), + rev=cast(str, json_dict["rev"]) or "", + serial=cast(str, json_dict["serial"]) or "", + size=cast(str, json_dict["size"]), + start=cast(str, json_dict["start"]) or "", + mountpoint=cast(str, json_dict["mountpoint"]) or "", mountpoints=mountpoints, - type=json_dict["type"], - uuid=json_dict["uuid"] or "", + type=cast(str, json_dict["type"]), + uuid=cast(str, json_dict["uuid"]) or "", ) @@ -124,7 +128,8 @@ def list_block_devices(lsblk_output: str = "") -> DiskoResult[list[BlockDevice]] lsblk_output = lsblk_result.value # We trust the output of `lsblk` to be valid JSON - lsblk_json: list[dict[str, Any]] = json.loads(lsblk_output)["blockdevices"] + output: JsonDict = json.loads(lsblk_output) + lsblk_json: list[JsonDict] = output["blockdevices"] # type: ignore[assignment] blockdevices = [BlockDevice.from_json_dict(dev) for dev in lsblk_json] diff --git a/src/disko_lib/types/disk.py b/src/disko_lib/types/disk.py index ea604f14..4dec06a9 100644 --- a/src/disko_lib/types/disk.py +++ b/src/disko_lib/types/disk.py @@ -1,5 +1,4 @@ -from typing import Any - +from typing import cast from disko_lib.messages.msgs import ( err_unsupported_pttype, warn_generate_partial_failure, @@ -8,10 +7,11 @@ from ..logging import DiskoMessage, debug from ..result import DiskoError, DiskoResult, DiskoSuccess from ..types.device import BlockDevice, list_block_devices +from ..json_types import JsonDict from . import gpt -def _generate_content(device: BlockDevice) -> DiskoResult[dict[str, Any]]: +def _generate_content(device: BlockDevice) -> DiskoResult[JsonDict]: match device.pttype: case "gpt": return gpt.generate_config(device) @@ -24,7 +24,7 @@ def _generate_content(device: BlockDevice) -> DiskoResult[dict[str, Any]]: ) -def generate_config(devices: list[BlockDevice] = []) -> DiskoResult[dict[str, Any]]: +def generate_config(devices: list[BlockDevice] = []) -> DiskoResult[JsonDict]: block_devices = devices if not block_devices: lsblk_result = list_block_devices() @@ -34,13 +34,10 @@ def generate_config(devices: list[BlockDevice] = []) -> DiskoResult[dict[str, An block_devices = lsblk_result.value - if isinstance(block_devices, DiskoError): - return block_devices - debug(f"Generating config for devices {[d.path for d in block_devices]}") - disks = {} - error_messages = [] + disks: JsonDict = {} + error = DiskoError([], "generate disk config") failed_devices = [] successful_devices = [] @@ -48,7 +45,7 @@ def generate_config(devices: list[BlockDevice] = []) -> DiskoResult[dict[str, An content = _generate_content(device) if isinstance(content, DiskoError): - error_messages.extend(content.messages) + error.extend(content) failed_devices.append(device.path) continue @@ -59,21 +56,19 @@ def generate_config(devices: list[BlockDevice] = []) -> DiskoResult[dict[str, An } successful_devices.append(device.path) - if not failed_devices: - return DiskoSuccess({"disks": disks}, "generate disk config") + config: JsonDict = {"disks": disks} - if not successful_devices: - return DiskoError(error_messages, "generate disk config") + if not failed_devices: + return DiskoSuccess(config, "generate disk config") - return DiskoError( - error_messages - + [ + if successful_devices: + error.append( DiskoMessage( warn_generate_partial_failure, - partial_config={"disks": disks}, + partial_config=config, failed_devices=failed_devices, successful_devices=successful_devices, - ) - ], - "generate disk config", - ) + ), + ) + + return error diff --git a/src/disko_lib/types/filesystem.py b/src/disko_lib/types/filesystem.py index 2fb080df..5888f1b5 100644 --- a/src/disko_lib/types/filesystem.py +++ b/src/disko_lib/types/filesystem.py @@ -1,9 +1,9 @@ -from typing import Any from .device import BlockDevice from ..result import DiskoResult, DiskoSuccess +from ..json_types import JsonDict -def generate_config(device: BlockDevice) -> DiskoResult[dict[str, Any]]: +def generate_config(device: BlockDevice) -> DiskoResult[JsonDict]: assert ( device.type == "part" ), f"BUG! filesystem.generate_config called with non-partition device {device.path}" diff --git a/src/disko_lib/types/gpt.py b/src/disko_lib/types/gpt.py index eafac997..10bbde28 100644 --- a/src/disko_lib/types/gpt.py +++ b/src/disko_lib/types/gpt.py @@ -1,13 +1,11 @@ -from typing import Any from ..logging import debug from . import filesystem from .device import BlockDevice from ..result import DiskoError, DiskoResult, DiskoSuccess +from ..json_types import JsonDict -def _add_type_if_required( - device: BlockDevice, part_config: dict[str, Any] -) -> dict[str, Any]: +def _add_type_if_required(device: BlockDevice, part_config: JsonDict) -> JsonDict: type = { "c12a7328-f81f-11d2-ba4b-00a0c93ec93b": "EF00", # EFI System "21686148-6449-6e6f-744e-656564454649": "EF02", # BIOS boot @@ -26,22 +24,22 @@ def _generate_name(device: BlockDevice) -> str: return f"PARTUUID:{device.partuuid}" -def _generate_content(device: BlockDevice) -> DiskoResult[dict[str, Any]]: +def _generate_content(device: BlockDevice) -> DiskoResult[JsonDict]: match device.fstype: # TODO: Add filesystems that are not supported by `mkfs` here case _: return filesystem.generate_config(device) -def generate_config(device: BlockDevice) -> DiskoResult[dict[str, Any]]: +def generate_config(device: BlockDevice) -> DiskoResult[JsonDict]: assert ( device.pttype == "gpt" ), f"BUG! gpt.generate_config called with non-gpt device {device.path}" debug(f"Generating GPT config for device {device.path}") - partitions = {} - error_messages = [] + partitions: JsonDict = {} + error = DiskoError([], "generate gpt config") failed_partitions = [] successful_partitions = [] @@ -49,7 +47,7 @@ def generate_config(device: BlockDevice) -> DiskoResult[dict[str, Any]]: content = _generate_content(partition) if isinstance(content, DiskoError): - error_messages.extend(content.messages) + error.extend(content) failed_partitions.append(partition.path) continue @@ -61,4 +59,4 @@ def generate_config(device: BlockDevice) -> DiskoResult[dict[str, Any]]: if not failed_partitions: return DiskoSuccess({"partitions": partitions}, "generate gpt config") - return DiskoError(error_messages, "generate gpt config") + return error diff --git a/tests/disko_lib/eval_config/test_eval_config.py b/tests/disko_lib/eval_config/test_eval_config.py index 249439a1..4b176888 100644 --- a/tests/disko_lib/eval_config/test_eval_config.py +++ b/tests/disko_lib/eval_config/test_eval_config.py @@ -1,9 +1,11 @@ import json from pathlib import Path +from typing import cast from disko_lib.eval_config import eval_config from disko_lib.messages.msgs import err_missing_arguments, err_too_many_arguments from disko_lib.result import DiskoError, DiskoSuccess +from disko_lib.json_types import JsonDict CURRENT_DIR = Path(__file__).parent ROOT_DIR = CURRENT_DIR.parent.parent.parent @@ -13,14 +15,14 @@ def test_eval_config_missing_arguments() -> None: result = eval_config(disko_file=None, flake=None) assert isinstance(result, DiskoError) - assert result.messages[0].factory == err_missing_arguments + assert result.messages[0].is_message(err_missing_arguments) assert result.context == "validate args" def test_eval_config_too_many_arguments() -> None: result = eval_config(disko_file="foo", flake="bar") assert isinstance(result, DiskoError) - assert result.messages[0].factory == err_too_many_arguments + assert result.messages[0].is_message(err_too_many_arguments) assert result.context == "validate args" @@ -29,7 +31,7 @@ def test_eval_config_disk_file() -> None: result = eval_config(disko_file=str(disko_file_path), flake=None) assert isinstance(result, DiskoSuccess) with open(CURRENT_DIR / "file-simple-efi-result.json") as f: - expected_result = json.load(f) + expected_result = cast(JsonDict, json.load(f)) assert result.value == expected_result @@ -37,5 +39,5 @@ def test_eval_config_flake_testmachine() -> None: result = eval_config(disko_file=None, flake=f"{ROOT_DIR}#testmachine") assert isinstance(result, DiskoSuccess) with open(CURRENT_DIR / "flake-testmachine-result.json") as f: - expected_result = json.load(f) + expected_result = cast(JsonDict, json.load(f)) assert result.value == expected_result diff --git a/tests/disko_lib/test_dict_diff.py b/tests/disko_lib/test_dict_diff.py index 7cf0aef0..c1cb59a0 100644 --- a/tests/disko_lib/test_dict_diff.py +++ b/tests/disko_lib/test_dict_diff.py @@ -1,14 +1,15 @@ from disko_lib.dict_diff import dict_diff +from disko_lib.json_types import JsonDict def test_dict_diff_basic() -> None: - left = { + left: JsonDict = { "a": 1, "b": 2, "c": 3, "d": 4, } - right = { + right: JsonDict = { "a": 1, "b": 3, "c": 4, @@ -39,12 +40,12 @@ def test_dict_diff_basic() -> None: def test_dict_diff_arrays() -> None: - left = { + left: JsonDict = { "a": [1, 2, 3], "b": [4, 5, 6], "c": [7, 8, 9], } - right = { + right: JsonDict = { "a": [1, 2, 3], "b": [4, 5, 7], "c": [7, 8, 9], @@ -69,7 +70,7 @@ def test_dict_diff_arrays() -> None: def test_dict_diff_nested() -> None: - left = { + left: JsonDict = { "a": { "b": { "c": 1, @@ -87,7 +88,7 @@ def test_dict_diff_nested() -> None: }, }, } - right = { + right: JsonDict = { "a": { "b": { "c": 1, diff --git a/tests/disko_lib/types_disk/test_types_disk.py b/tests/disko_lib/types_disk/test_types_disk.py index 47ac6bb8..c6cc45e0 100644 --- a/tests/disko_lib/types_disk/test_types_disk.py +++ b/tests/disko_lib/types_disk/test_types_disk.py @@ -19,15 +19,15 @@ def test_generate_config_partial_failure_dos_table() -> None: assert isinstance(result, DiskoError) - assert result.messages[0].factory == err_unsupported_pttype + assert result.messages[0].is_message(err_unsupported_pttype) assert result.messages[0].details == { "pttype": "dos", "device": PosixPath("/dev/sdc"), } - assert result.messages[1].factory == warn_generate_partial_failure + assert result.messages[1].is_message(warn_generate_partial_failure) with open(CURRENT_DIR / "partial_failure_dos_table-generate-result.json") as f: - assert result.messages[1].details["partial_config"] == json.load(f) + assert result.messages[1].details["partial_config"] == json.load(f) # type: ignore[misc] assert result.messages[1].details["failed_devices"] == [PosixPath("/dev/sdc")] assert result.messages[1].details["successful_devices"] == [ PosixPath("/dev/sda"), From 6651792c282dcc1ba310225ff895017ceff16eb7 Mon Sep 17 00:00:00 2001 From: Felix Uhl Date: Sun, 17 Nov 2024 19:45:50 +0100 Subject: [PATCH 34/37] disko2: Validate config for type-safety in python --- scripts/generate_python_types.py | 7 +- src/disko/cli.py | 14 +++- src/disko/mode_dev.py | 22 +++++- src/disko_lib/config_type.py | 66 +++++----------- src/disko_lib/default.nix | 22 ++---- src/disko_lib/eval_config.py | 32 +++++++- src/disko_lib/logging.py | 1 - src/disko_lib/messages/bugs.py | 35 ++++++++ src/disko_lib/types/device.py | 3 +- src/disko_lib/types/disk.py | 1 - ....json => file-simple-efi-eval-result.json} | 0 .../file-simple-efi-validate-result.json | 66 ++++++++++++++++ ...son => flake-testmachine-eval-result.json} | 0 .../flake-testmachine-validate-result.json | 79 +++++++++++++++++++ .../disko_lib/eval_config/test_eval_config.py | 34 ++++++-- 15 files changed, 299 insertions(+), 83 deletions(-) rename tests/disko_lib/eval_config/{file-simple-efi-result.json => file-simple-efi-eval-result.json} (100%) create mode 100644 tests/disko_lib/eval_config/file-simple-efi-validate-result.json rename tests/disko_lib/eval_config/{flake-testmachine-result.json => flake-testmachine-eval-result.json} (100%) create mode 100644 tests/disko_lib/eval_config/flake-testmachine-validate-result.json diff --git a/scripts/generate_python_types.py b/scripts/generate_python_types.py index 6c356121..d962c567 100755 --- a/scripts/generate_python_types.py +++ b/scripts/generate_python_types.py @@ -112,6 +112,11 @@ def _parse_simple_type(type_str: str) -> str: return "int" case "anything": return "Any" + # Set up discriminated unions to reduce error messages when validation fails + case "deviceType": + return '"deviceType" = Field(..., discriminator="type")' + case "partitionType": + return '"partitionType" = Field(..., discriminator="type")' case _: # Probably a type alias, needs to be quoted in case the type is defined later return f'"{type_str}"' @@ -212,7 +217,7 @@ def generate_python_code(schema: JsonDict) -> io.StringIO: # Disable auto-formatting for this file # fmt: off from typing import Any, Literal, Union -from pydantic import BaseModel +from pydantic import BaseModel, Field """ diff --git a/src/disko/cli.py b/src/disko/cli.py index fb76ba73..1e17ab2b 100644 --- a/src/disko/cli.py +++ b/src/disko/cli.py @@ -6,7 +6,8 @@ from disko.mode_dev import run_dev from disko.mode_generate import run_generate -from disko_lib.eval_config import eval_config +from disko_lib.config_type import DiskoConfig +from disko_lib.eval_config import eval_and_validate_config from disko_lib.logging import LOGGER, debug, info from disko_lib.messages.msgs import err_missing_mode from disko_lib.result import DiskoError, DiskoResult, exit_on_error @@ -46,13 +47,13 @@ def run_apply( *, mode: str, disko_file: str | None, flake: str | None, **_kwargs: dict[str, Any] -) -> DiskoResult[JsonDict]: - return eval_config(disko_file=disko_file, flake=flake) +) -> DiskoResult[DiskoConfig]: + return eval_and_validate_config(disko_file=disko_file, flake=flake) def run( args: argparse.Namespace, -) -> DiskoResult[None | JsonDict]: +) -> DiskoResult[None | JsonDict | DiskoConfig]: if cast(bool, args.verbose): LOGGER.setLevel("DEBUG") debug("Enabled debug logging.") @@ -129,6 +130,11 @@ def add_common_apply_args(parser: argparse.ArgumentParser) -> None: "eval", help="Evaluate a disko configuration and print the result as JSON" ) add_common_apply_args(dev_eval_parser) + dev_validate_parser = dev_parsers.add_parser( + "validate", + help="Validate a disko configuration file or flake", + ) + add_common_apply_args(dev_validate_parser) return root_parser.parse_args() diff --git a/src/disko/mode_dev.py b/src/disko/mode_dev.py index e8bf4fa4..35f7f7f1 100644 --- a/src/disko/mode_dev.py +++ b/src/disko/mode_dev.py @@ -3,7 +3,7 @@ from typing import Any, cast from disko_lib.ansi import Colors -from disko_lib.eval_config import eval_config +from disko_lib.eval_config import eval_and_validate_config, eval_config_as_json from disko_lib.messages.msgs import err_missing_mode from disko_lib.result import DiskoError, DiskoSuccess, DiskoResult from disko_lib.types.device import run_lsblk @@ -31,7 +31,7 @@ def run_dev_ansi() -> DiskoResult[None]: def run_dev_eval( *, disko_file: str | None, flake: str | None, **_: Any ) -> DiskoResult[None]: - result = eval_config(disko_file=disko_file, flake=flake) + result = eval_config_as_json(disko_file=disko_file, flake=flake) if isinstance(result, DiskoError): return result @@ -40,6 +40,18 @@ def run_dev_eval( return DiskoSuccess(None, "run disko dev eval") +def run_dev_validate( + *, disko_file: str | None, flake: str | None, **_: Any +) -> DiskoResult[None]: + result = eval_and_validate_config(disko_file=disko_file, flake=flake) + + if isinstance(result, DiskoError): + return result + + print(result.value.model_dump_json(indent=2)) + return DiskoSuccess(None, "run disko dev validate") + + def run_dev(args: argparse.Namespace) -> DiskoResult[None]: match cast(str | None, args.dev_command): case "lsblk": @@ -48,7 +60,11 @@ def run_dev(args: argparse.Namespace) -> DiskoResult[None]: return run_dev_ansi() case "eval": return run_dev_eval(**vars(args)) # type: ignore[misc] + case "validate": + return run_dev_validate(**vars(args)) # type: ignore[misc] case _: return DiskoError.single_message( - err_missing_mode, "select mode", valid_modes=["lsblk", "ansi", "eval"] + err_missing_mode, + "select mode", + valid_modes=["lsblk", "ansi", "eval", "validate"], ) diff --git a/src/disko_lib/config_type.py b/src/disko_lib/config_type.py index c8e80dd1..4daa2960 100644 --- a/src/disko_lib/config_type.py +++ b/src/disko_lib/config_type.py @@ -4,46 +4,34 @@ # Disable auto-formatting for this file # fmt: off from typing import Any, Literal, Union -from pydantic import BaseModel +from pydantic import BaseModel, Field -class btrfs_subvolumes_options_swap_options(BaseModel): +class btrfs_subvolumes_swap(BaseModel): options: list[str] path: str priority: None | int size: str -class btrfs_subvolumes_options_swap(BaseModel): - options: btrfs_subvolumes_options_swap_options - - -class btrfs_subvolumes_options(BaseModel): +class btrfs_subvolumes(BaseModel): extraArgs: list[str] mountOptions: list[str] mountpoint: None | str name: str - swap: dict[str, btrfs_subvolumes_options_swap] + swap: dict[str, btrfs_subvolumes_swap] type: Literal['btrfs_subvol'] -class btrfs_subvolumes(BaseModel): - options: btrfs_subvolumes_options - - -class btrfs_swap_options(BaseModel): +class btrfs_swap(BaseModel): options: list[str] path: str priority: None | int size: str -class btrfs_swap(BaseModel): - options: btrfs_swap_options - - class btrfs(BaseModel): device: str extraArgs: list[str] @@ -56,10 +44,10 @@ class btrfs(BaseModel): -deviceType = Union["btrfs", "filesystem", "gpt", "luks", "lvm_pv", "mdraid", "swap", "table", "zfs"] +deviceType = None | Union["btrfs", "filesystem", "gpt", "luks", "lvm_pv", "mdraid", "swap", "table", "zfs"] class disk(BaseModel): - content: "deviceType" + content: "deviceType" = Field(..., discriminator="type") device: str imageName: str imageSize: str @@ -78,22 +66,18 @@ class filesystem(BaseModel): -class gpt_partitions_options_hybrid_options(BaseModel): +class gpt_partitions_hybrid(BaseModel): mbrBootableFlag: bool mbrPartitionType: None | str -class gpt_partitions_options_hybrid(BaseModel): - options: gpt_partitions_options_hybrid_options - - -class gpt_partitions_options(BaseModel): +class gpt_partitions(BaseModel): _index: int alignment: int - content: "partitionType" + content: "partitionType" = Field(..., discriminator="type") device: str end: str - hybrid: None | gpt_partitions_options_hybrid + hybrid: None | gpt_partitions_hybrid label: str name: str priority: int @@ -102,10 +86,6 @@ class gpt_partitions_options(BaseModel): type: Union[str, str] -class gpt_partitions(BaseModel): - options: gpt_partitions_options - - class gpt(BaseModel): device: str efiGptPartitionFirst: bool @@ -116,7 +96,7 @@ class gpt(BaseModel): class luks(BaseModel): additionalKeyFiles: list[str] askPassword: bool - content: "deviceType" + content: "deviceType" = Field(..., discriminator="type") device: str extraFormatArgs: list[str] extraOpenArgs: list[str] @@ -136,8 +116,8 @@ class lvm_pv(BaseModel): -class lvm_vg_lvs_options(BaseModel): - content: "partitionType" +class lvm_vg_lvs(BaseModel): + content: "partitionType" = Field(..., discriminator="type") extraArgs: list[str] lvm_type: None | Literal['mirror', 'raid0', 'raid1', 'raid4', 'raid5', 'raid6', 'thin-pool', 'thinlv'] name: str @@ -146,10 +126,6 @@ class lvm_vg_lvs_options(BaseModel): size: str -class lvm_vg_lvs(BaseModel): - options: lvm_vg_lvs_options - - class lvm_vg(BaseModel): lvs: dict[str, lvm_vg_lvs] name: str @@ -157,7 +133,7 @@ class lvm_vg(BaseModel): class mdadm(BaseModel): - content: "deviceType" + content: "deviceType" = Field(..., discriminator="type") level: int metadata: Literal['1', '1.0', '1.1', '1.2', 'default', 'ddf', 'imsm'] name: str @@ -180,7 +156,7 @@ class nodev(BaseModel): -partitionType = Union["btrfs", "filesystem", "luks", "lvm_pv", "mdraid", "swap", "zfs"] +partitionType = None | Union["btrfs", "filesystem", "luks", "lvm_pv", "mdraid", "swap", "zfs"] class swap(BaseModel): device: str @@ -195,10 +171,10 @@ class swap(BaseModel): -class table_partitions_options(BaseModel): +class table_partitions(BaseModel): _index: int bootable: bool - content: "partitionType" + content: "partitionType" = Field(..., discriminator="type") end: str flags: list[str] fs_type: None | Literal['btrfs', 'ext2', 'ext3', 'ext4', 'fat16', 'fat32', 'hfs', 'hfs+', 'linux-swap', 'ntfs', 'reiserfs', 'udf', 'xfs'] @@ -207,10 +183,6 @@ class table_partitions_options(BaseModel): start: str -class table_partitions(BaseModel): - options: table_partitions_options - - class table(BaseModel): device: str format: Literal['gpt', 'msdos'] @@ -233,7 +205,7 @@ class zfs_fs(BaseModel): class zfs_volume(BaseModel): - content: "partitionType" + content: "partitionType" = Field(..., discriminator="type") mountOptions: list[str] name: str options: dict[str, str] diff --git a/src/disko_lib/default.nix b/src/disko_lib/default.nix index 779b2236..87fcba43 100644 --- a/src/disko_lib/default.nix +++ b/src/disko_lib/default.nix @@ -664,10 +664,10 @@ let str = "str"; bool = "bool"; int = "int"; - submodule = x: x { + submodule = x: (x { inherit (diskoLib.typesSerializerLib) lib config options; name = ""; - }; + }).options; }; }; diskoLib = { @@ -692,18 +692,12 @@ let (diskoLib.serializeType (import ./types/${file} diskoLib.typesSerializerLib)) ) (lib.filter (name: lib.hasSuffix ".nix" name) (lib.attrNames (builtins.readDir ./types))) - ) // { - partitionType = { - type = "oneOf"; - types = lib.attrNames diskoLib._partitionTypes; - "__isCompositeType" = true; - }; - deviceType = { - type = "oneOf"; - types = lib.attrNames diskoLib._deviceTypes; - "__isCompositeType" = true; - }; - }; + ) // ( + let types = diskoLib.typesSerializerLib.lib.types; in { + partitionType = types.nullOr (types.oneOf (lib.attrNames diskoLib._partitionTypes)); + deviceType = types.nullOr (types.oneOf (lib.attrNames diskoLib._deviceTypes)); + } + ); }; outputs = diff --git a/src/disko_lib/eval_config.py b/src/disko_lib/eval_config.py index 6eed241a..8e041f2c 100644 --- a/src/disko_lib/eval_config.py +++ b/src/disko_lib/eval_config.py @@ -1,7 +1,12 @@ import json from pathlib import Path import re -from typing import cast +from typing import Any, cast + +from pydantic import ValidationError + +from disko_lib.config_type import DiskoConfig +from disko_lib.messages.bugs import bug_validate_config_failed from .json_types import JsonDict @@ -90,7 +95,9 @@ def _eval_flake(flake_uri: str) -> DiskoResult[JsonDict]: return _eval_config({"flake": flake, "flakeAttr": flake_attr}) -def eval_config(*, disko_file: str | None, flake: str | None) -> DiskoResult[JsonDict]: +def eval_config_as_json( + *, disko_file: str | None, flake: str | None +) -> DiskoResult[JsonDict]: # match would be nicer, but mypy doesn't understand type narrowing in tuples if not disko_file and not flake: return DiskoError.single_message(err_missing_arguments, "validate args") @@ -100,3 +107,24 @@ def eval_config(*, disko_file: str | None, flake: str | None) -> DiskoResult[Jso return _eval_disko_file(Path(disko_file)) return DiskoError.single_message(err_too_many_arguments, "validate args") + + +def eval_and_validate_config( + *, disko_file: str | None, flake: str | None +) -> DiskoResult[DiskoConfig]: + json_config = eval_config_as_json(disko_file=disko_file, flake=flake) + + if isinstance(json_config, DiskoError): + return json_config + + try: + result = DiskoConfig(**cast(dict[str, Any], json_config.value)) # type: ignore[misc] + except ValidationError as e: + return DiskoError.single_message( + bug_validate_config_failed, + "validate disko config", + error=e, + config=json_config.value, + ) + + return DiskoSuccess(result, "validate disko config") diff --git a/src/disko_lib/logging.py b/src/disko_lib/logging.py index 93c694e5..ac25477a 100644 --- a/src/disko_lib/logging.py +++ b/src/disko_lib/logging.py @@ -9,7 +9,6 @@ Literal, ParamSpec, TypeAlias, - cast, ) from .ansi import Colors diff --git a/src/disko_lib/messages/bugs.py b/src/disko_lib/messages/bugs.py index 67d8cf34..1d8d6f86 100644 --- a/src/disko_lib/messages/bugs.py +++ b/src/disko_lib/messages/bugs.py @@ -1,4 +1,8 @@ +import json +import pydantic from disko_lib.logging import ReadableMessage +from disko_lib.messages.colors import INVALID, RESET, VALUE +from ..json_types import JsonDict def __bug_help_message(error_code: str) -> ReadableMessage: @@ -27,3 +31,34 @@ def bug_success_without_context(*, value: object) -> list[ReadableMessage]: ), __bug_help_message("bug_success_without_context"), ] + + +def bug_validate_config_failed( + *, config: JsonDict, error: pydantic.ValidationError +) -> list[ReadableMessage]: + errors_printed = json.dumps(error.errors(), indent=2) # type: ignore[misc] + return [ + ReadableMessage( + "info", + f""" + Evaluated configuration: + {json.dumps(config, indent=2)} + """, + ), + ReadableMessage( + "error", + f""" + Validation errors: + {errors_printed} + """, + ), + ReadableMessage( + "bug", + f""" + Configuration was evaluated successfully, but failed validation! + Most likely, the types in python are out-of-sync with those in nix. + The {INVALID}validation errors{RESET} and the {VALUE}evaluated configuration{RESET} are printed above. + """, + ), + __bug_help_message("bug_validate_config_failed"), + ] diff --git a/src/disko_lib/types/device.py b/src/disko_lib/types/device.py index 08c91c30..1d44fd37 100644 --- a/src/disko_lib/types/device.py +++ b/src/disko_lib/types/device.py @@ -1,8 +1,7 @@ from dataclasses import dataclass import json from pathlib import Path -from typing import Any, cast -import typing +from typing import cast from ..result import DiskoError, DiskoResult, DiskoSuccess from ..run_cmd import run diff --git a/src/disko_lib/types/disk.py b/src/disko_lib/types/disk.py index 4dec06a9..ab886aa5 100644 --- a/src/disko_lib/types/disk.py +++ b/src/disko_lib/types/disk.py @@ -1,4 +1,3 @@ -from typing import cast from disko_lib.messages.msgs import ( err_unsupported_pttype, warn_generate_partial_failure, diff --git a/tests/disko_lib/eval_config/file-simple-efi-result.json b/tests/disko_lib/eval_config/file-simple-efi-eval-result.json similarity index 100% rename from tests/disko_lib/eval_config/file-simple-efi-result.json rename to tests/disko_lib/eval_config/file-simple-efi-eval-result.json diff --git a/tests/disko_lib/eval_config/file-simple-efi-validate-result.json b/tests/disko_lib/eval_config/file-simple-efi-validate-result.json new file mode 100644 index 00000000..2a652845 --- /dev/null +++ b/tests/disko_lib/eval_config/file-simple-efi-validate-result.json @@ -0,0 +1,66 @@ +{ + "disk": { + "main": { + "content": { + "device": "/dev/disk/by-id/some-disk-id", + "efiGptPartitionFirst": true, + "partitions": { + "ESP": { + "alignment": 0, + "content": { + "device": "/dev/disk/by-partlabel/disk-main-ESP", + "extraArgs": [], + "format": "vfat", + "mountOptions": [ + "umask=0077" + ], + "mountpoint": "/boot", + "type": "filesystem" + }, + "device": "/dev/disk/by-partlabel/disk-main-ESP", + "end": "+500M", + "hybrid": null, + "label": "disk-main-ESP", + "name": "ESP", + "priority": 1000, + "size": "500M", + "start": "0", + "type": "EF00" + }, + "root": { + "alignment": 0, + "content": { + "device": "/dev/disk/by-partlabel/disk-main-root", + "extraArgs": [], + "format": "ext4", + "mountOptions": [ + "defaults" + ], + "mountpoint": "/", + "type": "filesystem" + }, + "device": "/dev/disk/by-partlabel/disk-main-root", + "end": "-0", + "hybrid": null, + "label": "disk-main-root", + "name": "root", + "priority": 9001, + "size": "100%", + "start": "0", + "type": "8300" + } + }, + "type": "gpt" + }, + "device": "/dev/disk/by-id/some-disk-id", + "imageName": "main", + "imageSize": "2G", + "name": "main", + "type": "disk" + } + }, + "lvm_vg": {}, + "mdadm": {}, + "nodev": {}, + "zpool": {} +} diff --git a/tests/disko_lib/eval_config/flake-testmachine-result.json b/tests/disko_lib/eval_config/flake-testmachine-eval-result.json similarity index 100% rename from tests/disko_lib/eval_config/flake-testmachine-result.json rename to tests/disko_lib/eval_config/flake-testmachine-eval-result.json diff --git a/tests/disko_lib/eval_config/flake-testmachine-validate-result.json b/tests/disko_lib/eval_config/flake-testmachine-validate-result.json new file mode 100644 index 00000000..02535487 --- /dev/null +++ b/tests/disko_lib/eval_config/flake-testmachine-validate-result.json @@ -0,0 +1,79 @@ +{ + "disk": { + "main": { + "content": { + "device": "/dev/disk/by-id/ata-Samsung_SSD_850_EVO_250GB_S21PNXAGB12345", + "efiGptPartitionFirst": true, + "partitions": { + "ESP": { + "alignment": 0, + "content": { + "device": "/dev/disk/by-partlabel/disk-main-ESP", + "extraArgs": [], + "format": "vfat", + "mountOptions": [ + "umask=0077" + ], + "mountpoint": "/boot", + "type": "filesystem" + }, + "device": "/dev/disk/by-partlabel/disk-main-ESP", + "end": "+512M", + "hybrid": null, + "label": "disk-main-ESP", + "name": "ESP", + "priority": 1000, + "size": "512M", + "start": "0", + "type": "EF00" + }, + "boot": { + "alignment": 0, + "content": null, + "device": "/dev/disk/by-partlabel/disk-main-boot", + "end": "+1M", + "hybrid": null, + "label": "disk-main-boot", + "name": "boot", + "priority": 100, + "size": "1M", + "start": "0", + "type": "EF02" + }, + "root": { + "alignment": 0, + "content": { + "device": "/dev/disk/by-partlabel/disk-main-root", + "extraArgs": [], + "format": "ext4", + "mountOptions": [ + "defaults" + ], + "mountpoint": "/", + "type": "filesystem" + }, + "device": "/dev/disk/by-partlabel/disk-main-root", + "end": "-0", + "hybrid": null, + "label": "disk-main-root", + "name": "root", + "priority": 9001, + "size": "100%", + "start": "0", + "type": "8300" + } + }, + "type": "gpt" + }, + "device": "/dev/disk/by-id/ata-Samsung_SSD_850_EVO_250GB_S21PNXAGB12345", + "imageName": "main", + "imageSize": "2G", + "name": "main", + "type": "disk" + } + }, + "lvm_vg": {}, + "mdadm": {}, + "nodev": {}, + "zpool": {} +} diff --git a/tests/disko_lib/eval_config/test_eval_config.py b/tests/disko_lib/eval_config/test_eval_config.py index 4b176888..20521489 100644 --- a/tests/disko_lib/eval_config/test_eval_config.py +++ b/tests/disko_lib/eval_config/test_eval_config.py @@ -1,8 +1,9 @@ import json from pathlib import Path +import readline from typing import cast -from disko_lib.eval_config import eval_config +from disko_lib.eval_config import eval_config_as_json, eval_and_validate_config from disko_lib.messages.msgs import err_missing_arguments, err_too_many_arguments from disko_lib.result import DiskoError, DiskoSuccess from disko_lib.json_types import JsonDict @@ -13,31 +14,48 @@ def test_eval_config_missing_arguments() -> None: - result = eval_config(disko_file=None, flake=None) + result = eval_config_as_json(disko_file=None, flake=None) assert isinstance(result, DiskoError) assert result.messages[0].is_message(err_missing_arguments) assert result.context == "validate args" def test_eval_config_too_many_arguments() -> None: - result = eval_config(disko_file="foo", flake="bar") + result = eval_config_as_json(disko_file="foo", flake="bar") assert isinstance(result, DiskoError) assert result.messages[0].is_message(err_too_many_arguments) assert result.context == "validate args" -def test_eval_config_disk_file() -> None: +def test_eval_config_disko_file() -> None: disko_file_path = ROOT_DIR / "example" / "simple-efi.nix" - result = eval_config(disko_file=str(disko_file_path), flake=None) + result = eval_config_as_json(disko_file=str(disko_file_path), flake=None) assert isinstance(result, DiskoSuccess) - with open(CURRENT_DIR / "file-simple-efi-result.json") as f: + with open(CURRENT_DIR / "file-simple-efi-eval-result.json") as f: expected_result = cast(JsonDict, json.load(f)) assert result.value == expected_result def test_eval_config_flake_testmachine() -> None: - result = eval_config(disko_file=None, flake=f"{ROOT_DIR}#testmachine") + result = eval_config_as_json(disko_file=None, flake=f"{ROOT_DIR}#testmachine") assert isinstance(result, DiskoSuccess) - with open(CURRENT_DIR / "flake-testmachine-result.json") as f: + with open(CURRENT_DIR / "flake-testmachine-eval-result.json") as f: expected_result = cast(JsonDict, json.load(f)) assert result.value == expected_result + + +def test_eval_and_validate_config_disko_file() -> None: + disko_file_path = ROOT_DIR / "example" / "simple-efi.nix" + result = eval_and_validate_config(disko_file=str(disko_file_path), flake=None) + assert isinstance(result, DiskoSuccess) + with open(CURRENT_DIR / "file-simple-efi-validate-result.json") as f: + expected_result = f.read() + assert json.loads(result.value.model_dump_json()) == json.loads(expected_result) # type: ignore[misc] + + +def test_eval_and_validate_flake_testmachine() -> None: + result = eval_and_validate_config(disko_file=None, flake=f"{ROOT_DIR}#testmachine") + assert isinstance(result, DiskoSuccess) + with open(CURRENT_DIR / "flake-testmachine-validate-result.json") as f: + expected_result = f.read() + assert json.loads(result.value.model_dump_json()) == json.loads(expected_result) # type: ignore[misc] From cfcc518b70daf0e891d40904cb24f6069badc126 Mon Sep 17 00:00:00 2001 From: Felix Uhl Date: Mon, 25 Nov 2024 23:53:19 +0100 Subject: [PATCH 35/37] disko2: Add rudimentary plan generation --- .vscode/launch.json | 111 ++++---- .vscode/settings.json | 7 +- scripts/generate_python_types.py | 10 +- src/disko/cli.py | 100 +++++++- src/disko/mode_dev.py | 17 +- src/disko/mode_generate.py | 44 ++-- src/disko_lib/action.py | 44 ++++ src/disko_lib/config_type.py | 4 +- src/disko_lib/dict_diff.py | 53 ---- src/disko_lib/eval-config.nix | 7 +- src/disko_lib/eval_config.py | 15 +- src/disko_lib/generate_config.py | 58 +++++ src/disko_lib/generate_plan.py | 11 + src/disko_lib/messages/bugs.py | 34 ++- src/disko_lib/messages/msgs.py | 106 +++++++- src/disko_lib/result.py | 31 ++- src/disko_lib/types/device.py | 19 +- src/disko_lib/types/disk.py | 163 ++++++++++-- src/disko_lib/types/filesystem.py | 59 +++++ src/disko_lib/types/gpt.py | 199 +++++++++++++- src/disko_lib/utils.py | 23 ++ .../file-simple-efi-validate-result.json | 4 +- .../flake-testmachine-validate-result.json | 5 +- .../disko_lib/eval_config/test_eval_config.py | 29 ++- tests/disko_lib/test_dict_diff.py | 168 ------------ ...ial_failure_dos_table-generate-result.json | 217 +++++++++------- ...artial_failure_dos_table-lsblk-output.json | 242 +++++++++--------- tests/disko_lib/types_disk/test_types_disk.py | 18 +- 28 files changed, 1190 insertions(+), 608 deletions(-) create mode 100644 src/disko_lib/action.py delete mode 100644 src/disko_lib/dict_diff.py create mode 100644 src/disko_lib/generate_config.py create mode 100644 src/disko_lib/generate_plan.py create mode 100644 src/disko_lib/utils.py delete mode 100644 tests/disko_lib/test_dict_diff.py diff --git a/.vscode/launch.json b/.vscode/launch.json index 3b37f7d7..559ae5da 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,50 +1,65 @@ { - // Use IntelliSense to learn about possible attributes. - // Hover to view descriptions of existing attributes. - // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 - "version": "0.2.0", - "configurations": [ - { - "name": "disko2 mount disko_file", - "type": "debugpy", - "request": "launch", - "module": "disko", - "env": { - "PYTHONPATH": "${workspaceFolder}/src" - }, - "console": "integratedTerminal", - "args": [ - "mount", - "example/simple-efi.nix" - ] - }, - { - "name": "disko2 mount flake", - "type": "debugpy", - "request": "launch", - "module": "disko", - "env": { - "PYTHONPATH": "${workspaceFolder}/src" - }, - "console": "integratedTerminal", - "args": [ - "mount", - "--flake", - ".#testmachine" - ] - }, - { - "name": "disko2 generate", - "type": "debugpy", - "request": "launch", - "module": "disko", - "env": { - "PYTHONPATH": "${workspaceFolder}/src" - }, - "console": "integratedTerminal", - "args": [ - "generate", - ] - } - ] + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "disko2 mount disko_file", + "type": "debugpy", + "request": "launch", + "module": "disko", + "env": { + "PYTHONPATH": "${workspaceFolder}/src" + }, + "console": "integratedTerminal", + "args": [ + "mount", + "example/simple-efi.nix" + ] + }, + { + "name": "disko2 mount flake", + "type": "debugpy", + "request": "launch", + "module": "disko", + "env": { + "PYTHONPATH": "${workspaceFolder}/src" + }, + "console": "integratedTerminal", + "args": [ + "mount", + "--flake", + ".#testmachine" + ] + }, + { + "name": "disko2 generate", + "type": "debugpy", + "request": "launch", + "module": "disko", + "env": { + "PYTHONPATH": "${workspaceFolder}/src" + }, + "console": "integratedTerminal", + "args": [ + "generate" + ] + }, + { + "name": "disko2 destroy,format,mount dry-run", + "type": "debugpy", + "request": "launch", + "module": "disko", + "env": { + "PYTHONPATH": "${workspaceFolder}/src" + }, + "console": "integratedTerminal", + "args": [ + "destroy,format,mount", + "--dry-run", + "disko-config.nix" + ] + } + ] } \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index af62cb7c..c1bf8bb3 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -18,5 +18,10 @@ "python.analysis.extraPaths": [ "./src" ], - "mypy-type-checker.importStrategy": "fromEnvironment" + "mypy-type-checker.importStrategy": "fromEnvironment", + "python.testing.pytestArgs": [ + "tests" + ], + "python.testing.unittestEnabled": false, + "python.testing.pytestEnabled": true } \ No newline at end of file diff --git a/scripts/generate_python_types.py b/scripts/generate_python_types.py index d962c567..1580aeff 100755 --- a/scripts/generate_python_types.py +++ b/scripts/generate_python_types.py @@ -181,7 +181,15 @@ def generate_class(name: str, fields: dict[str, Any]) -> tuple[str, io.StringIO if type_code: contained_classes_buffer.write(type_code.getvalue()) - buffer.write(f" {field_name}: {type_name}\n") + # Fields starting with _ are swallowed, see https://github.com/pydantic/pydantic/issues/2105 + if field_name.startswith("_"): + field_definition = ( + f'{field_name.lstrip("_")}: {type_name} = Field(alias="{field_name}")' + ) + else: + field_definition = f"{field_name}: {type_name}" + + buffer.write(f" {field_definition}\n") if contained_classes_buffer.tell() == 0: return buffer.getvalue(), None diff --git a/src/disko/cli.py b/src/disko/cli.py index 1e17ab2b..320c9101 100644 --- a/src/disko/cli.py +++ b/src/disko/cli.py @@ -1,27 +1,49 @@ #!/usr/bin/env python3 import argparse +import dataclasses import json from typing import Any, Literal, cast from disko.mode_dev import run_dev from disko.mode_generate import run_generate +from disko_lib.action import Action from disko_lib.config_type import DiskoConfig -from disko_lib.eval_config import eval_and_validate_config +from disko_lib.eval_config import ( + eval_config_dict_as_json, + eval_config_file_as_json, + validate_config, +) +from disko_lib.generate_config import generate_config +from disko_lib.generate_plan import generate_plan from disko_lib.logging import LOGGER, debug, info from disko_lib.messages.msgs import err_missing_mode -from disko_lib.result import DiskoError, DiskoResult, exit_on_error +from disko_lib.result import ( + DiskoError, + DiskoPartialSuccess, + DiskoResult, + DiskoSuccess, + exit_on_error, +) from disko_lib.json_types import JsonDict -Mode = Literal[ - "destroy", - "format", - "mount", - "destroy,format,mount", - "format,mount", - "generate", - "dev", -] +Mode = ( + Action + | Literal[ + "destroy,format,mount", + "format,mount", + "generate", + "dev", + ] +) + +MODE_TO_ACTIONS: dict[Mode, set[Action]] = { + "destroy": {"destroy"}, + "format": {"format"}, + "mount": {"mount"}, + "destroy,format,mount": {"destroy", "format", "mount"}, + "format,mount": {"format", "mount"}, +} # Modes to apply an existing configuration @@ -46,9 +68,52 @@ def run_apply( - *, mode: str, disko_file: str | None, flake: str | None, **_kwargs: dict[str, Any] -) -> DiskoResult[DiskoConfig]: - return eval_and_validate_config(disko_file=disko_file, flake=flake) + *, + mode: Mode, + disko_file: str | None, + flake: str | None, + dry_run: bool, + **_kwargs: dict[str, Any], +) -> DiskoResult[JsonDict]: + assert mode in APPLY_MODES + + target_config_json = eval_config_file_as_json(disko_file=disko_file, flake=flake) + if isinstance(target_config_json, DiskoError): + return target_config_json + + target_config = validate_config(target_config_json.value) + if isinstance(target_config, DiskoError): + return target_config.with_context("validate evaluated config") + + current_status_dict = generate_config() + if isinstance(current_status_dict, DiskoError) and not isinstance( + current_status_dict, DiskoPartialSuccess + ): + return current_status_dict.with_context("generate current status") + + current_status_evaluated = eval_config_dict_as_json(current_status_dict.value) + if isinstance(current_status_evaluated, DiskoError): + return current_status_evaluated.with_context("eval current status") + + current_status = validate_config(current_status_evaluated.value) + if isinstance(current_status, DiskoError): + return current_status.with_context("validate current status") + + actions = MODE_TO_ACTIONS[mode] + + plan = generate_plan(actions, current_status.value, target_config.value) + if isinstance(plan, DiskoError): + return plan + + plan_as_dict: JsonDict = dataclasses.asdict(plan.value) + steps = {"steps": plan_as_dict.get("steps", [])} + + if dry_run: + return DiskoSuccess(steps, "generate plan") + + info("Plan execution is not implemented yet!") + + return DiskoSuccess(steps, "generate plan") def run( @@ -106,6 +171,13 @@ def add_common_apply_args(parser: argparse.ArgumentParser) -> None: "-f", help="Flake to fetch the disko configuration from", ) + parser.add_argument( + "--dry-run", + "-n", + action="store_true", + default=False, + help="Print the plan without executing it", + ) # Commands to apply an existing configuration apply_parsers = [create_apply_parser(mode) for mode in APPLY_MODES] diff --git a/src/disko/mode_dev.py b/src/disko/mode_dev.py index 35f7f7f1..a7c91ee4 100644 --- a/src/disko/mode_dev.py +++ b/src/disko/mode_dev.py @@ -3,7 +3,7 @@ from typing import Any, cast from disko_lib.ansi import Colors -from disko_lib.eval_config import eval_and_validate_config, eval_config_as_json +from disko_lib.eval_config import eval_config_file_as_json, validate_config from disko_lib.messages.msgs import err_missing_mode from disko_lib.result import DiskoError, DiskoSuccess, DiskoResult from disko_lib.types.device import run_lsblk @@ -31,7 +31,7 @@ def run_dev_ansi() -> DiskoResult[None]: def run_dev_eval( *, disko_file: str | None, flake: str | None, **_: Any ) -> DiskoResult[None]: - result = eval_config_as_json(disko_file=disko_file, flake=flake) + result = eval_config_file_as_json(disko_file=disko_file, flake=flake) if isinstance(result, DiskoError): return result @@ -43,12 +43,17 @@ def run_dev_eval( def run_dev_validate( *, disko_file: str | None, flake: str | None, **_: Any ) -> DiskoResult[None]: - result = eval_and_validate_config(disko_file=disko_file, flake=flake) + eval_result = eval_config_file_as_json(disko_file=disko_file, flake=flake) + if isinstance(eval_result, DiskoError): + return eval_result - if isinstance(result, DiskoError): - return result + validate_result = validate_config(eval_result.value) + if isinstance(validate_result, DiskoError): + return validate_result - print(result.value.model_dump_json(indent=2)) + print( + validate_result.value.model_dump_json(indent=2, by_alias=True, warnings="error") + ) return DiskoSuccess(None, "run disko dev validate") diff --git a/src/disko/mode_generate.py b/src/disko/mode_generate.py index 9f971d01..f785acd1 100644 --- a/src/disko/mode_generate.py +++ b/src/disko/mode_generate.py @@ -1,9 +1,9 @@ import json import re +from disko_lib.eval_config import eval_config_dict_as_json from disko_lib.logging import info -from disko_lib.messages.msgs import warn_generate_partial_failure -from disko_lib.result import DiskoResult, DiskoError -from disko_lib.types.disk import generate_config +from disko_lib.result import DiskoPartialSuccess, DiskoResult, DiskoError +from disko_lib.generate_config import generate_config from disko_lib.run_cmd import run from disko_lib.json_types import JsonDict @@ -25,29 +25,35 @@ """ +def filter_internal_keys(d: JsonDict) -> JsonDict: + return { + k: (v if not isinstance(v, dict) else filter_internal_keys(v)) + for k, v in d.items() + if not k.startswith("_") + } + + def run_generate() -> DiskoResult[JsonDict]: - generated_config_result = generate_config() - generated_config = None + generate_result = generate_config() + + if isinstance(generate_result, DiskoError) and not isinstance( + generate_result, DiskoPartialSuccess + ): + return generate_result - if isinstance(generated_config_result, DiskoError): - partial_failure_warning = generated_config_result.find_message( - warn_generate_partial_failure - ) - if not partial_failure_warning: - return generated_config_result + config_to_write = filter_internal_keys(generate_result.value) - generated_config = partial_failure_warning.details["partial_config"] - else: - generated_config = generated_config_result.value + evaluate_result = eval_config_dict_as_json(config_to_write) + if isinstance(evaluate_result, DiskoError): + # TODO: Add --no-validate flag and explanatory text + return evaluate_result config_as_nix = run( [ "nix", "eval", "--expr", - "{ disko.devices = (" - f"builtins.fromJSON(''{json.dumps(generated_config)}'')" - "); }", + f"builtins.fromJSON(''{json.dumps(config_to_write)}'')", ] ) if isinstance(config_as_nix, DiskoError): @@ -59,11 +65,11 @@ def run_generate() -> DiskoResult[JsonDict]: with open(DEFAULT_CONFIG_FILE, "w") as f: f.write(HEADER_COMMENT) - if isinstance(generated_config_result, DiskoError): + if isinstance(generate_result, DiskoError): f.write(PARTIAL_FAILURE_COMMENT) f.write(nix_code) info(f"Wrote generated config to {DEFAULT_CONFIG_FILE}") run(["nixfmt", DEFAULT_CONFIG_FILE]) - return generated_config_result + return generate_result diff --git a/src/disko_lib/action.py b/src/disko_lib/action.py new file mode 100644 index 00000000..2bf2d2eb --- /dev/null +++ b/src/disko_lib/action.py @@ -0,0 +1,44 @@ +from dataclasses import dataclass, field +from typing import Literal + + +Action = Literal["destroy", "format", "mount"] + +EMPTY_DESCRIPTION = "__empty__" + + +@dataclass +class Step: + action: Action # Which action the step belongs to + commands: list[list[str]] # List of commands to execute + description: str # Explanatory message to display to the user + + @classmethod + def empty(cls, action: Action) -> "Step": + return cls(action, [], EMPTY_DESCRIPTION) + + def is_empty(self) -> bool: + return self.commands == [] and self.description == EMPTY_DESCRIPTION + + +@dataclass +class Plan: + actions: set[Action] + steps: list[Step] = field(default_factory=list) + skipped_steps: list[Step] = field(default_factory=list) + + def extend(self, other: "Plan") -> None: + # For now I don't see a usecase for merging plans with action sets. + assert self.actions == other.actions + + self.steps.extend(other.steps) + self.skipped_steps.extend(other.skipped_steps) + + def append(self, step: Step) -> None: + if step.is_empty(): + return + + if step.action in self.actions: + self.steps.append(step) + else: + self.skipped_steps.append(step) diff --git a/src/disko_lib/config_type.py b/src/disko_lib/config_type.py index 4daa2960..11461bc1 100644 --- a/src/disko_lib/config_type.py +++ b/src/disko_lib/config_type.py @@ -72,7 +72,7 @@ class gpt_partitions_hybrid(BaseModel): class gpt_partitions(BaseModel): - _index: int + index: int = Field(alias="_index") alignment: int content: "partitionType" = Field(..., discriminator="type") device: str @@ -172,7 +172,7 @@ class swap(BaseModel): class table_partitions(BaseModel): - _index: int + index: int = Field(alias="_index") bootable: bool content: "partitionType" = Field(..., discriminator="type") end: str diff --git a/src/disko_lib/dict_diff.py b/src/disko_lib/dict_diff.py deleted file mode 100644 index ac90dfee..00000000 --- a/src/disko_lib/dict_diff.py +++ /dev/null @@ -1,53 +0,0 @@ -from .json_types import JsonDict - - -def dict_diff(left: JsonDict, right: JsonDict) -> JsonDict: - """Return a dict that only contains the keys and values of `right` - that are different from those in `left`. - - >>> dict_diff({"a": 1, "b": 2}, {"a": 1, "b": 3}) - {'b': 3} - - Keys that are in `left` but not in `right` get the value `None`. - - >>> dict_diff({"a": 1, "b": 2}, {"a": 1}) - {'b': None} - - Dicts are compared recursively. - - >>> dict_diff({"a": {"b": 2}}, {"a": {"b": 3}}) - {'a': {'b': 3}} - - If a dict is missing in `left`, it gets the special key "_new" set - to True to differentiate it from a dict that was present but changed. - - >>> dict_diff({"a": {"b": 1}}, {"a": {"b": 3}, "c": {"d": 4}}) - {'a': {'b': 3}, 'c': {'d': 4, '_new': True}} - """ - new_dict: JsonDict = {} - - for k, right_val in right.items(): - left_val = left.get(k) - if left_val == right_val: - continue - - if not isinstance(right_val, dict): - new_dict[k] = right_val - continue - - if not isinstance(left_val, dict): - left_val = {} - - diffed_right_val = dict_diff(left_val, right_val) - if not left_val: - diffed_right_val["_new"] = True - - new_dict[k] = diffed_right_val - - for k, left_val in left.items(): - if k not in right: - # Do not recurse, even if left_val is a dict! - # Recursion is already done in the first loop. - new_dict[k] = None - - return new_dict diff --git a/src/disko_lib/eval-config.nix b/src/disko_lib/eval-config.nix index eebdbe7e..9f691f79 100644 --- a/src/disko_lib/eval-config.nix +++ b/src/disko_lib/eval-config.nix @@ -3,6 +3,7 @@ , flake ? null , flakeAttr ? null , diskoFile ? null +, configJsonStr ? null , rootMountPoint ? "/mnt" , ... }@args: @@ -18,6 +19,8 @@ let hasFlakeDiskoConfig = lib.hasAttrByPath [ "diskoConfigurations" flakeAttr ] flake'; + hasConfigStr = configJsonStr != null; + hasFlakeDiskoModule = lib.hasAttrByPath [ "nixosConfigurations" flakeAttr "config" "disko" "devices" ] flake'; @@ -26,6 +29,8 @@ let diskoConfig = if hasDiskoFile then import diskoFile + else if hasConfigStr then + (builtins.fromJSON configJsonStr) else flake'.diskoConfigurations.${flakeAttr}; in @@ -35,7 +40,7 @@ let diskoConfig; evaluatedConfig = - if hasDiskoFile || hasFlakeDiskoConfig then + if hasDiskoFile || hasConfigStr || hasFlakeDiskoConfig then disko.eval-disko diskFormat else if (lib.traceValSeq hasFlakeDiskoModule) then flake'.nixosConfigurations.${flakeAttr} diff --git a/src/disko_lib/eval_config.py b/src/disko_lib/eval_config.py index 8e041f2c..67d91060 100644 --- a/src/disko_lib/eval_config.py +++ b/src/disko_lib/eval_config.py @@ -95,7 +95,7 @@ def _eval_flake(flake_uri: str) -> DiskoResult[JsonDict]: return _eval_config({"flake": flake, "flakeAttr": flake_attr}) -def eval_config_as_json( +def eval_config_file_as_json( *, disko_file: str | None, flake: str | None ) -> DiskoResult[JsonDict]: # match would be nicer, but mypy doesn't understand type narrowing in tuples @@ -109,22 +109,19 @@ def eval_config_as_json( return DiskoError.single_message(err_too_many_arguments, "validate args") -def eval_and_validate_config( - *, disko_file: str | None, flake: str | None -) -> DiskoResult[DiskoConfig]: - json_config = eval_config_as_json(disko_file=disko_file, flake=flake) +def eval_config_dict_as_json(config: JsonDict) -> DiskoResult[JsonDict]: + return _eval_config({"configJsonStr": json.dumps(config)}) - if isinstance(json_config, DiskoError): - return json_config +def validate_config(json_config: JsonDict) -> DiskoResult[DiskoConfig]: try: - result = DiskoConfig(**cast(dict[str, Any], json_config.value)) # type: ignore[misc] + result = DiskoConfig(**cast(dict[str, Any], json_config)) # type: ignore[misc] except ValidationError as e: return DiskoError.single_message( bug_validate_config_failed, "validate disko config", error=e, - config=json_config.value, + config=json_config, ) return DiskoSuccess(result, "validate disko config") diff --git a/src/disko_lib/generate_config.py b/src/disko_lib/generate_config.py new file mode 100644 index 00000000..9f7de79d --- /dev/null +++ b/src/disko_lib/generate_config.py @@ -0,0 +1,58 @@ +from disko_lib.logging import DiskoMessage +from disko_lib.messages.msgs import ( + help_generate_partial_failure, +) +from disko_lib.result import DiskoError, DiskoPartialSuccess, DiskoResult, DiskoSuccess +from disko_lib.types.device import BlockDevice +from disko_lib.json_types import JsonDict +import disko_lib.types.disk as disk + + +def generate_config(devices: list[BlockDevice] = []) -> DiskoResult[JsonDict]: + error = DiskoError([], "generate disko config") + + config: dict[str, JsonDict] = { + "disk": {}, + "lvm_vg": {}, + "mdadm": {}, + "nodev": {}, + "zpool": {}, + } + successful_sections = [] + failed_sections = [] + + disk_config = disk.generate_config(devices) + if isinstance(disk_config, DiskoSuccess): + config["disk"] = disk_config.value + successful_sections.append("disk") + else: + error.extend(disk_config) + failed_sections.append("disk") + if isinstance(disk_config, DiskoPartialSuccess): + config["disk"] = disk_config.value + successful_sections.append("disk") + + # TODO: Add generation for ZFS, MDADM, LVM, etc. + successful_sections.append("lvm_vg") + successful_sections.append("mdadm") + successful_sections.append("nodev") + successful_sections.append("zpool") + + final_config: JsonDict = {"disko": {"devices": config}} # type: ignore[dict-item] + + if not failed_sections: + return DiskoSuccess(final_config, "generate disko config") + + if not successful_sections: + return error + + error.append( + DiskoMessage( + help_generate_partial_failure, + partial_config=final_config, + successful=successful_sections, + failed=failed_sections, + ) + ) + + return error.to_partial_success(final_config) diff --git a/src/disko_lib/generate_plan.py b/src/disko_lib/generate_plan.py new file mode 100644 index 00000000..bb75cc6d --- /dev/null +++ b/src/disko_lib/generate_plan.py @@ -0,0 +1,11 @@ +from disko_lib.action import Action, Plan +from disko_lib.config_type import DiskoConfig +from disko_lib.result import DiskoResult +import disko_lib.types.disk as disk + + +def generate_plan( + actions: set[Action], current_status: DiskoConfig, target_config: DiskoConfig +) -> DiskoResult[Plan]: + # TODO: Add generation for ZFS, MDADM, LVM, etc. + return disk.generate_plan(actions, current_status, target_config) diff --git a/src/disko_lib/messages/bugs.py b/src/disko_lib/messages/bugs.py index 1d8d6f86..1d1d9bb8 100644 --- a/src/disko_lib/messages/bugs.py +++ b/src/disko_lib/messages/bugs.py @@ -1,7 +1,7 @@ import json import pydantic from disko_lib.logging import ReadableMessage -from disko_lib.messages.colors import INVALID, RESET, VALUE +from disko_lib.messages.colors import FILE, INVALID, RESET, VALUE from ..json_types import JsonDict @@ -55,10 +55,40 @@ def bug_validate_config_failed( ReadableMessage( "bug", f""" - Configuration was evaluated successfully, but failed validation! + Configuration validation failed! Most likely, the types in python are out-of-sync with those in nix. The {INVALID}validation errors{RESET} and the {VALUE}evaluated configuration{RESET} are printed above. """, ), __bug_help_message("bug_validate_config_failed"), ] + + +def bug_unsupported_device_content_type( + *, name: str, device: str, type: str +) -> list[ReadableMessage]: + return [ + ReadableMessage( + "bug", + f""" + Configuration for device {FILE}{device}{RESET} (name={VALUE}{name}{RESET}) specifies unsupported + device content type {INVALID}{type}{RESET}, which was not implemented yet! + """, + ), + __bug_help_message("err_unsupported_device_content_type"), + ] + + +def bug_unsupported_partition_content_type( + *, name: str, device: str, type: str +) -> list[ReadableMessage]: + return [ + ReadableMessage( + "bug", + f""" + Configuration for partition {FILE}{device}{RESET} (name={VALUE}{name}{RESET}) specifies unsupported + partition content type {INVALID}{type}{RESET}, which was not implemented yet! + """, + ), + __bug_help_message("bug_unsupported_partition_content_type"), + ] diff --git a/src/disko_lib/messages/msgs.py b/src/disko_lib/messages/msgs.py index 39518e84..77cbca97 100644 --- a/src/disko_lib/messages/msgs.py +++ b/src/disko_lib/messages/msgs.py @@ -19,6 +19,54 @@ def err_command_failed(*, command: str, exit_code: int, stderr: str) -> Readable ) +def err_disk_type_changed_no_destroy( + *, disk: str, device: str, old_type: str, new_type: str +) -> list[ReadableMessage]: + return [ + ReadableMessage( + "error", + f""" + Disk {VALUE}{disk}{RESET} ({FILE}{device}{RESET}) changed type from {INVALID}{old_type}{RESET} to {INVALID}{new_type}{RESET}. + Need to destroy and recreate the disk, but the current mode does not allow it! + """, + ), + ReadableMessage( + "help", + f""" + Run `{COMMAND}disko{RESET} {VALUE}destroy,format,mount{RESET}` to allow destructive changes, + or change {VALUE}{disk}{RESET}'s type back to {INVALID}{old_type}{RESET} to keep the data. + """, + ), + ] + + +def err_disk_not_found(*, disk: str, device: str) -> ReadableMessage: + return ReadableMessage( + "error", + f"Device path {FILE}{device}{RESET} (for disk {VALUE}{disk}{RESET}) was not found!", + ) + + +def err_duplicated_disk_devices( + *, devices: list[str], duplicates: set[str] +) -> list[ReadableMessage]: + return [ + ReadableMessage( + "error", + f""" + Your config sets the same device path for multiple disks! + Devices: {", ".join(f"{VALUE}{d}{RESET}" for d in sorted(devices))} + """, + ), + ReadableMessage( + "help", + f"""The duplicates are: + {", ".join(f"{INVALID}{d}{RESET}" for d in duplicates)} + """, + ), + ] + + def err_eval_config_failed(*, args: dict[str, str], stderr: str) -> ReadableMessage: return ReadableMessage( "error", @@ -33,6 +81,27 @@ def err_file_not_found(*, path: Path) -> ReadableMessage: return ReadableMessage("error", f"File not found: {FILE}{path}{RESET}") +def err_filesystem_changed_no_destroy( + *, device: str, old_format: str, new_format: str +) -> list[ReadableMessage]: + return [ + ReadableMessage( + "error", + f""" + Filesystem on device {FILE}{device}{RESET} changed from {INVALID}{old_format}{RESET} to {INVALID}{new_format}{RESET}. + Need to destroy and recreate the filesystem, but the current mode does not allow it! + """, + ), + ReadableMessage( + "help", + f""" + Run `{COMMAND}disko{RESET} {VALUE}destroy,format,mount{RESET}` to allow destructive changes, + or change the filesystem back to {INVALID}{old_format}{RESET} to keep the data. + """, + ), + ] + + def err_flake_uri_no_attr(*, flake_uri: str) -> list[ReadableMessage]: return [ ReadableMessage( @@ -82,10 +151,30 @@ def err_unsupported_pttype(*, device: Path, pttype: str) -> ReadableMessage: def warn_generate_partial_failure( + *, + kind: str, + failed: list[str], + successful: list[str], +) -> ReadableMessage: + partially_successful = [x for x in successful if x in failed] + failed = [x for x in failed if x not in partially_successful] + successful = [x for x in successful if x not in partially_successful] + return ReadableMessage( + "warning", + f""" + Successfully generated config for {EM}some{RESET} {kind}s of your setup, {EM_WARN}but not all{RESET}! + Failed {kind}s: {", ".join(f"{INVALID}{d}{RESET}" for d in failed)} + Successful {kind}s: {", ".join(f"{VALUE}{d}{RESET}" for d in successful)} + Partially successful {kind}s: {", ".join(f"{EM_WARN}{d}{RESET}" for d in partially_successful)} + """, + ) + + +def help_generate_partial_failure( *, partial_config: JsonDict, - failed_devices: list[str], - successful_devices: list[str], + failed: list[str], + successful: list[str], ) -> list[ReadableMessage]: return [ ReadableMessage( @@ -94,16 +183,13 @@ def warn_generate_partial_failure( Successfully generated config for {EM}some{RESET} devices. Errors are printed above. The generated partial config is: {json.dumps(partial_config, indent=2)} - """, - ), - ReadableMessage( - "warning", - f""" - Successfully generated config for {EM}some{RESET} devices, {EM_WARN}but not all{RESET}! - Failed devices: {", ".join(f"{INVALID}{d}{RESET}" for d in failed_devices)} - Successful devices: {", ".join(f"{VALUE}{d}{RESET}" for d in successful_devices)} """, ), + warn_generate_partial_failure( + kind="section", + failed=failed, + successful=successful, + ), ReadableMessage( "help", f""" diff --git a/src/disko_lib/result.py b/src/disko_lib/result.py index ba76be05..16577547 100644 --- a/src/disko_lib/result.py +++ b/src/disko_lib/result.py @@ -1,11 +1,18 @@ from dataclasses import dataclass -from typing import Any, Generic, Literal, ParamSpec, TypeVar, cast +from typing import Any, Generic, ParamSpec, TypeVar, cast from disko_lib.messages.bugs import bug_success_without_context -from .logging import DiskoMessage, debug, MessageFactory +from .logging import ( + DiskoMessage, + ReadableMessage, + debug, + MessageFactory, + render_message, +) T = TypeVar("T", covariant=True) +S = TypeVar("S") P = ParamSpec("P") @@ -13,14 +20,15 @@ class DiskoSuccess(Generic[T]): value: T context: None | str = None - success: Literal[True] = True @dataclass class DiskoError: messages: list[DiskoMessage[object]] context: str - success: Literal[False] = False + + def __len__(self) -> int: + return len(self.messages) @classmethod def single_message( @@ -43,8 +51,19 @@ def append(self, message: DiskoMessage[Any]) -> None: def extend(self, other_error: "DiskoError") -> None: self.messages.extend(other_error.messages) + def with_context(self, context: str) -> "DiskoError": + return DiskoError(self.messages, context) + + def to_partial_success(self, value: S) -> "DiskoPartialSuccess[S]": + return DiskoPartialSuccess(self.messages, self.context, value) + + +@dataclass +class DiskoPartialSuccess(Generic[T], DiskoError): + value: T -DiskoResult = DiskoSuccess[T] | DiskoError # type: ignore[misc, unused-ignore] + +DiskoResult = DiskoSuccess[T] | DiskoPartialSuccess[T] | DiskoError # type: ignore[misc, unused-ignore] def exit_on_error(result: DiskoResult[T]) -> T: @@ -56,6 +75,8 @@ def exit_on_error(result: DiskoResult[T]) -> T: debug(f"Returned value: {result.value}") return result.value + render_message(ReadableMessage("error", f"Failed to {result.context}!")) + for message in result.messages: message.print() diff --git a/src/disko_lib/types/device.py b/src/disko_lib/types/device.py index 1d44fd37..3e2ff76f 100644 --- a/src/disko_lib/types/device.py +++ b/src/disko_lib/types/device.py @@ -62,7 +62,7 @@ class BlockDevice: serial: str size: str start: str - mountpoint: str + mountpoint: str | None mountpoints: list[str] type: str uuid: str @@ -104,9 +104,9 @@ def from_json_dict(cls, json_dict: JsonDict) -> "BlockDevice": pttype=cast(str, json_dict["pttype"]), rev=cast(str, json_dict["rev"]) or "", serial=cast(str, json_dict["serial"]) or "", - size=cast(str, json_dict["size"]), + size=str(cast(int, json_dict["size"])), start=cast(str, json_dict["start"]) or "", - mountpoint=cast(str, json_dict["mountpoint"]) or "", + mountpoint=cast(str, json_dict["mountpoint"]) or None, mountpoints=mountpoints, type=cast(str, json_dict["type"]), uuid=cast(str, json_dict["uuid"]) or "", @@ -114,7 +114,18 @@ def from_json_dict(cls, json_dict: JsonDict) -> "BlockDevice": def run_lsblk() -> DiskoResult[str]: - return run(["lsblk", "--json", "--tree", "--output", ",".join(LSBLK_OUTPUT_FIELDS)]) + return run( + [ + "lsblk", + "--json", # JSON output + "--tree", # Tree structure with `children` field + # Show sizes in bytes. The human readable abbreviation can be less precise and harder to parse + "--bytes", + # Determine and output only the fields we are interested in + "--output", + ",".join(LSBLK_OUTPUT_FIELDS), + ] + ) def list_block_devices(lsblk_output: str = "") -> DiskoResult[list[BlockDevice]]: diff --git a/src/disko_lib/types/disk.py b/src/disko_lib/types/disk.py index ab886aa5..ea04d2fd 100644 --- a/src/disko_lib/types/disk.py +++ b/src/disko_lib/types/disk.py @@ -1,23 +1,31 @@ +from typing import cast + +from disko_lib.action import Action, Plan, Step +from disko_lib.config_type import DiskoConfig, disk, gpt, deviceType from disko_lib.messages.msgs import ( + err_disk_not_found, + err_duplicated_disk_devices, err_unsupported_pttype, warn_generate_partial_failure, ) +from disko_lib.messages.bugs import bug_unsupported_device_content_type +from disko_lib.utils import find_by_predicate, find_duplicates +import disko_lib.types.gpt from ..logging import DiskoMessage, debug from ..result import DiskoError, DiskoResult, DiskoSuccess from ..types.device import BlockDevice, list_block_devices from ..json_types import JsonDict -from . import gpt -def _generate_content(device: BlockDevice) -> DiskoResult[JsonDict]: +def _generate_config_content(device: BlockDevice) -> DiskoResult[JsonDict]: match device.pttype: case "gpt": - return gpt.generate_config(device) + return disko_lib.types.gpt.generate_config(device) case _: return DiskoError.single_message( err_unsupported_pttype, - "generate disk content", + "generate disk config", device=device.path, pttype=device.pttype, ) @@ -41,7 +49,7 @@ def generate_config(devices: list[BlockDevice] = []) -> DiskoResult[JsonDict]: successful_devices = [] for device in block_devices: - content = _generate_content(device) + content = _generate_config_content(device) if isinstance(content, DiskoError): error.extend(content) @@ -49,25 +57,148 @@ def generate_config(devices: list[BlockDevice] = []) -> DiskoResult[JsonDict]: continue disks[f"MODEL:{device.model},SN:{device.serial}"] = { - "device": device.kname, + "device": f"/dev/{device.kname}", "type": device.type, "content": content.value, } successful_devices.append(device.path) - config: JsonDict = {"disks": disks} - if not failed_devices: - return DiskoSuccess(config, "generate disk config") + return DiskoSuccess(disks, "generate disk config") + + if not successful_devices: + return error + + error.append( + DiskoMessage( + warn_generate_partial_failure, + kind="disk", + failed=failed_devices, + successful=successful_devices, + ) + ) + return error.to_partial_success(disks) + - if successful_devices: +def _generate_plan_content( + actions: set[Action], + name: str, + device: str, + current_content: deviceType, + target_content: deviceType, +) -> DiskoResult[Plan]: + if target_content is None: + debug(f"Element '{name}': No target content") + return DiskoSuccess(Plan(actions, []), f"generate '{name}' content plan") + + target_type = target_content.type + + if current_content is not None: + assert ( + current_content.type == target_type + ), "BUG! Device content type mismatch, should've been resolved earlier!" + + match target_type: + case "gpt": + return disko_lib.types.gpt.generate_plan( + actions, cast(gpt | None, current_content), cast(gpt, target_content) + ) + case _: + return DiskoError.single_message( + bug_unsupported_device_content_type, + "generate disk plan", + name=name, + device=device, + type=target_type, + ) + + +def generate_plan( + actions: set[Action], current_status: DiskoConfig, target_config: DiskoConfig +) -> DiskoResult[Plan]: + debug("Generating plan for disko config") + + error = DiskoError([], "generate disk plan") + plan = Plan(actions) + + current_disks = current_status.disk + target_disks = target_config.disk + + target_devices = [d.device for d in target_disks.values()] + + if duplicate_devices := find_duplicates(target_devices): error.append( DiskoMessage( - warn_generate_partial_failure, - partial_config=config, - failed_devices=failed_devices, - successful_devices=successful_devices, - ), + err_duplicated_disk_devices, + devices=target_devices, + duplicates=duplicate_devices, + ) ) - return error + current_disks_by_target_name: dict[str, disk] = {} + + # Create plan for this disk + for name, target_disk_config in target_disks.items(): + device = target_disk_config.device + _, current_disk_config = find_by_predicate( + current_disks, lambda k, v: v.device == device + ) + disk_exists = current_disk_config is not None + current_type = current_disk_config.type if current_disk_config else None + target_type = target_disk_config.type + disk_has_same_type = current_type == target_type + + debug( + f"Disk '{name}': {device=}, {disk_exists=}, {disk_has_same_type=}, {current_type=}, {target_type=}" + ) + + # Can't use disk_exists here, mypy doesn't understand that it + # narrows the type of current_disk_config + if current_disk_config is None: + error.append( + DiskoMessage( + err_disk_not_found, + disk=name, + device=device, + ) + ) + continue + + current_disks_by_target_name[name] = current_disk_config + + if disk_has_same_type: + continue + + plan.append( + Step( + "destroy", + [[f"disk-deactivate {device}"]], + f"destroy partition table on `{name}`, at {device}", + ) + ) + + # Create content plan + for name, target_disk_config in target_disks.items(): + current_content = None + current_disk_config = current_disks_by_target_name.get(name) + if current_disk_config is not None: + current_content = current_disk_config.content + + result = _generate_plan_content( + actions, + name, + target_disk_config.device, + current_content, + target_disk_config.content, + ) + + if isinstance(result, DiskoError): + error.extend(result) + continue + + plan.extend(result.value) + + if error: + return error + + return DiskoSuccess(plan, "generate disk plan") diff --git a/src/disko_lib/types/filesystem.py b/src/disko_lib/types/filesystem.py index 5888f1b5..eea1be25 100644 --- a/src/disko_lib/types/filesystem.py +++ b/src/disko_lib/types/filesystem.py @@ -1,3 +1,6 @@ +from disko_lib.action import Action, Plan, Step +from disko_lib.config_type import filesystem +from disko_lib.logging import debug from .device import BlockDevice from ..result import DiskoResult, DiskoSuccess from ..json_types import JsonDict @@ -15,3 +18,59 @@ def generate_config(device: BlockDevice) -> DiskoResult[JsonDict]: "mountpoint": device.mountpoint, } ) + + +def _generate_mount_step(target_config: filesystem) -> Step: + if target_config.mountpoint is None: + return Step.empty("mount") + + return Step( + "mount", + [ + # TODO: Only try to mount if the device is not already mounted + # This will probably require us to change the way we specify steps, + # as they currently don't allow for conditional execution + [ + "mount", + target_config.device, + target_config.mountpoint, + "-t", + target_config.format, + ] + + target_config.mountOptions + + ["-o", "X-mount.mkdir"] + ], + "mount filesystem", + ) + + +def generate_plan( + actions: set[Action], current_config: filesystem | None, target_config: filesystem +) -> DiskoResult[Plan]: + debug("Generating plan for filesystem") + + plan = Plan(actions, []) + + current_format = current_config.format if current_config is not None else None + target_format = target_config.format + device = target_config.device + need_to_destroy_current = ( + current_config is not None and current_format != target_format + ) + + debug( + f"Filesystem {device}: {current_format=}, {target_format=}, {need_to_destroy_current=}" + ) + + if need_to_destroy_current: + plan.append( + Step( + "destroy", + [[f"mkfs.{target_format}"] + target_config.extraArgs + [device]], + "destroy current filesystem and create new one", + ) + ) + + plan.append(_generate_mount_step(target_config)) + + return DiskoSuccess(plan, "generate filesystem plan") diff --git a/src/disko_lib/types/gpt.py b/src/disko_lib/types/gpt.py index 10bbde28..ce5a648d 100644 --- a/src/disko_lib/types/gpt.py +++ b/src/disko_lib/types/gpt.py @@ -1,5 +1,10 @@ +from typing import cast +from disko_lib.action import Action, Plan, Step +from disko_lib.config_type import gpt, gpt_partitions, partitionType, filesystem +from disko_lib.messages.bugs import bug_unsupported_partition_content_type +from disko_lib.utils import find_by_predicate +import disko_lib.types.filesystem from ..logging import debug -from . import filesystem from .device import BlockDevice from ..result import DiskoError, DiskoResult, DiskoSuccess from ..json_types import JsonDict @@ -24,11 +29,11 @@ def _generate_name(device: BlockDevice) -> str: return f"PARTUUID:{device.partuuid}" -def _generate_content(device: BlockDevice) -> DiskoResult[JsonDict]: +def _generate_config_content(device: BlockDevice) -> DiskoResult[JsonDict]: match device.fstype: # TODO: Add filesystems that are not supported by `mkfs` here case _: - return filesystem.generate_config(device) + return disko_lib.types.filesystem.generate_config(device) def generate_config(device: BlockDevice) -> DiskoResult[JsonDict]: @@ -43,8 +48,8 @@ def generate_config(device: BlockDevice) -> DiskoResult[JsonDict]: failed_partitions = [] successful_partitions = [] - for partition in device.children: - content = _generate_content(partition) + for index, partition in enumerate(device.children): + content = _generate_config_content(partition) if isinstance(content, DiskoError): error.extend(content) @@ -52,11 +57,191 @@ def generate_config(device: BlockDevice) -> DiskoResult[JsonDict]: continue partitions[_generate_name(partition)] = _add_type_if_required( - partition, {"size": partition.size, "content": content.value} + partition, + {"_index": index + 1, "size": partition.size, "content": content.value}, ) successful_partitions.append(partition.path) if not failed_partitions: - return DiskoSuccess({"partitions": partitions}, "generate gpt config") + return DiskoSuccess( + {"type": "gpt", "partitions": partitions}, "generate gpt config" + ) return error + + +def _generate_plan_content( + actions: set[Action], + name: str, + device: str, + current_content: partitionType, + target_content: partitionType, +) -> DiskoResult[Plan]: + if target_content is None: + return DiskoSuccess(Plan(actions, []), f"generate '{name}' content plan") + + target_type = target_content.type + + if current_content is not None: + assert ( + current_content.type == target_type + ), "BUG! Partition content type mismatch, should've been resolved earlier!" + + match target_type: + case "filesystem": + return disko_lib.types.filesystem.generate_plan( + actions, + cast(filesystem | None, current_content), + cast(filesystem, target_content), + ) + case _: + return DiskoError.single_message( + bug_unsupported_partition_content_type, + "generate partition plan", + name=name, + device=device, + type=target_type, + ) + + +def _step_clear_partition_table(device: str) -> Step: + return Step( + "format", [["sgdisk", "--clear", device]], f"Clear partition table on {device}" + ) + + +def _partprobe_settle(device: str) -> list[list[str]]: + # ensure /dev/disk/by-path/..-partN exists before continuing + return [ + ["partprobe", device], + ["udevadm", "trigger", "--subsystem-match=block"], + ["udevadm", "settle"], + ] + + +def _sgdisk_create_args(partition_config: gpt_partitions) -> list[str]: + alignment = partition_config.alignment + index = partition_config.index + start = partition_config.start + end = partition_config.end + + alignment_args = [] if alignment == 0 else [f"--set-alignment={alignment}"] + + return [ + "--align-end", + *alignment_args, + f"--new={index}:{start}:{end}", + ] + + +def _sgdisk_modify_args(partition_config: gpt_partitions) -> list[str]: + index = partition_config.index + label = partition_config.label + type = partition_config.type + + return [ + f'--change-name="{index}:{label}"', + f"--typecode=${index}:{type}", + ] + + +def _step_modify_partition(device: str, partition_config: gpt_partitions) -> Step: + return Step( + "format", + [ + [ + "sgdisk", + *_sgdisk_modify_args(partition_config), + device, + ] + ] + + _partprobe_settle(device), + "Create partition {}", + ) + + +def _step_create_partition(device: str, partition_config: gpt_partitions) -> Step: + return Step( + "format", + [ + [ + "sgdisk", + *_sgdisk_create_args(partition_config), + *_sgdisk_modify_args(partition_config), + device, + ] + ] + + _partprobe_settle(device), + "Create partition {}", + ) + + +def generate_plan( + actions: set[Action], current_gpt_config: gpt | None, target_gpt_config: gpt +) -> DiskoResult[Plan]: + device = target_gpt_config.device + debug(f"Generating GPT plan for disk {device}") + + if current_gpt_config is None: + current_partitions = {} + else: + current_partitions = current_gpt_config.partitions + target_partitions = target_gpt_config.partitions + + error_messages = [] + plan = Plan(actions) + + if current_gpt_config is None: + plan.append(_step_clear_partition_table(device)) + + current_partitions_by_target_name: dict[str, gpt_partitions] = {} + + # Create or modify all partitions first + for name, target_partition in target_partitions.items(): + _, current_partition = find_by_predicate( + current_partitions, lambda k, v: v.index == target_partition.index + ) + + if not current_partition: + plan.append(_step_create_partition(device, target_partition)) + continue + + current_partitions_by_target_name[name] = current_partition + + if ( + current_partition.type == target_partition.type + and current_partition.label == target_partition.label + ): + debug(f"Partition {name} has no changes we could apply") + continue + + # TODO: Determine if something else about the disk changed. Add a warning message if that change + # can't be applied by disko automatically, (plus a help message that explains how to target just + # a single disk in case the user wants to make the change destructively) or add the + # necessary steps to apply the changes + if "format" not in actions: + continue + + plan.append(_step_modify_partition(device, target_partition)) + + # Then dispatch to all the filesystems + for name, target_partition in target_partitions.items(): + current_content = None + current_partition_config = current_partitions_by_target_name.get(name) + if current_partition_config is not None: + current_content = current_partition_config.content + + content_plan_result = _generate_plan_content( + actions, + name, + target_partition.device, + current_content, + target_partition.content, + ) + if isinstance(content_plan_result, DiskoError): + error_messages.append(content_plan_result.messages) + continue + + plan.extend(content_plan_result.value) + + return DiskoSuccess(plan, "generate gpt plan") diff --git a/src/disko_lib/utils.py b/src/disko_lib/utils.py new file mode 100644 index 00000000..11a20f36 --- /dev/null +++ b/src/disko_lib/utils.py @@ -0,0 +1,23 @@ +from typing import Iterable, TypeVar, Callable + +T = TypeVar("T") + + +def find_by_predicate( + dct: dict[str, T], predicate: Callable[[str, T], bool] +) -> tuple[str, T] | tuple[None, None]: + for k, v in dct.items(): + if predicate(k, v): + return k, v + return None, None + + +def find_duplicates(it: Iterable[T]) -> set[T]: + seen = set() + duplicates = set() + for item in it: + if item in seen: + duplicates.add(item) + else: + seen.add(item) + return duplicates diff --git a/tests/disko_lib/eval_config/file-simple-efi-validate-result.json b/tests/disko_lib/eval_config/file-simple-efi-validate-result.json index 2a652845..d3e50160 100644 --- a/tests/disko_lib/eval_config/file-simple-efi-validate-result.json +++ b/tests/disko_lib/eval_config/file-simple-efi-validate-result.json @@ -6,6 +6,7 @@ "efiGptPartitionFirst": true, "partitions": { "ESP": { + "_index": 1, "alignment": 0, "content": { "device": "/dev/disk/by-partlabel/disk-main-ESP", @@ -28,6 +29,7 @@ "type": "EF00" }, "root": { + "_index": 2, "alignment": 0, "content": { "device": "/dev/disk/by-partlabel/disk-main-root", @@ -63,4 +65,4 @@ "mdadm": {}, "nodev": {}, "zpool": {} -} +} \ No newline at end of file diff --git a/tests/disko_lib/eval_config/flake-testmachine-validate-result.json b/tests/disko_lib/eval_config/flake-testmachine-validate-result.json index 02535487..1ddd6436 100644 --- a/tests/disko_lib/eval_config/flake-testmachine-validate-result.json +++ b/tests/disko_lib/eval_config/flake-testmachine-validate-result.json @@ -6,6 +6,7 @@ "efiGptPartitionFirst": true, "partitions": { "ESP": { + "_index": 2, "alignment": 0, "content": { "device": "/dev/disk/by-partlabel/disk-main-ESP", @@ -28,6 +29,7 @@ "type": "EF00" }, "boot": { + "_index": 1, "alignment": 0, "content": null, "device": "/dev/disk/by-partlabel/disk-main-boot", @@ -41,6 +43,7 @@ "type": "EF02" }, "root": { + "_index": 3, "alignment": 0, "content": { "device": "/dev/disk/by-partlabel/disk-main-root", @@ -76,4 +79,4 @@ "mdadm": {}, "nodev": {}, "zpool": {} -} +} \ No newline at end of file diff --git a/tests/disko_lib/eval_config/test_eval_config.py b/tests/disko_lib/eval_config/test_eval_config.py index 20521489..7249aaea 100644 --- a/tests/disko_lib/eval_config/test_eval_config.py +++ b/tests/disko_lib/eval_config/test_eval_config.py @@ -1,9 +1,8 @@ import json from pathlib import Path -import readline from typing import cast -from disko_lib.eval_config import eval_config_as_json, eval_and_validate_config +from disko_lib.eval_config import eval_config_file_as_json, validate_config from disko_lib.messages.msgs import err_missing_arguments, err_too_many_arguments from disko_lib.result import DiskoError, DiskoSuccess from disko_lib.json_types import JsonDict @@ -14,14 +13,14 @@ def test_eval_config_missing_arguments() -> None: - result = eval_config_as_json(disko_file=None, flake=None) + result = eval_config_file_as_json(disko_file=None, flake=None) assert isinstance(result, DiskoError) assert result.messages[0].is_message(err_missing_arguments) assert result.context == "validate args" def test_eval_config_too_many_arguments() -> None: - result = eval_config_as_json(disko_file="foo", flake="bar") + result = eval_config_file_as_json(disko_file="foo", flake="bar") assert isinstance(result, DiskoError) assert result.messages[0].is_message(err_too_many_arguments) assert result.context == "validate args" @@ -29,7 +28,7 @@ def test_eval_config_too_many_arguments() -> None: def test_eval_config_disko_file() -> None: disko_file_path = ROOT_DIR / "example" / "simple-efi.nix" - result = eval_config_as_json(disko_file=str(disko_file_path), flake=None) + result = eval_config_file_as_json(disko_file=str(disko_file_path), flake=None) assert isinstance(result, DiskoSuccess) with open(CURRENT_DIR / "file-simple-efi-eval-result.json") as f: expected_result = cast(JsonDict, json.load(f)) @@ -37,7 +36,7 @@ def test_eval_config_disko_file() -> None: def test_eval_config_flake_testmachine() -> None: - result = eval_config_as_json(disko_file=None, flake=f"{ROOT_DIR}#testmachine") + result = eval_config_file_as_json(disko_file=None, flake=f"{ROOT_DIR}#testmachine") assert isinstance(result, DiskoSuccess) with open(CURRENT_DIR / "flake-testmachine-eval-result.json") as f: expected_result = cast(JsonDict, json.load(f)) @@ -46,16 +45,22 @@ def test_eval_config_flake_testmachine() -> None: def test_eval_and_validate_config_disko_file() -> None: disko_file_path = ROOT_DIR / "example" / "simple-efi.nix" - result = eval_and_validate_config(disko_file=str(disko_file_path), flake=None) - assert isinstance(result, DiskoSuccess) + eval_result = eval_config_file_as_json(disko_file=str(disko_file_path), flake=None) + assert isinstance(eval_result, DiskoSuccess) + validate_result = validate_config(eval_result.value) + assert isinstance(validate_result, DiskoSuccess) with open(CURRENT_DIR / "file-simple-efi-validate-result.json") as f: expected_result = f.read() - assert json.loads(result.value.model_dump_json()) == json.loads(expected_result) # type: ignore[misc] + assert json.loads(validate_result.value.model_dump_json(by_alias=True)) == json.loads(expected_result) # type: ignore[misc] def test_eval_and_validate_flake_testmachine() -> None: - result = eval_and_validate_config(disko_file=None, flake=f"{ROOT_DIR}#testmachine") - assert isinstance(result, DiskoSuccess) + eval_result = eval_config_file_as_json( + disko_file=None, flake=f"{ROOT_DIR}#testmachine" + ) + assert isinstance(eval_result, DiskoSuccess) + validate_result = validate_config(eval_result.value) + assert isinstance(validate_result, DiskoSuccess) with open(CURRENT_DIR / "flake-testmachine-validate-result.json") as f: expected_result = f.read() - assert json.loads(result.value.model_dump_json()) == json.loads(expected_result) # type: ignore[misc] + assert json.loads(validate_result.value.model_dump_json(by_alias=True)) == json.loads(expected_result) # type: ignore[misc] diff --git a/tests/disko_lib/test_dict_diff.py b/tests/disko_lib/test_dict_diff.py deleted file mode 100644 index c1cb59a0..00000000 --- a/tests/disko_lib/test_dict_diff.py +++ /dev/null @@ -1,168 +0,0 @@ -from disko_lib.dict_diff import dict_diff -from disko_lib.json_types import JsonDict - - -def test_dict_diff_basic() -> None: - left: JsonDict = { - "a": 1, - "b": 2, - "c": 3, - "d": 4, - } - right: JsonDict = { - "a": 1, - "b": 3, - "c": 4, - "e": 5, - } - assert dict_diff(left, right) == { - "b": 3, - "c": 4, - "d": None, - "e": 5, - } - assert dict_diff(right, left) == { - "b": 2, - "c": 3, - "d": 4, - "e": None, - } - assert dict_diff(left, left) == {} - assert dict_diff(right, right) == {} - assert dict_diff({}, {}) == {} - assert dict_diff(left, {}) == { - "a": None, - "b": None, - "c": None, - "d": None, - } - assert dict_diff({}, right) == right - - -def test_dict_diff_arrays() -> None: - left: JsonDict = { - "a": [1, 2, 3], - "b": [4, 5, 6], - "c": [7, 8, 9], - } - right: JsonDict = { - "a": [1, 2, 3], - "b": [4, 5, 7], - "c": [7, 8, 9], - "d": [10, 11, 12], - } - assert dict_diff(left, right) == { - "b": [4, 5, 7], - "d": [10, 11, 12], - } - assert dict_diff(right, left) == { - "b": [4, 5, 6], - "d": None, - } - assert dict_diff(left, left) == {} - assert dict_diff(right, right) == {} - assert dict_diff(left, {}) == { - "a": None, - "b": None, - "c": None, - } - assert dict_diff({}, right) == right - - -def test_dict_diff_nested() -> None: - left: JsonDict = { - "a": { - "b": { - "c": 1, - "d": 2, - }, - "e": 3, - "f": 4, - }, - "g": { - "h": 4, - }, - "k": { - "l": { - "m": 5, - }, - }, - } - right: JsonDict = { - "a": { - "b": { - "c": 1, - "d": 3, - }, - "e": 3, - }, - "g": { - "h": 4, - "i": 5, - }, - "o": { - "p": 6, - }, - } - assert dict_diff(left, right) == { - "a": { - "b": { - "d": 3, - }, - "f": None, - }, - "g": { - "i": 5, - }, - "k": None, - "o": { - "p": 6, - "_new": True, - }, - } - assert dict_diff(right, left) == { - "a": { - "b": { - "d": 2, - }, - "f": 4, - }, - "g": { - "i": None, - }, - "k": { - "l": { - "m": 5, - "_new": True, - }, - "_new": True, - }, - "o": None, - } - assert dict_diff(left, left) == {} - assert dict_diff(right, right) == {} - assert dict_diff(left, {}) == { - "a": None, - "g": None, - "k": None, - } - assert dict_diff({}, right) == { - "a": { - "b": { - "c": 1, - "d": 3, - "_new": True, - }, - "e": 3, - "_new": True, - }, - "g": { - "h": 4, - "i": 5, - "_new": True, - }, - "o": { - "p": 6, - "_new": True, - }, - } diff --git a/tests/disko_lib/types_disk/partial_failure_dos_table-generate-result.json b/tests/disko_lib/types_disk/partial_failure_dos_table-generate-result.json index 0f8fc0b2..5ac6aee9 100644 --- a/tests/disko_lib/types_disk/partial_failure_dos_table-generate-result.json +++ b/tests/disko_lib/types_disk/partial_failure_dos_table-generate-result.json @@ -1,110 +1,131 @@ { - "disks": { - "MODEL:ST2000LM003 HN-M201RAD,SN:S321J9GFC01497": { - "device": "sda", - "type": "disk", - "content": { - "partitions": { - "PARTUUID:c090741b-68e2-4867-96df-2ec00765f2c0": { - "size": "128M", - "content": { - "type": "filesystem", - "format": "", - "mountpoint": "" - } - }, - "UUID:7CA41E5EA41E1B6A": { - "size": "1.8T", - "content": { - "type": "filesystem", - "format": "ntfs", - "mountpoint": "/mnt/g" - } - } + "disko": { + "devices": { + "disk": { + "MODEL:SanDisk SD8TB8U256G1001,SN:171887425854": { + "device": "/dev/sdb", + "type": "disk", + "content": { + "type": "gpt", + "partitions": { + "UUID:01D60069CEED69C0": { + "_index": 1, + "size": "523730944", + "content": { + "type": "filesystem", + "format": "ntfs", + "mountpoint": null + } + }, + "UUID:01D6006B90875FE0": { + "_index": 2, + "size": "254301093376", + "content": { + "type": "filesystem", + "format": "ntfs", + "mountpoint": null } + }, + "UUID:708B-C192": { + "_index": 3, + "size": "104857600", + "content": { + "type": "filesystem", + "format": "vfat", + "mountpoint": null + }, + "type": "EF00" + }, + "UUID:90B6FB81B6FB65DE": { + "_index": 4, + "size": "570425344", + "content": { + "type": "filesystem", + "format": "ntfs", + "mountpoint": null + } + } } + } }, - "MODEL:SanDisk SD8TB8U256G1001,SN:171887425854": { - "device": "sdb", - "type": "disk", - "content": { - "partitions": { - "UUID:01D60069CEED69C0": { - "size": "499.5M", - "content": { - "type": "filesystem", - "format": "ntfs", - "mountpoint": "" - } - }, - "UUID:01D6006B90875FE0": { - "size": "236.8G", - "content": { - "type": "filesystem", - "format": "ntfs", - "mountpoint": "" - } - }, - "UUID:708B-C192": { - "size": "100M", - "content": { - "type": "filesystem", - "format": "vfat", - "mountpoint": "" - }, - "type": "EF00" - }, - "UUID:90B6FB81B6FB65DE": { - "size": "544M", - "content": { - "type": "filesystem", - "format": "ntfs", - "mountpoint": "" - } - } + "MODEL:CT2000MX500SSD1,SN:2105E4F0DE85": { + "device": "/dev/sdc", + "type": "disk", + "content": { + "type": "gpt", + "partitions": { + "UUID:2740-1628": { + "_index": 1, + "size": "1048576000", + "content": { + "type": "filesystem", + "format": "vfat", + "mountpoint": "/boot" + }, + "type": "EF00" + }, + "UUID:ca548f68-4e51-4364-b366-690ecc27590f": { + "_index": 2, + "size": "631794302976", + "content": { + "type": "filesystem", + "format": "ext4", + "mountpoint": "/" } + }, + "UUID:879299db-4147-4fac-9f34-5e8e92073efc": { + "_index": 3, + "size": "631810031616", + "content": { + "type": "filesystem", + "format": "crypto_LUKS", + "mountpoint": null + } + }, + "UUID:9A48E8C248E89E6F": { + "_index": 4, + "size": "735743836160", + "content": { + "type": "filesystem", + "format": "ntfs", + "mountpoint": "/mnt/s" + } + } } + } }, - "MODEL:CT2000MX500SSD1,SN:2105E4F0DE85": { - "device": "sdd", - "type": "disk", - "content": { - "partitions": { - "UUID:2740-1628": { - "size": "1000M", - "content": { - "type": "filesystem", - "format": "vfat", - "mountpoint": "/boot" - }, - "type": "EF00" - }, - "UUID:ca548f68-4e51-4364-b366-690ecc27590f": { - "size": "588.4G", - "content": { - "type": "filesystem", - "format": "ext4", - "mountpoint": "/" - } - }, - "UUID:879299db-4147-4fac-9f34-5e8e92073efc": { - "size": "588.4G", - "content": { - "type": "filesystem", - "format": "crypto_LUKS", - "mountpoint": "" - } - }, - "UUID:9A48E8C248E89E6F": { - "size": "685.2G", - "content": { - "type": "filesystem", - "format": "ntfs", - "mountpoint": "/mnt/s" - } - } + "MODEL:ST2000LM003 HN-M201RAD,SN:S321J9GFC01497": { + "device": "/dev/sdd", + "type": "disk", + "content": { + "type": "gpt", + "partitions": { + "PARTUUID:c090741b-68e2-4867-96df-2ec00765f2c0": { + "_index": 1, + "size": "134217728", + "content": { + "type": "filesystem", + "format": "", + "mountpoint": null + } + }, + "UUID:7CA41E5EA41E1B6A": { + "_index": 2, + "size": "2000263577600", + "content": { + "type": "filesystem", + "format": "ntfs", + "mountpoint": "/mnt/g" } + } } + } } + }, + "lvm_vg": {}, + "mdadm": {}, + "nodev": {}, + "zpool": {} } + } } \ No newline at end of file diff --git a/tests/disko_lib/types_disk/partial_failure_dos_table-lsblk-output.json b/tests/disko_lib/types_disk/partial_failure_dos_table-lsblk-output.json index 33e70e7d..2b48ab76 100644 --- a/tests/disko_lib/types_disk/partial_failure_dos_table-lsblk-output.json +++ b/tests/disko_lib/types_disk/partial_failure_dos_table-lsblk-output.json @@ -1,13 +1,13 @@ { "blockdevices": [ { - "id-link": "wwn-0x50004cf20ecb1679", + "id-link": "wwn-0x5002538043584d30", "fstype": null, "fssize": null, "fsuse%": null, "kname": "sda", "label": null, - "model": "ST2000LM003 HN-M201RAD", + "model": "SAMSUNG SSD PM830 mSATA 256GB", "partflags": null, "partlabel": null, "partn": null, @@ -15,75 +15,18 @@ "parttypename": null, "partuuid": null, "path": "/dev/sda", - "phy-sec": 4096, - "pttype": "gpt", - "rev": "2BC10001", - "serial": "S321J9GFC01497", - "size": "1.8T", + "phy-sec": 512, + "pttype": "dos", + "rev": "CXM13D1Q", + "serial": "S0XPNYAD407619", + "size": 256060514304, "start": null, "mountpoint": null, "mountpoints": [ null ], "type": "disk", - "uuid": null, - "children": [ - { - "id-link": "wwn-0x50004cf20ecb1679-part1", - "fstype": null, - "fssize": null, - "fsuse%": null, - "kname": "sda1", - "label": null, - "model": null, - "partflags": null, - "partlabel": "Microsoft reserved partition", - "partn": 1, - "parttype": "e3c9e316-0b5c-4db8-817d-f92df00215ae", - "parttypename": "Microsoft reserved", - "partuuid": "c090741b-68e2-4867-96df-2ec00765f2c0", - "path": "/dev/sda1", - "phy-sec": 4096, - "pttype": "gpt", - "rev": null, - "serial": null, - "size": "128M", - "start": 34, - "mountpoint": null, - "mountpoints": [ - null - ], - "type": "part", - "uuid": null - },{ - "id-link": "wwn-0x50004cf20ecb1679-part2", - "fstype": "ntfs", - "fssize": "1.8T", - "fsuse%": "51%", - "kname": "sda2", - "label": "Groß", - "model": null, - "partflags": null, - "partlabel": "Basic data partition", - "partn": 2, - "parttype": "ebd0a0a2-b9e5-4433-87c0-68b6b72699c7", - "parttypename": "Microsoft basic data", - "partuuid": "425b6415-db2b-4684-b619-994bdd0f9b71", - "path": "/dev/sda2", - "phy-sec": 4096, - "pttype": "gpt", - "rev": null, - "serial": null, - "size": "1.8T", - "start": 264192, - "mountpoint": "/mnt/g", - "mountpoints": [ - "/mnt/g" - ], - "type": "part", - "uuid": "7CA41E5EA41E1B6A" - } - ] + "uuid": null },{ "id-link": "wwn-0x5001b444a63292c3", "fstype": null, @@ -103,7 +46,7 @@ "pttype": "gpt", "rev": "X4133101", "serial": "171887425854", - "size": "238.5G", + "size": 256060514304, "start": null, "mountpoint": null, "mountpoints": [ @@ -131,7 +74,7 @@ "pttype": "gpt", "rev": null, "serial": null, - "size": "499.5M", + "size": 523730944, "start": 64, "mountpoint": null, "mountpoints": [ @@ -158,7 +101,7 @@ "pttype": "gpt", "rev": null, "serial": null, - "size": "236.8G", + "size": 254301093376, "start": 1022976, "mountpoint": null, "mountpoints": [ @@ -185,7 +128,7 @@ "pttype": "gpt", "rev": null, "serial": null, - "size": "100M", + "size": 104857600, "start": 497704960, "mountpoint": null, "mountpoints": [ @@ -212,7 +155,7 @@ "pttype": "gpt", "rev": null, "serial": null, - "size": "544M", + "size": 570425344, "start": 497909760, "mountpoint": null, "mountpoints": [ @@ -222,39 +165,12 @@ "uuid": "90B6FB81B6FB65DE" } ] - },{ - "id-link": "wwn-0x5002538043584d30", - "fstype": null, - "fssize": null, - "fsuse%": null, - "kname": "sdc", - "label": null, - "model": "SAMSUNG SSD PM830 mSATA 256GB", - "partflags": null, - "partlabel": null, - "partn": null, - "parttype": null, - "parttypename": null, - "partuuid": null, - "path": "/dev/sdc", - "phy-sec": 512, - "pttype": "dos", - "rev": "CXM13D1Q", - "serial": "S0XPNYAD407619", - "size": "238.5G", - "start": null, - "mountpoint": null, - "mountpoints": [ - null - ], - "type": "disk", - "uuid": null },{ "id-link": "wwn-0x500a0751e4f0de85", "fstype": null, "fssize": null, "fsuse%": null, - "kname": "sdd", + "kname": "sdc", "label": null, "model": "CT2000MX500SSD1", "partflags": null, @@ -263,12 +179,12 @@ "parttype": null, "parttypename": null, "partuuid": null, - "path": "/dev/sdd", + "path": "/dev/sdc", "phy-sec": 4096, "pttype": "gpt", "rev": "M3CR033", "serial": "2105E4F0DE85", - "size": "1.8T", + "size": 2000398934016, "start": null, "mountpoint": null, "mountpoints": [ @@ -280,9 +196,9 @@ { "id-link": "wwn-0x500a0751e4f0de85-part1", "fstype": "vfat", - "fssize": "998M", - "fsuse%": "6%", - "kname": "sdd1", + "fssize": 1046478848, + "fsuse%": "9%", + "kname": "sdc1", "label": "boot", "model": null, "partflags": null, @@ -291,12 +207,12 @@ "parttype": "c12a7328-f81f-11d2-ba4b-00a0c93ec93b", "parttypename": "EFI System", "partuuid": "7f623bea-5891-49ee-9980-6534716f0f50", - "path": "/dev/sdd1", + "path": "/dev/sdc1", "phy-sec": 4096, "pttype": "gpt", "rev": null, "serial": null, - "size": "1000M", + "size": 1048576000, "start": 2048, "mountpoint": "/boot", "mountpoints": [ @@ -307,9 +223,9 @@ },{ "id-link": "wwn-0x500a0751e4f0de85-part2", "fstype": "ext4", - "fssize": "578.1G", - "fsuse%": "10%", - "kname": "sdd2", + "fssize": 620727574528, + "fsuse%": "13%", + "kname": "sdc2", "label": "root", "model": null, "partflags": null, @@ -318,12 +234,12 @@ "parttype": "0fc63daf-8483-4772-8e79-3d69d8477de4", "parttypename": "Linux filesystem", "partuuid": "d562416c-1632-40c6-88ed-5095fb921698", - "path": "/dev/sdd2", + "path": "/dev/sdc2", "phy-sec": 4096, "pttype": "gpt", "rev": null, "serial": null, - "size": "588.4G", + "size": 631794302976, "start": 2050048, "mountpoint": "/", "mountpoints": [ @@ -336,7 +252,7 @@ "fstype": "crypto_LUKS", "fssize": null, "fsuse%": null, - "kname": "sdd3", + "kname": "sdc3", "label": "home", "model": null, "partflags": null, @@ -345,12 +261,12 @@ "parttype": "0fc63daf-8483-4772-8e79-3d69d8477de4", "parttypename": "Linux filesystem", "partuuid": "0ca34f17-5c80-4f2d-97b5-2a5f559b55b5", - "path": "/dev/sdd3", + "path": "/dev/sdc3", "phy-sec": 4096, "pttype": "gpt", "rev": null, "serial": null, - "size": "588.4G", + "size": 631810031616, "start": 1236023296, "mountpoint": null, "mountpoints": [ @@ -362,8 +278,8 @@ { "id-link": "dm-name-crypt-home", "fstype": "btrfs", - "fssize": "588.4G", - "fsuse%": "49%", + "fssize": 631793254400, + "fsuse%": "47%", "kname": "dm-0", "label": null, "model": null, @@ -378,7 +294,7 @@ "pttype": null, "rev": null, "serial": null, - "size": "588.4G", + "size": 631793254400, "start": null, "mountpoint": "/home", "mountpoints": [ @@ -391,9 +307,9 @@ },{ "id-link": "wwn-0x500a0751e4f0de85-part4", "fstype": "ntfs", - "fssize": "685.2G", - "fsuse%": "90%", - "kname": "sdd4", + "fssize": 735743832064, + "fsuse%": "92%", + "kname": "sdc4", "label": "Schnell", "model": null, "partflags": null, @@ -402,12 +318,12 @@ "parttype": "ebd0a0a2-b9e5-4433-87c0-68b6b72699c7", "parttypename": "Microsoft basic data", "partuuid": "1d55f1cf-5a42-4fa1-a0ba-573fbd0d152c", - "path": "/dev/sdd4", + "path": "/dev/sdc4", "phy-sec": 4096, "pttype": "gpt", "rev": null, "serial": null, - "size": "685.2G", + "size": 735743836160, "start": 2470027264, "mountpoint": "/mnt/s", "mountpoints": [ @@ -417,6 +333,90 @@ "uuid": "9A48E8C248E89E6F" } ] + },{ + "id-link": "wwn-0x50004cf20ecb1679", + "fstype": null, + "fssize": null, + "fsuse%": null, + "kname": "sdd", + "label": null, + "model": "ST2000LM003 HN-M201RAD", + "partflags": null, + "partlabel": null, + "partn": null, + "parttype": null, + "parttypename": null, + "partuuid": null, + "path": "/dev/sdd", + "phy-sec": 4096, + "pttype": "gpt", + "rev": "2BC10001", + "serial": "S321J9GFC01497", + "size": 2000398934016, + "start": null, + "mountpoint": null, + "mountpoints": [ + null + ], + "type": "disk", + "uuid": null, + "children": [ + { + "id-link": "wwn-0x50004cf20ecb1679-part1", + "fstype": null, + "fssize": null, + "fsuse%": null, + "kname": "sdd1", + "label": null, + "model": null, + "partflags": null, + "partlabel": "Microsoft reserved partition", + "partn": 1, + "parttype": "e3c9e316-0b5c-4db8-817d-f92df00215ae", + "parttypename": "Microsoft reserved", + "partuuid": "c090741b-68e2-4867-96df-2ec00765f2c0", + "path": "/dev/sdd1", + "phy-sec": 4096, + "pttype": "gpt", + "rev": null, + "serial": null, + "size": 134217728, + "start": 34, + "mountpoint": null, + "mountpoints": [ + null + ], + "type": "part", + "uuid": null + },{ + "id-link": "wwn-0x50004cf20ecb1679-part2", + "fstype": "ntfs", + "fssize": 2000263573504, + "fsuse%": "51%", + "kname": "sdd2", + "label": "Groß", + "model": null, + "partflags": null, + "partlabel": "Basic data partition", + "partn": 2, + "parttype": "ebd0a0a2-b9e5-4433-87c0-68b6b72699c7", + "parttypename": "Microsoft basic data", + "partuuid": "425b6415-db2b-4684-b619-994bdd0f9b71", + "path": "/dev/sdd2", + "phy-sec": 4096, + "pttype": "gpt", + "rev": null, + "serial": null, + "size": 2000263577600, + "start": 264192, + "mountpoint": "/mnt/g", + "mountpoints": [ + "/mnt/g" + ], + "type": "part", + "uuid": "7CA41E5EA41E1B6A" + } + ] } ] } diff --git a/tests/disko_lib/types_disk/test_types_disk.py b/tests/disko_lib/types_disk/test_types_disk.py index c6cc45e0..ea3b898e 100644 --- a/tests/disko_lib/types_disk/test_types_disk.py +++ b/tests/disko_lib/types_disk/test_types_disk.py @@ -2,8 +2,8 @@ from pathlib import Path, PosixPath from disko_lib.messages import err_unsupported_pttype, warn_generate_partial_failure -from disko_lib.result import DiskoError, DiskoSuccess -from disko_lib.types import disk +from disko_lib.result import DiskoPartialSuccess, DiskoSuccess +from disko_lib.generate_config import generate_config from disko_lib.types import device CURRENT_DIR = Path(__file__).parent @@ -15,22 +15,22 @@ def test_generate_config_partial_failure_dos_table() -> None: assert isinstance(lsblk_result, DiskoSuccess) - result = disk.generate_config(lsblk_result.value) + result = generate_config(lsblk_result.value) - assert isinstance(result, DiskoError) + assert isinstance(result, DiskoPartialSuccess) assert result.messages[0].is_message(err_unsupported_pttype) assert result.messages[0].details == { "pttype": "dos", - "device": PosixPath("/dev/sdc"), + "device": PosixPath("/dev/sda"), } assert result.messages[1].is_message(warn_generate_partial_failure) with open(CURRENT_DIR / "partial_failure_dos_table-generate-result.json") as f: - assert result.messages[1].details["partial_config"] == json.load(f) # type: ignore[misc] - assert result.messages[1].details["failed_devices"] == [PosixPath("/dev/sdc")] - assert result.messages[1].details["successful_devices"] == [ - PosixPath("/dev/sda"), + assert result.value == json.load(f) # type: ignore[misc] + assert result.messages[1].details["failed"] == [PosixPath("/dev/sda")] + assert result.messages[1].details["successful"] == [ PosixPath("/dev/sdb"), + PosixPath("/dev/sdc"), PosixPath("/dev/sdd"), ] From db57281c83c91345a829c60a8e81780af4b78eb8 Mon Sep 17 00:00:00 2001 From: Felix Uhl Date: Wed, 27 Nov 2024 23:06:19 +0100 Subject: [PATCH 36/37] disko2: Fix dedenting bug --- src/disko_lib/logging.py | 12 +++---- tests/disko_lib/test_logging.py | 55 +++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 6 deletions(-) create mode 100644 tests/disko_lib/test_logging.py diff --git a/src/disko_lib/logging.py b/src/disko_lib/logging.py index ac25477a..c2479a8f 100644 --- a/src/disko_lib/logging.py +++ b/src/disko_lib/logging.py @@ -12,13 +12,13 @@ ) from .ansi import Colors -from .messages.colors import RESET logging.basicConfig(format="%(message)s", level=logging.INFO) LOGGER = logging.getLogger("disko_logger") MessageTypes = Literal["bug", "error", "warning", "info", "help", "debug"] +RESET = Colors.RESET BG_COLOR_MAP = { "bug": Colors.BG_RED, "error": Colors.BG_RED, @@ -101,19 +101,19 @@ def dedent_start_lines(lines: list[str]) -> list[str]: if dedent_width == 0: return lines - match_indent_regex = re.compile(f"^ {{{dedent_width}}}") + indent_string = " " * dedent_width dedented_lines = [] stop_dedenting = False for line in lines: - if not line.startswith(" "): - stop_dedenting = True - if stop_dedenting: dedented_lines.append(line) continue - dedented_line = re.sub(match_indent_regex, "", line) + if not line.startswith(indent_string): + stop_dedenting = True + + dedented_line = re.sub(indent_string, "", line) dedented_lines.append(dedented_line) return dedented_lines diff --git a/tests/disko_lib/test_logging.py b/tests/disko_lib/test_logging.py new file mode 100644 index 00000000..afc0bf8d --- /dev/null +++ b/tests/disko_lib/test_logging.py @@ -0,0 +1,55 @@ +from disko_lib.logging import dedent_start_lines + + +def test_dedent_start_lines() -> None: + # Some later lines are indented as much or more than the first line, + # but they should NOT be dedented! + raw_lines = """ + Successfully generated config for some devices. + Errors are printed above. The generated partial config is: + { + "disko": { + "devices": { + "disk": { + "MODEL:SanDisk SD8TB8U256G1001,SN:171887425854": { + "device": "/dev/sdb", + "type": "disk", + "content": { + "type": "gpt", + "partitions": { + "UUID:01D60069CEED69C0": { + "_index": 1, + "size": "523730944", + "content": { + "type": "filesystem", + "format": "ntfs", + "mountpoint": null + } + """ + + expected_output = """Successfully generated config for some devices. +Errors are printed above. The generated partial config is: +{ + "disko": { + "devices": { + "disk": { + "MODEL:SanDisk SD8TB8U256G1001,SN:171887425854": { + "device": "/dev/sdb", + "type": "disk", + "content": { + "type": "gpt", + "partitions": { + "UUID:01D60069CEED69C0": { + "_index": 1, + "size": "523730944", + "content": { + "type": "filesystem", + "format": "ntfs", + "mountpoint": null + } + """ + + lines = raw_lines.splitlines()[1:] + result = "\n".join(dedent_start_lines(lines)) + + assert result == expected_output From 59edac4aba8673d9b898fbf3add9b6c87c390e52 Mon Sep 17 00:00:00 2001 From: Felix Uhl Date: Wed, 27 Nov 2024 23:07:54 +0100 Subject: [PATCH 37/37] disko2: Add jupyter notebook for experiments and debugging --- .vscode/extensions.json | 17 ++++---- flake.nix | 1 + src/experiments.ipynb | 91 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 101 insertions(+), 8 deletions(-) create mode 100644 src/experiments.ipynb diff --git a/.vscode/extensions.json b/.vscode/extensions.json index c568ac05..5853a6dd 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,10 +1,11 @@ { - "recommendations": [ - "mkhl.direnv", - "jnoortheen.nix-ide", - "ms-python.python", - "ms-python.debugpy", - "ms-python.mypy-type-checker", - "charliermarsh.ruff" - ] + "recommendations": [ + "mkhl.direnv", + "jnoortheen.nix-ide", + "ms-python.python", + "ms-python.debugpy", + "ms-python.mypy-type-checker", + "charliermarsh.ruff", + "ms-toolsai.jupyter" + ] } \ No newline at end of file diff --git a/flake.nix b/flake.nix index 88937273..a45317c6 100644 --- a/flake.nix +++ b/flake.nix @@ -99,6 +99,7 @@ (python3.withPackages (ps: [ ps.mypy # Static type checker ps.pytest # Test runner + ps.ipykernel # Jupyter kernel for experimenting # Actual runtime depedencies ps.pydantic # Validation of nixos configuration diff --git a/src/experiments.ipynb b/src/experiments.ipynb new file mode 100644 index 00000000..0a938307 --- /dev/null +++ b/src/experiments.ipynb @@ -0,0 +1,91 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Experiments\n", + "\n", + "This notebook is very useful for debugging parts of disko without having to run\n", + "the entire CLI. You can import anything from `disko` or `disko_lib` and call\n", + "functions directly. With VSCode, you can also start a debugging session from\n", + "within a cell!" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Run this cell to automatically reload modules on every execution.\n", + "# This avoids having to restart the kernel every time you make a change to a module.\n", + "%load_ext autoreload\n", + "%autoreload 2" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Examples\n", + "\n", + "These are some examples how you can use this notebook. Feel free to copy it and\n", + "create your own experiments. If you used a notebook to fix some bug, please\n", + "copy the code into a regression test and run it with pytest!" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Errors are printed above. The generated partial config is:\n", + "{\n", + " \"disko\": {\n", + " \"devices\": {\n", + " \"disk\": {\n" + ] + } + ], + "source": [ + "from disko_lib.logging import dedent_start_lines\n", + "\n", + "lines = \"\"\"\n", + " Errors are printed above. The generated partial config is:\n", + " {\n", + " \"disko\": {\n", + " \"devices\": {\n", + " \"disk\": {\n", + "\"\"\"\n", + "\n", + "print(\"\\n\".join(dedent_start_lines(lines.splitlines()[1:])))" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.5" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +}