diff --git a/default.nix b/default.nix index 9adbbde..b7a3bef 100644 --- a/default.nix +++ b/default.nix @@ -52,6 +52,10 @@ in dynix.overrideAttrs (final: prev: let self = final.finalPackage; in lib.recursiveUpdate prev { passthru = { + ts = let + scope = pkgs.callPackage ./modules/tests.nix { }; + in scope.packages scope; + dync = self.nixos.config.dynamicism; dyno = self.nixos.options.dynamicism; gotosocial = self.nixos.options.dynamicism.for.valueMeta.attrs.gotosocial.configuration; diff --git a/modules/dynamicism/default.nix b/modules/dynamicism/default.nix index 3d1dcaa..8e4764c 100644 --- a/modules/dynamicism/default.nix +++ b/modules/dynamicism/default.nix @@ -1,38 +1,42 @@ { pkgs, lib, config, options, ... }: let - inherit (lib.modules) - mkIf - ; inherit (lib.options) mkOption - mkEnableOption - literalExpression showOption ; t = lib.types; - cfg = config.dynamicism; + + inherit (import ./lib.nix { inherit lib; }) + typeCheck + convenientAttrPath + concatFoldl + recUpdateFoldl + recUpdateFoldlAttrs + ; + + evalNixos = import (pkgs.path + "/nixos"); + opts = options.dynamicism; + subOpts = lib.mapAttrs (_: metaAttr: metaAttr.configuration.options) options.dynamicism.for.valueMeta.attrs; settingsFormat = pkgs.formats.yaml { }; - concatFoldl = f: list: lib.foldl' (acc: value: acc ++ (f value)) [ ] list; - recUpdateFoldlAttrs = f: attrs: lib.foldlAttrs (acc: name: value: lib.recursiveUpdate acc (f name value)) { } attrs; - - finalSettingsFor = { ... }@submod: lib.foldl (acc: optPath: let - next = - assert lib.isList optPath; - lib.setAttrByPath optPath (lib.getAttrFromPath optPath config); - in lib.recursiveUpdate acc next) { } submod.source-options; + finalSettingsFor = { ... }@submod: recUpdateFoldl (optPath: + lib.setAttrByPath optPath (lib.getAttrFromPath optPath config) + ) submod.source-options; ourAssertions = lib.concatAttrValues { - unitsExist = concatFoldl (submod: let - next = lib.map (unit: assert lib.isString unit; { + unitsExist = subOpts + |> lib.attrValues + |> concatFoldl (submod: submod.systemd-services-updated.value + |> lib.map (unit: { assertion = config.systemd.units.${unit}.enable or false; message = '' - '${showOption submod.systemd-services-updated.loc}' specified non-existent unit '${unit}' + ${showOption submod.systemd-services-updated.loc}' specified non-existent unit '${unit}' ''; - }) submod.systemd-services-updated.value; - in lib.optionals submod.enable.value next) (lib.attrValues subOpts); + }) + |> lib.optionals submod.enable.value + ); optsExist = concatFoldl (submod: lib.optionals submod.enable.value (lib.map (optPath: { assertion = lib.hasAttrByPath optPath options; @@ -62,6 +66,14 @@ in Attrset of each `source-options` tree to their actual values. ''; }; + + doChange = mkOption { + type = t.functionTo t.pathInStore; + readOnly = true; + description = '' + The function to call to Do The Thing. + ''; + }; }; # Assertions. @@ -70,6 +82,39 @@ in # # Generic implementation. # + config.dynamicism.doChange = { + option, + value, + configuration ? builtins.getEnv "NIXOS_CONFIG", + }: let + loc = opts.doChange.loc ++ [ "(function argument)" "value" ]; + option' = typeCheck loc convenientAttrPath option; + nixosAfter = evalNixos { + configuration = { config, ... }: { + imports = [ + configuration + (lib.setAttrByPath option' (lib.mkOverride (-999) value)) + ]; + + environment.systemPackages = [ + config.dynamicism.for.gotosocial.activate + ]; + }; + }; + + allActivations = lib.mapAttrsToList (name: submod: submod.activate) config.dynamicism.for; + allActivationScripts = pkgs.writeShellApplication { + name = "dynamicism-activate"; + runtimeInputs = allActivations; + text = nixosAfter.config.dynamicism.for + |> lib.mapAttrsToList (name: submod: '' + echo "Activating dynamicism for ${name}" + ${lib.getExe submod.activate} + '') + |> lib.concatStringsSep "\n"; + }; + in allActivationScripts; + config.dynamicism.finalSettings = lib.asserts.checkAssertWarn ourAssertions [ ] ( recUpdateFoldlAttrs (name: { ... }@submod: finalSettingsFor submod) config.dynamicism.for ); diff --git a/modules/dynamicism/lib.nix b/modules/dynamicism/lib.nix new file mode 100644 index 0000000..a988ae4 --- /dev/null +++ b/modules/dynamicism/lib.nix @@ -0,0 +1,24 @@ +{ + lib ? import , +}: let + t = lib.types; +in lib.fix (self: { + /** Perform module-system type checking and resolving on a single option value. */ + typeCheck = loc: option: value: + assert lib.isOptionType option; + assert lib.isList loc; + assert lib.all lib.isString loc; + let + merged = lib.modules.mergeDefinitions loc option [ { + inherit value; + file = "«inline»"; + } ]; + in merged.mergedValue; + + /** Either a list of strings, or a dotted string that will be split. */ + convenientAttrPath = t.coercedTo t.str (lib.splitString ".") (t.listOf t.str); + + concatFoldl = f: list: lib.foldl' (acc: value: acc ++ (f value)) [ ] list; + recUpdateFoldl = f: list: lib.foldl' (acc: value: lib.recursiveUpdate acc (f value)) { } list; + recUpdateFoldlAttrs = f: attrs: lib.foldlAttrs (acc: name: value: lib.recursiveUpdate acc (f name value)) { } attrs; +}) diff --git a/modules/tests-common.nix b/modules/tests-common.nix new file mode 100644 index 0000000..322771e --- /dev/null +++ b/modules/tests-common.nix @@ -0,0 +1,4 @@ +{ pkgs, ... }: +{ + nix.package = pkgs.lixPackageSets.latest.lix; +} diff --git a/modules/tests-main.py b/modules/tests-main.py new file mode 100644 index 0000000..dbd33a6 --- /dev/null +++ b/modules/tests-main.py @@ -0,0 +1,76 @@ +#import re +from pathlib import Path +from typing import cast, TYPE_CHECKING + +from test_driver.machine import Machine +from test_driver.errors import RequestedAssertionFailed + +DEFAULT_NIX = "@DEFAULT_NIX@" +CONFIGURATION_NIX = "@CONFIGURATION_NIX@" +DYNAMICISM = "@DYNAMICISM@" + +if TYPE_CHECKING: + global machine + machine = cast(Machine, ...) + +def run_log(machine: Machine, *commands: str, timeout: int | None = 60) -> str: + output = "" + for command in commands: + with machine.nested(f"must succeed: {command}"): + (status, out) = machine.execute(f"{command} | tee /dev/stderr", timeout=timeout) + if status != 0: + machine.log(f"output: {out}") + raise RequestedAssertionFailed( + f"command `{command}` failed (exit code {status})", + ) + output += out + + return output + +def get_config_file() -> Path: + machine.wait_for_unit("gotosocial.service") + gotosocial_pid = int(machine.get_unit_property("gotosocial.service", "MainPID")) + print(f"{gotosocial_pid=}") + + cmdline = machine.succeed(f"cat /proc/{gotosocial_pid}/cmdline") + cmdline_args = cmdline.split("\0") + + config_file_idx = cmdline_args.index("--config-path") + 1 + config_file = Path(cmdline_args[config_file_idx]) + + machine.log(f"copying from VM: {config_file=}") + machine.copy_from_vm(config_file.as_posix()) + + return machine.out_dir / config_file.name + + +machine.wait_for_unit("default.target") +assert "lix" in run_log(machine, "nix --version").lower() + +print(f"{CONFIGURATION_NIX=}") +machine.succeed("mkdir -vp /etc/nixos") +machine.copy_from_host(CONFIGURATION_NIX, "/etc/nixos") +machine.copy_from_host(DYNAMICISM, "/etc/nixos") + +run_log(machine, f"nix build --log-format multiline-with-logs --impure -E 'import {{ configuration = {CONFIGURATION_NIX}; }}'") +run_log(machine, f"nixos-rebuild switch --file {CONFIGURATION_NIX} --verbose --print-build-logs") + +config_file_local = get_config_file() +machine.log(f"opening copied file: {config_file_local=}") +with open(config_file_local, "r") as f: + text = f.read() + lines = text.splitlines() + application_name = next(line for line in lines if line.startswith("application-name:")) + assert "gotosocial-for-machine" in application_name, f"'gotosocial-for-machine' should be in {application_name=}" + +print(f"{DEFAULT_NIX=}") + +run_log(machine, "eza -lah --color=always --group-directories-first --tree /etc/") + +#exec_start = machine.succeed("systemctl show gotosocial.service --property=ExecStart --value") +#exec_start = machine.succeed("systemctl show gotosocial.service --property=ExecStart --value") + +#service_text = machine.succeed("systemctl show gotosocial.service") +#service_props = dict(line.split("=", maxsplit=1) for line in service_text.splitlines()) +#exec_start = service_props['ExecStart'] +#print(f"{exec_start=}") diff --git a/modules/tests.nix b/modules/tests.nix new file mode 100644 index 0000000..917c7bd --- /dev/null +++ b/modules/tests.nix @@ -0,0 +1,52 @@ +{ + pkgs, + lib, + testers, +}: + let + inherit (testers) runNixOSTest; +in lib.makeScope lib.callPackageWith (self: { + main = runNixOSTest { + name = "nixos-test-dynamicism-main"; + + defaults = { pkgs, ... }: { + imports = [ ./dynamicism ]; + + nix = { + package = pkgs.lixPackageSets.latest.lix; + settings.experimental-features = [ "nix-command" ]; + nixPath = [ "nixpkgs=${pkgs.path}" ]; + }; + + environment.shellAliases = { + ls = "eza --long --header --group --group-directories-first --classify --binary"; + }; + environment.systemPackages = with pkgs; [ + eza + fd + ripgrep + ]; + }; + + nodes.machine = { name, ... }: { + #services.gotosocial = { + # enable = true; + # setupPostgresqlDB = true; + # settings = { + # application-name = "gotosocial-for-${name}"; + # host = "${name}.local"; + # }; + #}; + # + #dynamicism.for.gotosocial.enable = true; + }; + + # What's a little IFD between friends? + testScript = pkgs.replaceVars ./tests-main.py { + DEFAULT_NIX = ../default.nix; + CONFIGURATION_NIX = ./tests-configuration.nix; + DYNAMICISM = ./dynamicism; + } + |> builtins.readFile; + }; +}) diff --git a/shell.nix b/shell.nix index d306f2e..c73c766 100644 --- a/shell.nix +++ b/shell.nix @@ -14,4 +14,10 @@ in devShell.overrideAttrs (prev: lib.recursiveUpdate prev { passthru = { inherit byStdenv; }; + env.PYTHONPATH = [ + "${pkgs.python3Packages.beartype}/${pkgs.python3.sitePackages}" + ] |> lib.concatStringsSep ":"; + packages = prev.packages or [ ] ++ [ + pkgs.python3Packages.beartype + ]; }) diff --git a/tests/basic/configuration-package.nix b/tests/basic/configuration-package.nix new file mode 100644 index 0000000..4b50af6 --- /dev/null +++ b/tests/basic/configuration-package.nix @@ -0,0 +1,9 @@ +{ + runCommand, +}: runCommand "tests-basic-configuration-dot-nix" { +} '' + set -euo pipefail + mkdir -vp "$out/share/nixos" + cp -rv ${./configuration.nix} "$out/share/nixos/configuration.nix" + cp -rv ${../../modules/dynamicism} "$out/share/nixos/dynamicism" +'' diff --git a/tests/basic/configuration.nix b/tests/basic/configuration.nix new file mode 100644 index 0000000..06470e3 --- /dev/null +++ b/tests/basic/configuration.nix @@ -0,0 +1,69 @@ +{ pkgs, lib, config, modulesPath, ... }: +let + name = config.networking.hostName; + nixosLibPath = (modulesPath + "/../lib"); + moduleList = import (modulesPath + "/module-list.nix"); + + optionalPath = p: lib.optional (builtins.pathExists p) p; +in +assert builtins.pathExists nixosLibPath; +builtins.seq lib +builtins.seq modulesPath +builtins.seq moduleList +{ + imports = moduleList ++ [ + (modulesPath + "/testing/test-instrumentation.nix") + ] ++ lib.concatLists [ + (optionalPath ./hardware-configuration.nix) + (optionalPath ./dynamicism) + (optionalPath ../../modules/dynamicism) + ]; + + system.switch.enable = true; + documentation.enable = false; + + networking.hostName = "machine"; + + boot.loader.grub = { + enable = true; + device = "/dev/vda"; + forceInstall = true; + }; + + nix = { + package = pkgs.lixPackageSets.latest.lix; + nixPath = [ "nixpkgs=${pkgs.path}" ]; + + settings = { + experimental-features = [ "nix-command" "pipe-operator" ]; + substituters = lib.mkForce [ ]; + hashed-mirrors = null; + connect-timeout = 1; + }; + }; + + services.gotosocial = { + enable = true; + setupPostgresqlDB = true; + settings = { + application-name = "gotosocial-for-${name}"; + host = "${name}.local"; + }; + }; + + dynamicism.for.gotosocial.enable = true; + + environment.pathsToLink = [ "/share" ]; + environment.variables = { + "NIXOS_CONFIG" = "/etc/nixos/configuration.nix"; + }; + + environment.shellAliases = { + ls = "eza --long --header --group --group-directories-first --classify --binary"; + }; + environment.systemPackages = with pkgs; [ + eza + fd + ripgrep + ]; +} diff --git a/tests/basic/default.nix b/tests/basic/default.nix new file mode 100644 index 0000000..7396103 --- /dev/null +++ b/tests/basic/default.nix @@ -0,0 +1,7 @@ +/** + * Convenience shortcut for running this test from the command-line. + * Normally this test is initialized from /tests/default.nix. + */ +{ + pkgs ? import { }, +}: pkgs.testers.runNixOSTest ./test.nix diff --git a/tests/basic/test-script.py b/tests/basic/test-script.py new file mode 100644 index 0000000..f507503 --- /dev/null +++ b/tests/basic/test-script.py @@ -0,0 +1,84 @@ +from pathlib import Path +import shlex +import textwrap +from typing import cast, TYPE_CHECKING + +from beartype import beartype + +from test_driver.machine import Machine +from test_driver.errors import RequestedAssertionFailed + +if TYPE_CHECKING: + global machine + machine = cast(Machine, ...) + assert machine.shell is not None + +ls = "eza -lah --color=always --group-directories-first" + +@beartype +def run_log(machine: Machine, *commands: str, timeout: int | None = 60) -> str: + output = "" + for command in commands: + with machine.nested(f"must succeed: {command}"): + (status, out) = machine.execute(f"{command} | tee /dev/stderr", timeout=timeout) + if status != 0: + machine.log(f"output: {out}") + raise RequestedAssertionFailed( + f"command `{command}` failed (exit code {status})", + ) + output += out + + return output + +@beartype +def get_config_file() -> str: + machine.wait_for_unit("gotosocial.service") + gotosocial_pid = int(machine.get_unit_property("gotosocial.service", "MainPID")) + print(f"{gotosocial_pid=}") + + cmdline = machine.succeed(f"cat /proc/{gotosocial_pid}/cmdline") + cmdline_args = cmdline.split("\0") + + config_file_idx = cmdline_args.index("--config-path") + 1 + config_file = Path(cmdline_args[config_file_idx]) + + machine.log(f"copying from VM: {config_file=}") + machine.copy_from_vm(config_file.as_posix()) + + config_file_path = machine.out_dir / config_file.name + with open(config_file_path, "r") as f: + return f.read() + + +machine.wait_for_unit("default.target") +assert "lix" in machine.succeed("nix --version").lower() + +run_log(machine, "nixos-generate-config") +machine.succeed("mkdir -vp /etc/nixos") +machine.succeed("cp -rv /run/current-system/sw/share/nixos/* /etc/nixos/") +machine.succeed("env PAGER= nixos-rebuild switch --log-format raw-with-logs -v --fallback >&2") + +config_text = get_config_file() +lines = config_text.splitlines() +application_name = next((line for line in lines if line.startswith("application-name:")), None) +assert application_name is not None, f"no 'application-name:' found in config file: {textwrap.indent(config_text, "")}" +assert "gotosocial-for-machine" in application_name, f"'gotosocial-for-machine' should be in {application_name=}" + +new_app_name = "yay!" +expr = textwrap.dedent(f""" + let + nixos = import {{ }}; + in nixos.config.dynamicism.doChange {{ + option = "services.gotosocial.settings.application-name"; + value = "{new_app_name}"; + }} +""").strip() +machine.succeed(rf""" + nix run --show-trace --log-format raw-with-logs --impure -E {shlex.quote(expr)} +""".strip()) + +config_file_new = get_config_file() +lines = config_file_new.splitlines() + +application_name = next(line for line in lines if line.startswith("application-name:")) +assert new_app_name in application_name, f"'{new_app_name}' should be in {application_name=}" diff --git a/tests/basic/test.nix b/tests/basic/test.nix new file mode 100644 index 0000000..61110ad --- /dev/null +++ b/tests/basic/test.nix @@ -0,0 +1,43 @@ +{ + pkgs, + lib, + config, + ... +}: +{ + name = "nixos-test-dynamicism-main"; + + defaults = { ... }: { }; + + #node.pkgsReadOnly = false; + + extraPythonPackages = p: [ + p.beartype + ]; + + nodes.machine = { pkgs, config, ... }: { + imports = [ ./configuration.nix ]; + + system.includeBuildDependencies = true; + system.switch.enable = true; + + virtualisation.additionalPaths = [ config.system.build.toplevel ]; + virtualisation = { + memorySize = 4096; + cores = 4; + writableStore = true; + mountHostNixStore = true; + installBootLoader = true; + }; + + environment.systemPackages = let + configFileTree = pkgs.callPackage ./configuration-package.nix { }; + in [ + configFileTree + ]; + }; + + # What's a little IFD between friends? + testScript = ./test-script.py + |> builtins.readFile; +} diff --git a/tests/default.nix b/tests/default.nix new file mode 100644 index 0000000..bc4beef --- /dev/null +++ b/tests/default.nix @@ -0,0 +1,8 @@ +{ + pkgs ? import { }, + lib ? pkgs.lib, +}: lib.makeScope lib.callPackageWith (self: let + inherit (pkgs.testers) runNixOSTest; +in { + basic = runNixOSTest ./basic/test.nix; +})