diff --git a/.editorconfig b/.editorconfig index aab3429..061699c 100644 --- a/.editorconfig +++ b/.editorconfig @@ -11,5 +11,5 @@ indent_style = space indent_size = 4 [*.nix] -indent_style = tab +indent_style = space indent_size = 2 diff --git a/Cargo.lock b/Cargo.lock index eef46d8..cba4ce7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -69,9 +69,9 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "bitflags" -version = "2.10.0" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" [[package]] name = "cfg-if" @@ -87,9 +87,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "clap" -version = "4.5.54" +version = "4.5.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6e6ff9dcd79cff5cd969a17a545d79e84ab086e444102a591e288a8aa3ce394" +checksum = "63be97961acde393029492ce0be7a1af7e323e6bae9511ebfac33751be5e6806" dependencies = [ "clap_builder", "clap_derive", @@ -97,9 +97,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.54" +version = "4.5.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa42cf4d2b7a41bc8f663a7cab4031ebafa1bf3875705bfaf8466dc60ab52c00" +checksum = "7f13174bda5dfd69d7e947827e5af4b0f2f94a4a3ee92912fba07a66150f21e2" dependencies = [ "anstream", "anstyle", @@ -109,9 +109,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.49" +version = "4.5.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" dependencies = [ "heck", "proc-macro2", @@ -121,9 +121,9 @@ dependencies = [ [[package]] name = "clap_lex" -version = "0.7.7" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" +checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" [[package]] name = "colorchoice" @@ -158,6 +158,8 @@ dependencies = [ "fs-err", "itertools", "libc", + "regex", + "regex-lite", "serde", "serde_json", "tap", @@ -190,9 +192,9 @@ dependencies = [ [[package]] name = "fs-err" -version = "3.2.2" +version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf68cef89750956493a66a10f512b9e58d9db21f2a573c079c0bdf1207a54a7" +checksum = "73fde052dbfc920003cfd2c8e2c6e6d4cc7c1091538c3a24226cec0665ab08c0" dependencies = [ "autocfg", ] @@ -271,9 +273,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.180" +version = "0.2.182" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" +checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" [[package]] name = "linux-raw-sys" @@ -307,9 +309,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.7.6" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[package]] name = "nix" @@ -385,9 +387,9 @@ checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" [[package]] name = "proc-macro2" -version = "1.0.105" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "535d180e0ecab6268a3e718bb9fd44db66bbbc256257165fc699dadf70d16fe7" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] @@ -406,9 +408,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.43" +version = "1.0.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" dependencies = [ "proc-macro2", ] @@ -423,10 +425,22 @@ dependencies = [ ] [[package]] -name = "regex-automata" -version = "0.4.13" +name = "regex" +version = "1.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" dependencies = [ "aho-corasick", "memchr", @@ -434,10 +448,16 @@ dependencies = [ ] [[package]] -name = "regex-syntax" -version = "0.8.8" +name = "regex-lite" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" + +[[package]] +name = "regex-syntax" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" [[package]] name = "rustix" @@ -555,9 +575,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.114" +version = "2.0.116" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +checksum = "3df424c70518695237746f84cede799c9c58fcb37450d7b23716568cc8bc69cb" dependencies = [ "proc-macro2", "quote", @@ -679,9 +699,9 @@ dependencies = [ [[package]] name = "unicode-ident" -version = "1.0.22" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-linebreak" @@ -915,6 +935,6 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "zmij" -version = "1.0.16" +version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfcd145825aace48cff44a8844de64bf75feec3080e0aa5cdbde72961ae51a65" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml index dd24d30..2e6dd55 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,12 +11,19 @@ path = "src/main.rs" name = "dynix" path = "src/lib.rs" +[features] +default = ["regex-full"] +regex-full = ["dep:regex"] +regex-lite = ["dep:regex-lite"] + [dependencies] clap = { version = "4.5.54", features = ["color", "derive"] } command-error = "0.8.0" fs-err = "3.2.2" itertools = "0.14.0" libc = { version = "0.2.180", features = ["extra_traits"] } +regex = { version = "1.12.3", optional = true } +regex-lite = { version = "0.1.9", optional = true } serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.149" tap = "1.0.1" diff --git a/configuration.nix b/configuration.nix deleted file mode 100644 index f34f2e5..0000000 --- a/configuration.nix +++ /dev/null @@ -1,36 +0,0 @@ -{ pkgs, modulesPath, ... }: - -{ - imports = [ - ./modules/dynamic-overrides.nix - ./modules/dynamicism - "${modulesPath}/profiles/qemu-guest.nix" - ]; - - dynamicism.for.gotosocial.enable = true; - - # Just an example system. - users.mutableUsers = false; - users.users.root = { - password = "root"; - }; - - services.openssh = { - enable = true; - settings = { - PermitRootLogin = "yes"; - }; - }; - - environment.shellAliases = { - ls = "eza --long --header --group --group-directories-first --classify --binary"; - }; - - environment.systemPackages = with pkgs; [ - eza - fd - ripgrep - ]; - - system.stateVersion = "25.11"; -} diff --git a/default.nix b/default.nix index b7a3bef..4372c35 100644 --- a/default.nix +++ b/default.nix @@ -5,71 +5,14 @@ in import src { inherit pkgs; }, }: let inherit (qpkgs) lib; - - dynix = qpkgs.callPackage ./package.nix { }; - + dynix = qpkgs.callPackage ./package.nix { } + |> qpkgs.stdlib.mkStdenvPretty; byStdenv = lib.mapAttrs (stdenvName: stdenv: let withStdenv = dynix.override { inherit stdenv; }; dynix' = withStdenv.overrideAttrs (prev: { pname = "${prev.pname}-${stdenvName}"; }); in dynix') qpkgs.validStdenvs; - - evalNixos = import (pkgs.path + "/nixos"); - - doChange = { - option, - value, - }: - assert lib.isList option; - assert lib.all lib.isString option; - let - nixosBefore = evalNixos { - configuration = ./configuration.nix; - }; - dynamicBefore = nixosBefore.config.dynamicism.finalSettings; - - nixosAfter = evalNixos { - configuration = { ... }: { - imports = [ - ./configuration.nix - (lib.setAttrByPath option (lib.mkOverride (-999) value)) - ]; - }; - }; - - withActivationScripts = evalNixos { - configuration = ({ ... }: { - imports = [ ./configuration.nix ]; - config.environment.systemPackages = [ nixosAfter.config.dynamicism.for.gotosocial.activate ]; - }); - }; - in { - inherit nixosBefore nixosAfter withActivationScripts; - }; - -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; - - inherit byStdenv; - nixos = evalNixos { - configuration = ./configuration.nix; - }; - c = self.nixos; - nixos-vm = self.nixos.config.system.build.vm; - doChange = builtins.seq self.nixos.config.dynamicism doChange; - withVox = self.doChange { - option = lib.splitString "." "services.gotosocial.settings.application-name"; - value = "Vox is an asshole"; - }; - }; +in dynix.overrideAttrs (prev: lib.recursiveUpdate prev { + passthru = { inherit byStdenv; }; }) diff --git a/flake.lock b/flake.lock index 9998eff..f07fa2c 100644 --- a/flake.lock +++ b/flake.lock @@ -1,5 +1,21 @@ { "nodes": { + "fenix": { + "flake": false, + "locked": { + "lastModified": 1771226183, + "narHash": "sha256-AbaMtaLbe37l2VI/KSRk63PuBnX/YDDFL0G1eFMbvwI=", + "owner": "nix-community", + "repo": "fenix", + "rev": "2e3759c5ef51f320eb0aaf83f2a32baae33db237", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "fenix", + "type": "github" + } + }, "flake-utils": { "inputs": { "systems": "systems" @@ -21,11 +37,11 @@ "nixpkgs": { "flake": false, "locked": { - "lastModified": 1768875095, - "narHash": "sha256-dYP3DjiL7oIiiq3H65tGIXXIT1Waiadmv93JS0sS+8A=", + "lastModified": 1770537093, + "narHash": "sha256-pF1quXG5wsgtyuPOHcLfYg/ft/QMr8NnX0i6tW2187s=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "ed142ab1b3a092c4d149245d0c4126a5d7ea00b0", + "rev": "fef9403a3e4d31b0a23f0bacebbec52c248fbb51", "type": "github" }, "original": { @@ -38,11 +54,11 @@ "qyriad-nur": { "flake": false, "locked": { - "lastModified": 1767357308, - "narHash": "sha256-PWDIBupTHASnzwPUuafIhwBCFKxjsSVj4QRAdX5y/z4=", + "lastModified": 1770385314, + "narHash": "sha256-zDlvon/yF9STxGi3l38j9EgTFHBHOjCJlP8mMX7zw5M=", "owner": "Qyriad", "repo": "nur-packages", - "rev": "c08309f918a0528ceb23659c0cc4a3c901fe8afa", + "rev": "23716e0347215a721f9489515a0c3dc91122c7d5", "type": "github" }, "original": { @@ -53,6 +69,7 @@ }, "root": { "inputs": { + "fenix": "fenix", "flake-utils": "flake-utils", "nixpkgs": "nixpkgs", "qyriad-nur": "qyriad-nur" diff --git a/flake.nix b/flake.nix index 8e0dfb6..f4270f0 100644 --- a/flake.nix +++ b/flake.nix @@ -5,6 +5,10 @@ flake = false; }; flake-utils.url = "github:numtide/flake-utils"; + fenix = { + url = "github:nix-community/fenix"; + flake = false; + }; qyriad-nur = { url = "github:Qyriad/nur-packages"; flake = false; @@ -15,11 +19,13 @@ self, nixpkgs, flake-utils, + fenix, qyriad-nur, }: flake-utils.lib.eachDefaultSystem (system: let pkgs = import nixpkgs { inherit system; }; qpkgs = import qyriad-nur { inherit pkgs; }; inherit (qpkgs) lib; + fenixLib = import fenix { inherit pkgs; }; dynix = import ./default.nix { inherit pkgs qpkgs; }; extraVersions = lib.mapAttrs' (stdenvName: value: { @@ -27,7 +33,7 @@ inherit value; }) dynix.byStdenv; - devShell = import ./shell.nix { inherit pkgs qpkgs dynix; }; + devShell = import ./shell.nix { inherit pkgs qpkgs dynix fenixLib; }; extraDevShells = lib.mapAttrs' (stdenvName: value: { name = "${stdenvName}-dynix"; inherit value; diff --git a/modules/dynamic-overrides.nix b/modules/dynamic-overrides.nix deleted file mode 100644 index 1952e96..0000000 --- a/modules/dynamic-overrides.nix +++ /dev/null @@ -1,21 +0,0 @@ -# Managed by dynix. -{ lib, ... }: - -lib.mkMerge [ - { - services.gotosocial = { - enable = true; - setupPostgresqlDB = true; - settings = { - application-name = "example!"; - host = "yuki.local"; - }; - }; - } - { - services.gotosocial.settings.application-name = lib.mkOverride 99 "removed herobrine"; - } - { - services.gotosocial.settings.application-name = lib.mkOverride 98 "reädded herobrine"; - } -] diff --git a/modules/dynamicism/default.nix b/modules/dynamicism/default.nix index 8e4764c..1fca96c 100644 --- a/modules/dynamicism/default.nix +++ b/modules/dynamicism/default.nix @@ -1,138 +1,5 @@ -{ pkgs, lib, config, options, ... }: -let - inherit (lib.options) - mkOption - showOption - ; - t = lib.types; +{ ... }: - 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 { }; - - finalSettingsFor = { ... }@submod: recUpdateFoldl (optPath: - lib.setAttrByPath optPath (lib.getAttrFromPath optPath config) - ) submod.source-options; - - ourAssertions = lib.concatAttrValues { - 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}' - ''; - }) - |> lib.optionals submod.enable.value - ); - - optsExist = concatFoldl (submod: lib.optionals submod.enable.value (lib.map (optPath: { - assertion = lib.hasAttrByPath optPath options; - message = "'${showOption submod.source-options.loc}' specified non-existent option '${showOption optPath}'"; - }) submod.source-options.value)) (lib.attrValues subOpts); - }; -in { - # - # Interface. - # - options.dynamicism = { - for = mkOption { - type = t.attrsOf (t.submoduleWith { - modules = [ ./submodule.nix ]; - shorthandOnlyDefinesConfig = false; - specialArgs = { inherit pkgs; }; - }); - default = { }; - }; - - finalSettings = mkOption { - type = t.attrsOf t.raw; - internal = true; - readOnly = true; - description = '' - 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. - config.assertions = ourAssertions; - - # - # 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 - ); - - # Implementations. - config.dynamicism.for.gotosocial = let - cfg = config.dynamicism.for.gotosocial; - in { - source-options = [ - "services.gotosocial.settings" - ]; - - configFile = settingsFormat.generate "gotosocial-override.yml" config.services.gotosocial.settings; - - unitDropins."gotosocial.service" = pkgs.writeText "gotosocial-override.conf" '' - [Service] - ExecStart= - ExecStart=${lib.getExe' pkgs.gotosocial "gotosocial"} --config-path ${cfg.configFile} start - ''; - }; + imports = [ ./dynamicism.nix ]; } diff --git a/modules/dynamicism/distccd.nix b/modules/dynamicism/distccd.nix new file mode 100644 index 0000000..3b87764 --- /dev/null +++ b/modules/dynamicism/distccd.nix @@ -0,0 +1,50 @@ +{ pkgs, lib, config, ... }: +let + cfg = config.services.distccd; + + cliArgs = lib.cli.toCommandLineShellGNU { explicitBool = false; } { + no-detach = true; + daemon = true; + enable-tcp-insecure = true; + port = cfg.port; + + # Nulls are handled automatically. + job-lifetime = cfg.jobTimeout; + log-level = cfg.logLevel; + jobs = cfg.maxJobs; + nice = cfg.nice; + stats = cfg.stats.enable; + stats-port = if cfg.stats.enable then cfg.stats.port else null; + zeroconf = cfg.zeroconf; + allow = cfg.allowedClients; + }; + + startDistccd = pkgs.writeShellApplication { + name = "start-distccd"; + runtimeInputs = [ pkgs.distccMasquerade ]; + + text = '' + ${lib.getExe' cfg.package "distccd"} \ + ${cliArgs} + ''; + }; +in +{ + dynamicism.for.distccd = { + source-options = [ + "services.distccd.jobTimeout" + "services.distccd.logLevel" + "services.distccd.maxJobs" + "services.distccd.nice" + ]; + unitDropins."distccd.service" = pkgs.writeTextFile { + name = "distccd-override.conf"; + text = '' + [Service] + ExecStart= + ExecStart=${lib.getExe startDistccd} + ''; + passthru.startScript = startDistccd; + }; + }; +} diff --git a/modules/dynamicism/dynamicism.nix b/modules/dynamicism/dynamicism.nix new file mode 100644 index 0000000..6f79993 --- /dev/null +++ b/modules/dynamicism/dynamicism.nix @@ -0,0 +1,213 @@ +{ pkgs, lib, config, options, ... }: +let + inherit (lib.options) + mkOption + showOption + ; + inherit (lib.asserts) + checkAssertWarn + ; + t = lib.types; + + inherit (import ./lib.nix { inherit lib; }) + typeCheck + convenientAttrPath + executablePathInStore + concatFoldl + recUpdateFoldl + recUpdateFoldlAttrs + ; + + evalNixos = import (pkgs.path + "/nixos"); + + opts = options.dynamicism; + + subOpts = lib.mapAttrs (_: metaAttr: metaAttr.configuration.options) options.dynamicism.for.valueMeta.attrs; + + seqTrue = v: lib.seq v true; + + finalSettingsFor = { ... }@submod: recUpdateFoldl (optPath: + lib.setAttrByPath optPath (lib.getAttrFromPath optPath config) + ) submod.source-options; + + ourAssertions = lib.concatAttrValues { + 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}' + ''; + }) + |> lib.optionals submod.enable.value + ); + + optsExist = concatFoldl (submod: lib.optionals submod.enable.value (lib.map (optPath: { + assertion = lib.hasAttrByPath optPath options; + message = "'${showOption submod.source-options.loc}' specified non-existent option '${showOption optPath}'"; + }) submod.source-options.value)) (lib.attrValues subOpts); + }; +in +{ + # + # Interface. + # + options.dynamicism = { + for = mkOption { + type = t.attrsOf (t.submoduleWith { + modules = [ ./submodule.nix ]; + shorthandOnlyDefinesConfig = false; + specialArgs = { + host = { inherit pkgs options config; }; + }; + }); + default = { }; + }; + + finalEnabledSubmodules = mkOption { + type = options.dynamicism.for.type; + internal = true; + readOnly = true; + default = lib.filterAttrs (lib.const (lib.getAttr "enable")) config.dynamicism.for; + }; + + finalSettings = mkOption { + type = t.attrsOf t.raw; + internal = true; + readOnly = true; + description = '' + Attrset of each `source-options` tree to their actual values. + ''; + }; + + finalActivationScript = mkOption { + type = executablePathInStore; + internal = true; + }; + + applyDynamicConfiguration = mkOption { + #type = t.functionTo t.pathInStore; + type = t.functionTo t.raw; + readOnly = true; + }; + }; + + # Assertions. + config.assertions = ourAssertions; + + # + # Generic implementation. + # + + config.system.activationScripts = config.dynamicism.for + |> lib.filterAttrs (lib.const (lib.getAttr "enable")) + |> lib.mapAttrs' (name: submod: let + forUnit = unitName: assert lib.isString unitName; let + dropinDir = "/run/systemd/system/${unitName}.d"; + systemctl = lib.getExe' pkgs.systemd "systemctl"; + in '' + if [[ -d "${dropinDir}" ]]; then + echo "Removing files in "${dropinDir}" + echo "Removing files in "${dropinDir}" >&2 + + rm -rvf "${dropinDir}/"* + rmdir "${dropinDir}" + + ${systemctl} daemon-reload + ${systemctl} try-reload-or-restart "${unitName}" + fi + ''; + + in { + name = "dynix-reset-dynamicism-for-${name}"; + value.deps = [ "etc" "stdio" "specialfs" "binsh" "usrbinenv" "var" "udevd" ]; + value.text = '' + set -x + echo "Removing existing dynamic overrides for ${name}" + echo "Removing existing dynamic overrides for ${name}" >&2 + + ${submod.systemd-services-updated |> lib.map forUnit |> lib.concatStringsSep "\n"} + + set +x + ''; + }); + + config.dynamicism = { + + applyDynamicConfiguration = { + baseConfiguration ? builtins.getEnv "NIXOS_CONFIG", + newConfiguration ? (lib.filesystem.dirOf baseConfiguration) + "/dynamic.nix", + }: let + locFor = appendage: lib.concatLists [ + opts.applyDynamicConfiguration.loc + [ "(function argument)" ] + (lib.toList appendage) + ]; + in + assert seqTrue (typeCheck (locFor "baseConfiguration") t.deferredModule baseConfiguration); + assert seqTrue (typeCheck (locFor "newConfiguration") t.deferredModule newConfiguration); + let + + _file = "«inline module in ${showOption opts.applyDynamicConfiguration.loc}»"; + + nixosBefore = evalNixos { + configuration = { ... }: { + inherit _file; + imports = [ baseConfiguration ]; + }; + }; + + nixosAfter = evalNixos { + configuration = { ... }: { + inherit _file; + imports = [ baseConfiguration newConfiguration ]; + }; + }; + + submodulesChanged = nixosAfter.config.dynamicism.for + |> lib.filterAttrs (lib.const (lib.getAttr "enable")) + |> lib.filterAttrs (submodName: _: + nixosBefore.config.dynamicism.for.${submodName}.finalSettings + != + nixosAfter.config.dynamicism.for.${submodName}.finalSettings + ); + + runForSubmodCalled = name: '' + echo "Activating dynamic configuration for ${name}" + ${lib.getExe nixosAfter.config.dynamicism.for.${name}.activate} + ''; + + runForChanged = submodulesChanged + |> lib.mapAttrsToList (name: _: runForSubmodCalled name) + |> lib.concatStringsSep "\n"; + + in pkgs.writeShellApplication { + name = "dynamicism-activate"; + text = runForChanged; + passthru.configuration = nixosAfter; + }; + + finalActivationScript = pkgs.writeShellApplication { + name = "dynamicism-activate-all"; + text = config.dynamicism.for + |> lib.filterAttrs (lib.const (lib.getAttr "enable")) + |> lib.mapAttrsToList (name: submod: '' + echo "Activating dynamic configuration for ${name}" + ${lib.getExe submod.activate} + '') + |> lib.concatStringsSep "\n"; + }; + + finalSettings = config.dynamicism.for + |> recUpdateFoldlAttrs (name: { ... }@submod: finalSettingsFor submod) + |> checkAssertWarn ourAssertions [ ]; + }; + + # Implementations. + imports = [ + ./gotosocial.nix + ./harmonia.nix + ./distccd.nix + ]; +} diff --git a/modules/dynamicism/gotosocial.nix b/modules/dynamicism/gotosocial.nix new file mode 100644 index 0000000..b1fedc6 --- /dev/null +++ b/modules/dynamicism/gotosocial.nix @@ -0,0 +1,20 @@ +{ pkgs, lib, config, ... }: +let + settingsFormat = pkgs.formats.yaml { }; + configFile = settingsFormat.generate "gotosocial-override.yml" config.services.gotosocial.settings; +in +{ + dynamicism.for.gotosocial = { + source-options = [ "services.gotosocial.settings" ]; + + unitDropins."gotosocial.service" = pkgs.writeTextFile { + name = "gotosocial-override.conf"; + text = '' + [Service] + ExecStart= + ExecStart=${lib.getExe' pkgs.gotosocial "gotosocial"} --config-path ${configFile} server start + ''; + passthru = { inherit configFile; }; + }; + }; +} diff --git a/modules/dynamicism/harmonia.nix b/modules/dynamicism/harmonia.nix new file mode 100644 index 0000000..e4284d0 --- /dev/null +++ b/modules/dynamicism/harmonia.nix @@ -0,0 +1,26 @@ +{ pkgs, lib, config, ... }: +let + cfg = config.dynamicism.for.harmonia; + settingsFormat = pkgs.formats.toml { }; + + # FIXME: referring to config.dynamicism.finalSettings.* in here + # makes lib.checkAssertWarn in the generic module cause infinite recursion. + finalSettings = config.services.harmonia.settings; + + configFile = settingsFormat.generate "harmonia-override.toml" finalSettings; +in +{ + dynamicism.for.harmonia = { + source-options = [ "services.harmonia.settings" ]; + + unitDropins."harmonia.service" = pkgs.writeTextFile { + name = "harmonia-override.conf"; + text = '' + [Service] + Environment=CONFIG_FILE=${configFile} + ''; + + passthru = { inherit configFile; }; + }; + }; +} diff --git a/modules/dynamicism/lib.nix b/modules/dynamicism/lib.nix index a988ae4..ec41ab7 100644 --- a/modules/dynamicism/lib.nix +++ b/modules/dynamicism/lib.nix @@ -4,12 +4,12 @@ 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; + typeCheck = loc: optionType: value: + assert lib.isOptionType optionType; assert lib.isList loc; assert lib.all lib.isString loc; let - merged = lib.modules.mergeDefinitions loc option [ { + merged = lib.modules.mergeDefinitions loc optionType [ { inherit value; file = "«inline»"; } ]; @@ -18,6 +18,19 @@ in lib.fix (self: { /** Either a list of strings, or a dotted string that will be split. */ convenientAttrPath = t.coercedTo t.str (lib.splitString ".") (t.listOf t.str); + executablePathInStore = lib.mkOptionType { + name = "exepath"; + description = "executable path in the Nix store"; + descriptionClass = "noun"; + merge = lib.mergeEqualOption; + functor = lib.defaultFunctor "exepath"; + check = x: if lib.isDerivation x then ( + x.meta.mainProgram or null != null + ) else ( + lib.pathInStore.check x + ); + }; + 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/dynamicism/submodule.nix b/modules/dynamicism/submodule.nix index 1b9dac9..4100217 100644 --- a/modules/dynamicism/submodule.nix +++ b/modules/dynamicism/submodule.nix @@ -1,8 +1,8 @@ { name, - pkgs, lib, config, + host, ... }: let @@ -14,26 +14,15 @@ let mkEnableOption literalExpression ; - inherit (lib.types) - mkOptionType - ; t = lib.types; - /** Either a list of strings, or a dotted string that will be split. */ - convenientAttrPath = t.coercedTo t.str (lib.splitString ".") (t.listOf t.str); + inherit (import ./lib.nix { inherit lib; }) + convenientAttrPath + executablePathInStore + recUpdateFoldl + ; - executablePathInStore = mkOptionType { - name = "exepath"; - description = "executable path in the Nix store"; - descriptionClass = "noun"; - merge = lib.mergeEqualOption; - functor = lib.defaultFunctor "exepath"; - check = x: if lib.isDerivation x then ( - x.meta.mainProgram or null != null - ) else ( - lib.pathInStore.check x - ); - }; + pkgs = host.pkgs; in { options = { @@ -59,13 +48,22 @@ in default = lib.attrNames config.unitDropins; }; - configFile = mkOption { - type = t.pathInStore; + finalSettings = mkOption { + type = t.attrsOf t.raw; internal = true; + default = recUpdateFoldl (optPath: + lib.setAttrByPath optPath (lib.getAttrFromPath optPath host.config) + ) config.source-options; + }; + + configFile = mkOption { + type = t.nullOr t.pathInStore; + internal = true; + default = null; }; unitDropins = mkOption { - type = t.attrsOf t.pathInStore; + type = t.attrsOf t.package; internal = true; }; @@ -82,17 +80,19 @@ in text = let doEdits = config.unitDropins |> lib.mapAttrsToList (service: dropin: '' - cat "${dropin}" | systemctl edit "${service}" --runtime --stdin - ''); + cat "${dropin}" | systemctl edit "${service}" --runtime --drop=dynix-${dropin.name} --stdin + '') + |> lib.concatStringsSep "\n"; doReloads = config.unitDropins - |> lib.mapAttrsToList (service: _: '' - systemctl reload-or-restart "${service}" - ''); - in [ - doEdits - doReloads - ] |> lib.concatLists - |> lib.concatStringsSep "\n"; + |> lib.mapAttrsToList (service: _: '' + systemctl reload-or-restart "${service}" + '') + |> lib.concatStringsSep "\n"; + in '' + ${doEdits} + + ${doReloads} + ''; }; }; } diff --git a/modules/dynamicism/tzupdate.nix b/modules/dynamicism/tzupdate.nix new file mode 100644 index 0000000..73f634d --- /dev/null +++ b/modules/dynamicism/tzupdate.nix @@ -0,0 +1,20 @@ +{ pkgs, lib, config, ... }: let + cfg = config.dynamicism.for.tzupdate; + + + # FIXME: referring to config.dynamicism.finalSettings.* in here + # makes lib.checkAssertWarn in the generic module cause infinite recursion. + #finalSettings = config.dynamicism.finalSettings.tzupdate; + finalSettings = config.services.tzupdate.timer; +in +{ + dynamicism.for.tzupdate = { + source-options = [ "services.tzupdate.timer" ]; + + unitDropins."tzupdate.timer" = pkgs.writeText "tzupdate-timer-override.conf" '' + [Timer] + OnCalendar= + OnCalendar=${finalSettings.interval} + ''; + }; +} diff --git a/modules/tests-common.nix b/modules/tests-common.nix deleted file mode 100644 index 322771e..0000000 --- a/modules/tests-common.nix +++ /dev/null @@ -1,4 +0,0 @@ -{ pkgs, ... }: -{ - nix.package = pkgs.lixPackageSets.latest.lix; -} diff --git a/modules/tests-main.py b/modules/tests-main.py deleted file mode 100644 index dbd33a6..0000000 --- a/modules/tests-main.py +++ /dev/null @@ -1,76 +0,0 @@ -#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 deleted file mode 100644 index 917c7bd..0000000 --- a/modules/tests.nix +++ /dev/null @@ -1,52 +0,0 @@ -{ - 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/package.nix b/package.nix index 2cb8cee..448ab31 100644 --- a/package.nix +++ b/package.nix @@ -1,6 +1,8 @@ { lib, - stdenv, + clangStdenv, + callPackage, + linkFarm, rustHooks, rustPackages, versionCheckHook, @@ -8,79 +10,151 @@ rustPlatform, cargo, }: let + stdenv = clangStdenv; cargoToml = lib.importTOML ./Cargo.toml; cargoPackage = cargoToml.package; - -in stdenv.mkDerivation (self: { +in stdenv.mkDerivation (finalAttrs: let + self = finalAttrs.finalPackage; +in { pname = cargoPackage.name; version = cargoPackage.version; strictDeps = true; __structuredAttrs = true; + outputs = [ "out" "modules" ]; + doCheck = true; doInstallCheck = true; - src = lib.fileset.toSource { - root = ./.; - fileset = lib.fileset.unions [ - ./Cargo.toml - ./Cargo.lock - ./src - ]; + phases = [ "unpackPhase" "patchPhase" "installPhase" ]; + + src = linkFarm "dynix-source" { + inherit (self) dynixCommand dynixModules; }; - cargoDeps = rustPlatform.importCargoLock { - lockFile = ./Cargo.lock; - }; + installPhase = lib.dedent '' + runHook preInstall - versionCheckProgramArg = "--version"; + mkdir -p "$out" + cp -r --reflink=auto "$dynixCommand/"* "$out/" + mkdir -p "$modules" + cp -r --reflink=auto "$dynixModules/"* "$modules/" - nativeBuildInputs = rustHooks.asList ++ [ - cargo - ]; + runHook postInstall + ''; - nativeInstallCheckInputs = [ - versionCheckHook - ]; + # + # SUB-DERIVATONS + # - passthru.mkDevShell = { - mkShell, - }: let - mkShell' = mkShell.override { stdenv = stdenv; }; - in mkShell' { - name = "${self.pname}-devshell-${self.version}"; - inputsFrom = [ self.finalPackage ]; - packages = [ - rustPackages.rustc - rustPackages.rustfmt - ]; - }; + dynixCommand = stdenv.mkDerivation (finalAttrs: let + commandSelf = finalAttrs.finalPackage; + in { + pname = "${self.pname}-command"; + inherit (self) version; + inherit (self) strictDeps __structuredAttrs; + inherit (self) doCheck doInstallCheck; - passthru.tests.clippy = self.finalPackage.overrideAttrs (prev: { - pname = "${self.pname}-clippy"; + src = lib.fileset.toSource { + root = ./.; + fileset = lib.fileset.unions [ + ./Cargo.toml + ./Cargo.lock + ./src + ]; + }; - nativeCheckInputs = prev.nativeCheckInputs or [ ] ++ [ - rustPackages.clippy + cargoDeps = rustPlatform.importCargoLock { + lockFile = ./Cargo.lock; + }; + + nativeBuildInputs = rustHooks.asList ++ [ + cargo ]; - dontConfigure = true; - dontBuild = true; - doCheck = true; - dontFixup = true; - dontInstallCheck = true; + nativeInstallCheckInputs = [ + versionCheckHook + ]; - checkPhase = lib.trim '' - echo "cargoClippyPhase()" - cargo clippy --all-targets --profile "$cargoCheckType" -- --deny warnings - ''; + meta = { + mainProgram = "dynix"; + }; + }); - installPhase = lib.trim '' - touch "$out" + dynixModules = stdenv.mkDerivation (finalAttrs: let + modulesSelf = finalAttrs.finalPackage; + in { + pname = "${self.pname}-modules"; + inherit (self) version; + inherit (self) strictDeps __structuredAttrs; + inherit (self) doCheck doInstallCheck; + + src = lib.fileset.toSource { + root = ./modules/dynamicism; + fileset = lib.fileset.unions [ + ./modules/dynamicism + ]; + }; + + phases = [ "unpackPhase" "patchPhase" "installPhase" ]; + + modulesOut = "${placeholder "out"}/share/nixos/modules/dynix"; + + installPhase = lib.dedent '' + runHook preInstall + + mkdir -p "$modulesOut" + cp -r "$src/"* "$modulesOut/" + + runHook postInstall ''; }); + # + # ---------------------------------------------------------------------------- + # + + passthru.mkDevShell = { + path, + mkShell, + python3Packages, + fenixToolchain, + }: let + mkShell' = mkShell.override { inherit stdenv; }; + pyEnv = python3Packages.python.withPackages (p: [ p.beartype ]); + in mkShell' { + name = "devshell-for-${self.name}"; + inputsFrom = [ self ]; + packages = [ + pyEnv + stdenv.cc + fenixToolchain + ]; + env.PYTHONPATH = [ + "${pyEnv}/${pyEnv.sitePackages}" + # Cursed. + "${path}/nixos/lib/test-driver/src" + ] |> lib.concatStringsSep ":"; + }; + + passthru.modulesPath = self.modules + "/share/nixos/modules"; + passthru.dynix = self.modulesPath + "/dynix"; + + passthru.tests = lib.fix (callPackage ./tests { + dynix = self; + }).packages; + + passthru.allTests = linkFarm "dynix-all-tests" self.tests; + meta = { + longDescription = lib.dedent '' + Default output contains the Rust binary. + The `modules` output contains the modules prefixed under `/share/nixos/modules/dynix`. + The `dynix` passthru attr is a shortcut for the modules output *with* the modules prefix, + and thus `dynix.dynix` can be passed directly to `imports = [`. + ''; mainProgram = "dynix"; + outputsToInstall = [ "out" "modules" ]; }; })) diff --git a/shell.nix b/shell.nix index c73c766..e2af1b4 100644 --- a/shell.nix +++ b/shell.nix @@ -1,23 +1,26 @@ { - pkgs ? import { }, + pkgs ? import { + config = { + checkMeta = true; + allowAliases = false; + }; + }, qpkgs ? let src = fetchTarball "https://github.com/Qyriad/nur-packages/archive/main.tar.gz"; in import src { inherit pkgs; }, dynix ? import ./default.nix { inherit pkgs qpkgs; }, + fenixLib ? let + src = fetchTarball "https://github.com/nix-community/fenix/archive/main.tar.gz"; + in import src { inherit pkgs; }, + fenixToolchain ? fenixLib.latest.toolchain, }: let inherit (pkgs) lib; - mkDevShell = dynix: qpkgs.callPackage dynix.mkDevShell { }; + mkDevShell = dynix: qpkgs.callPackage dynix.mkDevShell { inherit fenixToolchain; }; devShell = mkDevShell dynix; byStdenv = lib.mapAttrs (lib.const mkDevShell) dynix.byStdenv; -in devShell.overrideAttrs (prev: lib.recursiveUpdate prev { +in devShell.overrideAttrs (prev: { passthru = { inherit byStdenv; }; - env.PYTHONPATH = [ - "${pkgs.python3Packages.beartype}/${pkgs.python3.sitePackages}" - ] |> lib.concatStringsSep ":"; - packages = prev.packages or [ ] ++ [ - pkgs.python3Packages.beartype - ]; }) diff --git a/src/args.rs b/src/args.rs index cc2fa07..0028e0f 100644 --- a/src/args.rs +++ b/src/args.rs @@ -1,4 +1,7 @@ -use std::sync::Arc; +use std::{ + env, + sync::{Arc, LazyLock}, +}; use clap::ColorChoice; @@ -52,6 +55,7 @@ impl FromStr for NixOsOption { pub struct AppendCmd { #[arg(required = true)] pub name: Arc, + #[arg(required = true)] pub value: Arc, } @@ -67,6 +71,28 @@ pub enum Subcommand { Delta(DeltaCmd), } +static DEFAULT_PATH: LazyLock> = LazyLock::new(|| { + // This has to be in a let binding to keep the storage around. + let nixos_config = env::var_os("NIXOS_CONFIG"); + let nixos_config = nixos_config + .as_deref() + .map(Path::new) + .unwrap_or(Path::new("/etc/nixos/configuration.nix")); + + nixos_config + .parent() + .unwrap_or_else(|| { + error!( + "Your $NIXOS_CONFIG value doesn't make sense: {}. Ignoring.", + nixos_config.display(), + ); + Path::new("/etc/nixos") + }) + .join("dynamic.nix") + .into_os_string() + .into_boxed_os_str() +}); + #[derive(Debug, Clone, PartialEq, clap::Parser)] #[command(version, about, author)] #[command(arg_required_else_help(true), args_override_self(true))] @@ -75,8 +101,10 @@ pub struct Args { #[arg(long, global(true), default_value = "auto")] pub color: ColorChoice, - // FIXME: default to /etc/configuration.nix, or something? - #[arg(long, global(true), default_value = "./configuration.nix")] + /// The .nix file with dynamic overrides to modify. + /// [default: $(dirname ${NIXOS_CONFIG-/etc/nixos/configuration.nix})/dynamic.nix] + #[arg(long, global(true), default_value = &**DEFAULT_PATH)] + #[arg(hide_default_value(true))] pub file: Arc, #[command(subcommand)] diff --git a/src/lib.rs b/src/lib.rs index 1956803..cf7839a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,7 @@ -use std::{iter, sync::Arc}; +use std::{ + iter, + sync::{Arc, LazyLock}, +}; pub(crate) mod prelude { #![allow(unused_imports)] @@ -41,14 +44,38 @@ pub mod line; mod nixcmd; pub use line::Line; pub mod source; -pub use source::SourceLine; +pub use source::{SourceFile, SourceLine}; +#[cfg(all(not(feature = "regex-full"), not(feature = "regex-lite")))] +compile_error!("At least one of features `regex-full` or `regex-lite` must be used"); + +#[cfg(feature = "regex-full")] +use regex as _regex; + +// Having both `regex-full` and `regex-lite` isn't an error; it's just wasteful. +#[cfg(not(feature = "regex-full"))] +use regex_lite as _regex; + +use _regex::Regex; + +use itertools::Itertools; use serde::{Deserialize, Serialize}; -use crate::source::SourceFile; - pub const ASCII_WHITESPACE: &[char] = &['\t', '\n', '\x0C', '\r', ' ']; +/// Regex pattern to extract the priority in a `lib.mkOverride` call. +static MK_OVERRIDE_RE: LazyLock = LazyLock::new(|| { + // Named capture group: priority + // - Word boundary + // - Literal `mkOverride` + // - One or more whitespace characters + // - Literal open parenthesis + // - Named capture group "priority" + // - One or more of: digit characters, or literal `-` + // - Literal close parenthesis + Regex::new(r"(?-u)\bmkOverride\s+\((?[\d-]+)\)").unwrap() +}); + #[tracing::instrument(level = "debug")] pub fn do_delta(args: Arc, delta_args: DeltaCmd) -> Result<(), BoxDynError> { todo!(); @@ -65,17 +92,15 @@ pub fn do_append(args: Arc, append_args: AppendCmd) -> Result<(), BoxDynEr filepath.to_path_buf() }; - // Get what file that thing is defined in. - let def_path = get_where(&append_args.name, &filepath)?; - let mut opts = File::options(); opts.read(true) .write(true) .create(false) .custom_flags(libc::O_CLOEXEC); - let source_file = SourceFile::open_from(Arc::from(def_path), opts)?; - let pri = get_highest_prio(&append_args.name, source_file.clone())?; + let source_file = SourceFile::open_from(Arc::from(filepath), opts)?; + let pri = get_where(source_file.clone())?; + let new_pri = pri - 1; let new_pri_line = get_next_prio_line( @@ -85,7 +110,7 @@ pub fn do_append(args: Arc, append_args: AppendCmd) -> Result<(), BoxDynEr append_args.value.into(), )?; - eprintln!("new_pri_line={new_pri_line}"); + debug!("new_pri_line={new_pri_line}"); write_next_prio(source_file, new_pri_line)?; @@ -108,35 +133,30 @@ pub fn expr_for_configuration(source_file: &Path) -> OsString { .collect() } -pub fn get_where(option_name: &str, configuration_nix: &Path) -> Result, BoxDynError> { - let expr = expr_for_configuration(configuration_nix); - let attrpath = format!("options.{}.definitionsWithLocations", option_name); - - let output = nixcmd::NixEvalExpr { expr, attrpath } - .into_command() - .output_checked_utf8()?; - let stdout = output.stdout(); - - let definitions: Box<[DefinitionWithLocation]> = serde_json::from_str(&stdout)?; - let last_location = definitions.into_iter().last().unwrap(); - - Ok(Box::from(last_location.file)) +fn maybe_extract_prio_from_line(line: &SourceLine) -> Option { + MK_OVERRIDE_RE + .captures(line.text_ref()) + .map(|caps| caps.name("priority").unwrap().as_str()) + .map(|prio_str| { + i64::from_str(prio_str).unwrap_or_else(|e| { + panic!( + "lib.mkOverride called with non-integer {}: {}. Nix source code is wrong!\n{}", + prio_str, e, line, + ); + }) + }) } -pub fn get_highest_prio(option_name: &str, source: SourceFile) -> Result { - // Get the current highest priority. +pub fn get_where(dynamic_nix: SourceFile) -> Result { + let lines = dynamic_nix.lines()?; + let prio = lines + .into_iter() + .filter_map(maybe_extract_prio_from_line) + .sorted_unstable() + .next() // Priorities with lower integer values are "stronger" priorities. + .unwrap_or(0); - let expr = expr_for_configuration(&source.path()); - - // Get the highest priority, and the file its defined in. - let attrpath = format!("options.{}.highestPrio", option_name); - let output = nixcmd::NixEvalExpr { expr, attrpath } - .into_command() - .output_checked_utf8()?; - let stdout = output.stdout(); - let highest_prio = i64::from_str(stdout.trim())?; - - Ok(highest_prio) + Ok(prio) } pub fn get_next_prio_line( @@ -146,15 +166,19 @@ pub fn get_next_prio_line( new_value: Arc, ) -> Result { let source_lines = source.lines()?; - let last_line = source_lines.last(); - assert_eq!(last_line.map(SourceLine::text).as_deref(), Some("]")); - let last_line = last_line.unwrap(); + let penultimate = source_lines.get(source_lines.len() - 2); + // FIXME: don't rely on whitespace lol + debug_assert_eq!(penultimate.map(SourceLine::text).as_deref(), Some(" ];")); + let penultimate = penultimate.unwrap(); + + let new_generation = 0 - new_prio; let new_line = SourceLine { - line: last_line.line, + line: penultimate.line, path: source.path(), text: Arc::from(format!( - " {option_name} = lib.mkOverride ({new_prio}) ({new_value});", + " {} = lib.mkOverride ({}) ({}); # DYNIX GENERATION {}", + option_name, new_prio, new_value, new_generation, )), }; @@ -165,12 +189,12 @@ pub fn write_next_prio(mut source: SourceFile, new_line: SourceLine) -> Result<( let new_mod_start = SourceLine { line: new_line.line.prev(), path: source.path(), - text: Arc::from(" {"), + text: Arc::from(" {"), }; let new_mod_end = SourceLine { line: new_line.line.next(), path: source.path(), - text: Arc::from(" }"), + text: Arc::from(" }"), }; source.insert_lines(&[new_mod_start, new_line, new_mod_end])?; diff --git a/tests/basic/configuration-package.nix b/tests/basic/configuration-package.nix deleted file mode 100644 index 4b50af6..0000000 --- a/tests/basic/configuration-package.nix +++ /dev/null @@ -1,9 +0,0 @@ -{ - 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/test-script.py b/tests/basic/test-script.py deleted file mode 100644 index f507503..0000000 --- a/tests/basic/test-script.py +++ /dev/null @@ -1,84 +0,0 @@ -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 deleted file mode 100644 index 61110ad..0000000 --- a/tests/basic/test.nix +++ /dev/null @@ -1,43 +0,0 @@ -{ - 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 index bc4beef..535c54b 100644 --- a/tests/default.nix +++ b/tests/default.nix @@ -1,8 +1,79 @@ { pkgs ? import { }, - lib ? pkgs.lib, -}: lib.makeScope lib.callPackageWith (self: let - inherit (pkgs.testers) runNixOSTest; -in { - basic = runNixOSTest ./basic/test.nix; + qpkgs ? let + src = fetchTree (builtins.parseFlakeRef "github:Qyriad/nur-packages"); + in import src { inherit pkgs; }, + callPackage ? qpkgs.callPackage, + lib ? qpkgs.lib, + dynix ? qpkgs.callPackage ../package.nix { }, +}: let + + mkDynixConfigurationDotNix = callPackage ./mk-test-configuration-dot-nix.nix { }; + + runDynixTest = testModule: pkgs.testers.runNixOSTest { + imports = [ testModule ]; + + # NOTE: these are arguments to each *test module*. + # Not the NixOS modules of the test's nodes. + _module.args = { inherit mkDynixConfigurationDotNix; }; + + # Why is this argument called "extraBaseModule**s**" but take a single module argument... + # Also note this is an extra base module for each node of the test, + # not an extra test module. + extraBaseModules = { name, config, options, modulesPath, ... }: { + /** + * Everything in this module will disappear once nixos-rebuild switch happens. + * So each test will need to use `mkDynixConfigurationDotNix` to get + * ./dynix-vm-configuration included in the in-VM configuration. + */ + + imports = (import "${modulesPath}/module-list.nix") ++ [ + # For the VM node, but not the in-VM configuration.nix + ./module-allow-rebuild-in-vm.nix + # For the VM node, and the in-VM configuration.nix + ./dynix-vm-configuration.nix + dynix.dynix + ]; + + systemd.services."install-dynix" = { + enable = true; + serviceConfig.Type = "oneshot"; + serviceConfig.RemainAfterExit = true; + path = [ config.system.path ]; + wantedBy = [ "multi-user.target" ]; + requiredBy = [ "multi-user.target" ]; + after = [ "default.target" ]; + script = '' + if [[ -e /etc/nixos/hardware-configuration.nix ]]; then + echo "install-dynix: configuration already copied; nothing to do" + exit 0 + fi + + nix profile install -vv "${dynix.drvPath}^*" # " + + mkdir -vp /etc/nixos + nixos-generate-config + cp -rv --dereference /run/current-system/sw/share/nixos/*.nix /etc/nixos/ + if ! [[ -e /etc/nixos/dynix-vm-configuration.nix ]]; then + echo "FAILURE" + echo "FAILURE" >&2 + fi + ''; + }; + + passthru = { inherit options; }; + + environment.systemPackages = [ config.passthru.configurationDotNix ]; + + # Just making something in this strict in `name`, + # which is only present as an argument for nodes and I don't want to + # confuse that with the test modules. + warnings = builtins.seq name [ ]; + }; + }; + +in lib.makeScope lib.callPackageWith (self: { + gotosocial = runDynixTest ./gotosocial/test.nix; + harmonia = runDynixTest ./harmonia/test.nix; + distccd = runDynixTest ./distccd/test.nix; }) diff --git a/tests/distccd/configuration.nix b/tests/distccd/configuration.nix new file mode 100644 index 0000000..a8fb343 --- /dev/null +++ b/tests/distccd/configuration.nix @@ -0,0 +1,14 @@ +{ ... }: +{ + services.distccd = { + enable = true; + jobTimeout = 900; + maxJobs = 12; + nice = -10; + }; + + dynamicism.for.distccd.enable = true; + + networking.hostName = "distccd-machine"; +} + diff --git a/tests/distccd/test-script.py b/tests/distccd/test-script.py new file mode 100644 index 0000000..d5867bf --- /dev/null +++ b/tests/distccd/test-script.py @@ -0,0 +1,130 @@ +import argparse +import functools +#from pathlib import Path +#from pprint import pformat +import shlex +import textwrap +from typing import Any, 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" + +indent = functools.partial(textwrap.indent, prefix=' ') + +@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 + +parser = argparse.ArgumentParser() +#parser.add_argument("--no-detach", action="store_true") +#parser.add_argument("--daemon", action="store_true") +#parser.add_argument("--enable-tcp-insecure", action="store_true") +#parser.add_argument("--port", type=int) +parser.add_argument("--jobs", type=int) +parser.add_argument("--job-lifetime", type=int) +parser.add_argument("--log-level", type=str) +#parser.add_argument("--nice", type=int) +#parser.add_argument("--stats", action="store_true") +#parser.add_argument("--stats-port", type=int) + +@beartype +def get_cli_args() -> argparse.Namespace: + machine.wait_for_unit("distccd.service") + mainpid = int(machine.get_unit_property("distccd.service", "MainPID")) + machine.log(f"{mainpid=}") + pidtext = machine.succeed(f"pgrep -P {mainpid}") + machine.log(f"{pidtext=}") + pid = int(pidtext.splitlines()[0]) + machine.log(f"{pid=}") + execstart = machine.get_unit_property("distccd.service", "ExecStart") + print(f"{execstart=}") + + cmdline = machine.succeed(f"cat /proc/{pid}/cmdline") + cmdline_args = cmdline.split("\0") + machine.log(f"{cmdline_args=}") + print(f"{cmdline_args=}") + + #return shlex.join(cmdline_args[1:]) + args, rest = parser.parse_known_args(cmdline_args) + return args + +@beartype +def dynix_append(option: str, value: Any): + machine.succeed(f''' + dynix append {shlex.quote(option)} {shlex.quote(str(value))} + '''.strip()) + +@beartype +def do_apply(): + expr = textwrap.dedent(""" + let + nixos = import { }; + in nixos.config.dynamicism.applyDynamicConfiguration { + baseConfiguration = /etc/nixos/configuration.nix; + newConfiguration = /etc/nixos/dynamic.nix; + } + """).strip() + + machine.succeed(rf""" + nix run --show-trace --log-format raw-with-logs --impure -E {shlex.quote(expr)} + """.strip()) + +machine.wait_for_unit("default.target") +assert "lix" in machine.succeed("nix --version").lower() +machine.log("INIT") + +# Config should have our initial values. +args = get_cli_args() +#assert '--jobs 12' in args, f'--jobs 12 not in {args=}' +assert args.jobs == 12, f'{args.jobs=} != 12' +assert args.job_lifetime == 900, f'{args.job_lifetime} != 900' +assert args.log_level == 'warning', f'{args.log_level=} != warning' + +with machine.nested("must succeed: initial nixos-rebuild switch"): + machine.succeed("env PAGER= nixos-rebuild switch --log-format raw-with-logs --fallback") + +# Config should not have changed. +args = get_cli_args() +#machine.log(f"config.toml after first rebuild: {indent(pformat(args))}") +#assert int(args['workers']) == 4, f"{args['workers']=} != 4" +#assert int(args['max_connection_rate']) == 256, f"{args['max_connection_rate']=} != 256" +# +new_jobs = 4 +dynix_append("services.distccd.maxJobs", new_jobs) +do_apply() + +args = get_cli_args() + +# Only jobs should have changed. The others should still be default. +assert args.jobs == new_jobs, f'{args.jobs=} != {new_jobs=}' +assert args.job_lifetime == 900, f'{args.job_lifetime} != 900' +assert args.log_level == 'warning', f'{args.log_level=} != warning' + +new_log_level = 'error' +dynix_append("services.distccd.logLevel", f'"{new_log_level}"') +do_apply() + +args = get_cli_args() +#assert args.jobs == new_jobs, f'{args.jobs=} != {new_jobs=}' +#assert args.job_lifetime == 900, f'{args.job_lifetime} != 900' +assert args.log_level == new_log_level, f'{args.log_level=} != {new_log_level=}' diff --git a/tests/distccd/test.nix b/tests/distccd/test.nix new file mode 100644 index 0000000..ad9ac73 --- /dev/null +++ b/tests/distccd/test.nix @@ -0,0 +1,22 @@ +{ mkDynixConfigurationDotNix, config, ... }: +let + testName = config.name; +in +{ + name = "nixos-test-dynamicism-distccd"; + + defaults = { ... }: { }; + + extraPythonPackages = p: [ p.beartype ]; + + nodes.machine = { ... }: { + imports = [ ./configuration.nix ]; + + passthru.configurationDotNix = mkDynixConfigurationDotNix { + name = testName; + configuration = ./configuration.nix; + }; + }; + + testScript = builtins.readFile ./test-script.py; +} diff --git a/tests/dynix-vm-configuration.nix b/tests/dynix-vm-configuration.nix new file mode 100644 index 0000000..fbc6193 --- /dev/null +++ b/tests/dynix-vm-configuration.nix @@ -0,0 +1,59 @@ +{ pkgs, lib, modulesPath, ... }: +let + moduleList = import "${modulesPath}/module-list.nix"; + + dynixFromSearchPath = let + res = builtins.tryEval ; + in lib.optional res.success res.value; +in +{ + imports = [ + "${modulesPath}/testing/test-instrumentation.nix" + ] ++ lib.concatLists [ + dynixFromSearchPath + moduleList + ]; + + system.switch.enable = true; + system.includeBuildDependencies = true; + documentation.enable = false; + + boot.loader.grub = { + enable = true; + device = "/dev/vda"; + forceInstall = true; + }; + + nix = { + package = pkgs.lixPackageSets.latest.lix; + nixPath = [ + "nixpkgs=${pkgs.path}" + "/nix/var/nix/profiles/per-user/root/profile/share/nixos/modules" + ]; + + settings = { + experimental-features = [ "nix-command" "pipe-operator" ]; + substituters = lib.mkForce [ ]; + hashed-mirrors = null; + connect-timeout = 1; + # For my debugging purposes. + show-trace = true; + }; + }; + + environment.pathsToLink = [ "/share" ]; + environment.extraOutputsToInstall = [ "modules" ]; + 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/gotosocial/configuration-package.nix b/tests/gotosocial/configuration-package.nix new file mode 100644 index 0000000..96a89d8 --- /dev/null +++ b/tests/gotosocial/configuration-package.nix @@ -0,0 +1,7 @@ +{ + runCommand, +}: runCommand "tests-gotosocial-configuration-dot-nix" { +} '' + set -euo pipefail + install -Dm a=r ${./configuration.nix} "$out/share/nixos/configuration.nix" +'' diff --git a/tests/gotosocial/configuration.nix b/tests/gotosocial/configuration.nix new file mode 100644 index 0000000..8ada5c8 --- /dev/null +++ b/tests/gotosocial/configuration.nix @@ -0,0 +1,17 @@ +{ pkgs, lib, config, modulesPath, ... }: +let + name = config.networking.hostName; +in +{ + networking.hostName = "gotosocial-machine"; + services.gotosocial = { + enable = true; + setupPostgresqlDB = true; + settings = { + application-name = "gotosocial-for-machine"; + host = "${name}.local"; + }; + }; + + dynamicism.for.gotosocial.enable = true; +} diff --git a/tests/basic/default.nix b/tests/gotosocial/default.nix similarity index 100% rename from tests/basic/default.nix rename to tests/gotosocial/default.nix diff --git a/tests/gotosocial/hardware-configuration.nix b/tests/gotosocial/hardware-configuration.nix new file mode 100644 index 0000000..f036a72 --- /dev/null +++ b/tests/gotosocial/hardware-configuration.nix @@ -0,0 +1,7 @@ +/** Dummy hardware configuration. + * Will be replaced with the real one in the test VM. + */ +{ ... }: +{ + +} diff --git a/tests/gotosocial/test-script.py b/tests/gotosocial/test-script.py new file mode 100644 index 0000000..9799dfb --- /dev/null +++ b/tests/gotosocial/test-script.py @@ -0,0 +1,120 @@ +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")) + + 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: + data = f.read() + + config_file_path.unlink() + + return data + +@beartype +def dynix_append(option: str, value: str): + machine.succeed(f''' + dynix append {shlex.quote(option)} {shlex.quote(value)} + '''.strip()) + +@beartype +def do_apply(): + expr = textwrap.dedent(""" + let + nixos = import { }; + in nixos.config.dynamicism.applyDynamicConfiguration { + baseConfiguration = /etc/nixos/configuration.nix; + newConfiguration = /etc/nixos/dynamic.nix; + } + """).strip() + + machine.succeed(rf""" + nix run --show-trace --log-format raw-with-logs --impure -E {shlex.quote(expr)} + """.strip()) + + +machine.wait_for_unit("default.target") +assert "lix" in machine.succeed("nix --version").lower() +machine.wait_for_unit("install-dynix.service") + +dynix_out = machine.succeed("dynix --version") +assert "dynix" in dynix_out, f"dynix not in {dynix_out=}" + +machine.log("REBUILDING configuration inside VM") +machine.succeed("env PAGER= nixos-rebuild switch --log-format raw-with-logs --fallback") +machine.wait_for_unit("gotosocial.service") + +# Make sure the config before any dynamic changes is what we expect. +config_text = get_config_file() +lines = config_text.splitlines() +try: + application_name = next(line for line in lines if line.startswith("application-name:")) +except StopIteration: + raise AssertionError(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!" +dynix_append("services.gotosocial.settings.application-name", f'"{new_app_name}"') +do_apply() + +config_text = get_config_file() +lines = config_text.splitlines() +try: + application_name = next(line for line in lines if line.startswith("application-name:")) +except StopIteration: + raise AssertionError(f"no 'application-name:' found in config file: {textwrap.indent(config_text, " ")}") +assert new_app_name in application_name, f"'{new_app_name}' should be in {application_name=}" + +machine.log("REBUILDING configuration inside VM") +machine.succeed("env PAGER= nixos-rebuild switch --log-format raw-with-logs --fallback") + +machine.wait_for_unit("gotosocial.service") + +config_text = get_config_file() +lines = config_text.splitlines() +try: + application_name = next(line for line in lines if line.startswith("application-name:")) +except StopIteration: + raise AssertionError(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=}" diff --git a/tests/gotosocial/test.nix b/tests/gotosocial/test.nix new file mode 100644 index 0000000..ccb71f6 --- /dev/null +++ b/tests/gotosocial/test.nix @@ -0,0 +1,28 @@ +{ mkDynixConfigurationDotNix, config, ... }: +let + testName = config.name; +in +{ + name = "nixos-test-dynamicism-gotosocial"; + + defaults = { ... }: { }; + + extraPythonPackages = p: [ + p.beartype + ]; + + nodes.machine = { ... }: { + # NOTE: Anything in this `nodes.machine = ` module will not be included + # in the VM's NixOS configuration once it does `nixos-rebuild switch`, + # except for `./configuration.nix` which will be copied to `/etc/nixos/`. + # dynix will also be statefully installed to root's user profile. + imports = [ ./configuration.nix ]; + + passthru.configurationDotNix = mkDynixConfigurationDotNix { + name = testName; + configuration = ./configuration.nix; + }; + }; + + testScript = builtins.readFile ./test-script.py; +} diff --git a/tests/harmonia/configuration.nix b/tests/harmonia/configuration.nix new file mode 100644 index 0000000..37494cb --- /dev/null +++ b/tests/harmonia/configuration.nix @@ -0,0 +1,30 @@ +{ lib, modulesPath, ... }: +let + moduleList = import "${modulesPath}/module-list.nix"; + + dynixFromSearchPath = let + res = builtins.tryEval ; + in lib.optional res.success res.value; +in +{ + imports = [ + #"${modulesPath}/testing/test-instrumentation.nix" + #./hardware-configuration.nix + ] ++ lib.concatLists [ + #dynixFromSearchPath + moduleList + ]; + + dynamicism.for.harmonia.enable = true; + services.harmonia = { + enable = true; + settings = { + # Default. + workers = 4; + # Default. + max_connection_rate = 256; + }; + }; + + networking.hostName = "harmonia-machine"; +} diff --git a/tests/harmonia/hardware-configuration.nix b/tests/harmonia/hardware-configuration.nix new file mode 100644 index 0000000..facb35d --- /dev/null +++ b/tests/harmonia/hardware-configuration.nix @@ -0,0 +1,4 @@ +{ ... }: +{ + +} diff --git a/tests/harmonia/test-script.py b/tests/harmonia/test-script.py new file mode 100644 index 0000000..215ea38 --- /dev/null +++ b/tests/harmonia/test-script.py @@ -0,0 +1,124 @@ +import functools +from pathlib import Path +from pprint import pformat +import shlex +import textwrap +import tomllib +from typing import Any, 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" + +indent = functools.partial(textwrap.indent, prefix=' ') + +@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() -> dict[str, Any]: + machine.wait_for_unit("harmonia.service") + pid = int(machine.get_unit_property("harmonia.service", "MainPID")) + env_lines: list[str] = machine.succeed(f"cat /proc/{pid}/environ").replace("\0", "\n").splitlines() + pairs: list[list[str]] = [line.split("=", maxsplit=1) for line in env_lines] + env = dict(pairs) + + config_file = Path(env["CONFIG_FILE"]) + + 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, "rb") as f: + config_data = tomllib.load(f) + + config_file_path.unlink() + return config_data + +@beartype +def dynix_append(option: str, value: Any): + machine.succeed(f''' + dynix append {shlex.quote(option)} {shlex.quote(str(value))} + '''.strip()) + +@beartype +def do_apply(): + expr = textwrap.dedent(""" + let + nixos = import { }; + in nixos.config.dynamicism.applyDynamicConfiguration { + baseConfiguration = /etc/nixos/configuration.nix; + newConfiguration = /etc/nixos/dynamic.nix; + } + """).strip() + + machine.succeed(rf""" + nix run --show-trace --log-format raw-with-logs --impure -E {shlex.quote(expr)} + """.strip()) + +machine.wait_for_unit("default.target") +assert "lix" in machine.succeed("nix --version").lower() +machine.log("INIT") + +# Config should have our initial values. +config_toml = get_config_file() +machine.log(f"config.toml BEFORE first rebuild (initial): {indent(pformat(config_toml))}") +assert int(config_toml['workers']) == 4, f"{config_toml['workers']=} != 4" +assert int(config_toml['max_connection_rate']) == 256, f"{config_toml['max_connection_rate']=} != 256" + +with machine.nested("must succeed: initial nixos-rebuild switch"): + machine.succeed("env PAGER= nixos-rebuild switch --log-format raw-with-logs --fallback") + +# Config should not have changed. +config_toml = get_config_file() +machine.log(f"config.toml after first rebuild: {indent(pformat(config_toml))}") +assert int(config_toml['workers']) == 4, f"{config_toml['workers']=} != 4" +assert int(config_toml['max_connection_rate']) == 256, f"{config_toml['max_connection_rate']=} != 256" + +new_workers = 20 +dynix_append("services.harmonia.settings.workers", new_workers) +do_apply() + +# Workers, but not max connection rate, should have changed. +config_toml = get_config_file() +machine.log(f"config.toml after DYNAMIC ACTIVATION: {indent(pformat(config_toml))}") +assert int(config_toml['workers']) == new_workers, f"{config_toml['workers']=} != {new_workers}" +assert int(config_toml['max_connection_rate']) == 256, f"{config_toml['max_connection_rate']=} != 256" + +new_max_connection_rate = 100 +dynix_append("services.harmonia.settings.max_connection_rate", new_max_connection_rate) +do_apply() + +# Max connection rate should have changed, but workers should have reverted. +config_toml = get_config_file() +machine.log(f"config_toml after SECOND dynamic activation: {indent(pformat(config_toml))}") +assert int(config_toml['max_connection_rate']) == new_max_connection_rate, f"{config_toml['max_connection_rate']=} != {new_max_connection_rate}" + +# And this should set everything back. +machine.succeed("env PAGER= nixos-rebuild switch --log-format raw-with-logs --fallback") +machine.systemctl("restart harmonia.service") +machine.wait_for_unit("harmonia.service") +config_toml = get_config_file() +machine.log(f"config_toml after NORMAL activation: {indent(pformat(config_toml))}") +assert int(config_toml['max_connection_rate']) == 256, f'{config_toml["max_connection_rate"]=} != 256' +assert int(config_toml['workers']) == 4, f'{config_toml["workers"]=} != 4' diff --git a/tests/harmonia/test.nix b/tests/harmonia/test.nix new file mode 100644 index 0000000..25fcfbf --- /dev/null +++ b/tests/harmonia/test.nix @@ -0,0 +1,22 @@ +{ mkDynixConfigurationDotNix, config, ... }: +let + testName = config.name; +in +{ + name = "nixos-test-dynamicism-harmonia"; + + defaults = { ... }: { }; + + extraPythonPackages = p: [ p.beartype ]; + + nodes.machine = { ... }: { + imports = [ ./configuration.nix ]; + + passthru.configurationDotNix = mkDynixConfigurationDotNix { + name = testName; + configuration = ./configuration.nix; + }; + }; + + testScript = builtins.readFile ./test-script.py; +} diff --git a/tests/mk-test-configuration-dot-nix.nix b/tests/mk-test-configuration-dot-nix.nix new file mode 100644 index 0000000..6b7dc25 --- /dev/null +++ b/tests/mk-test-configuration-dot-nix.nix @@ -0,0 +1,67 @@ +{ + lib, + stdenvNoCC, +}: let + stdenv = stdenvNoCC; + + mkDynixConfigurationDotNix = finalAttrs: { + name, + /** A *path* to a `configuration.nix`-like NixOS module. + * NOT a NixOS module *value*. + */ + configuration, + }: assert lib.isStringLike configuration; let + self = finalAttrs.finalPackage; + in { + name = "configuration-dot-nix-for-${name}"; + strictDeps = true; + __structuredAttrs = true; + preferLocalBuild = true; + + phases = [ "installPhase" ]; + + outputs = [ "out" ]; + modulesOut = "${placeholder "out"}/share/nixos"; + + baseConfiguration = configuration; + dynixVmConfiguration = ./dynix-vm-configuration.nix; + + installPhase = '' + runHook preInstall + + install -Dm a=r "$baseConfiguration" "$modulesOut/test-configuration.nix" + install -Dm a=r "$dynixVmConfiguration" "$modulesOut/dynix-vm-configuration.nix" + + echo "/** GENERATED BY mk-test-configuration-dot-nix! */" >> "$modulesOut/configuration.nix" + echo "{ ... }:" >> "$modulesOut/configuration.nix" + echo >> "$modulesOut/configuration.nix" + echo >> "$modulesOut/configuration.nix" + echo "{" >> "$modulesOut/configuration.nix" + echo " imports = [" >> "$modulesOut/configuration.nix" + echo " ./test-configuration.nix" >> "$modulesOut/configuration.nix" + echo " ./dynix-vm-configuration.nix" >> "$modulesOut/configuration.nix" + echo " ./hardware-configuration.nix" >> "$modulesOut/configuration.nix" + echo " ];" >> "$modulesOut/configuration.nix" + echo "}" >> "$modulesOut/configuration.nix" + + echo "/** GENERATED BY mk-test-configuration-dot-nix! */" >> "$modulesOut/dynamic.nix" + echo "{ lib, ... }:" >> "$modulesOut/dynamic.nix" + echo >> "$modulesOut/dynamic.nix" + echo >> "$modulesOut/dynamic.nix" + echo "{" >> "$modulesOut/dynamic.nix" + echo " imports = [ ./configuration.nix ];" >> "$modulesOut/dynamic.nix" + echo " config = lib.mkMerge [" >> "$modulesOut/dynamic.nix" + echo " ];" >> "$modulesOut/dynamic.nix" + echo "}" >> "$modulesOut/dynamic.nix" + runHook postInstall + ''; + + passthru = { + modulesPath = self.out + "/share/nixos"; + configuration = self.out + "/share/nixos/configuration.nix"; + }; + }; +in lib.extendMkDerivation { + constructDrv = stdenv.mkDerivation; + extendDrvArgs = mkDynixConfigurationDotNix; +} diff --git a/tests/module-allow-rebuild-in-vm.nix b/tests/module-allow-rebuild-in-vm.nix new file mode 100644 index 0000000..96280fe --- /dev/null +++ b/tests/module-allow-rebuild-in-vm.nix @@ -0,0 +1,31 @@ +{ name, config, ... }: + +{ + system.includeBuildDependencies = true; + system.switch.enable = true; + + documentation.enable = false; + + virtualisation = { + additionalPaths = [ config.system.build.toplevel ]; + writableStore = true; + mountHostNixStore = true; + installBootLoader = true; + + # With how much memory Nix eval uses, this is essentially required. + memorySize = 8192; + cores = 8; + + }; + + boot.loader.grub = { + enable = true; + device = "/dev/vda"; + forceInstall = true; + }; + + # Just making something in this strict in `name`, + # which is only present as an argument for nodes and I don't want to + # confuse that with the test modules. + warnings = builtins.seq name [ ]; +} diff --git a/tests/basic/configuration.nix b/tests/tzupdate/configuration.nix similarity index 53% rename from tests/basic/configuration.nix rename to tests/tzupdate/configuration.nix index 06470e3..176975c 100644 --- a/tests/basic/configuration.nix +++ b/tests/tzupdate/configuration.nix @@ -1,28 +1,30 @@ { pkgs, lib, config, modulesPath, ... }: let name = config.networking.hostName; - nixosLibPath = (modulesPath + "/../lib"); - moduleList = import (modulesPath + "/module-list.nix"); + moduleList = import "${modulesPath}/module-list.nix"; - optionalPath = p: lib.optional (builtins.pathExists p) p; + dynixFromSearchPath = let + res = builtins.tryEval ; + in lib.optional res.success res.value; in -assert builtins.pathExists nixosLibPath; -builtins.seq lib -builtins.seq modulesPath -builtins.seq moduleList { - imports = moduleList ++ [ - (modulesPath + "/testing/test-instrumentation.nix") + imports = [ + "${modulesPath}/testing/test-instrumentation.nix" + ./hardware-configuration.nix ] ++ lib.concatLists [ - (optionalPath ./hardware-configuration.nix) - (optionalPath ./dynamicism) - (optionalPath ../../modules/dynamicism) + moduleList + dynixFromSearchPath ]; + dynamicism.for.tzupdate.enable = true; + services.tzupdate = { + enable = true; + }; + system.switch.enable = true; documentation.enable = false; - networking.hostName = "machine"; + networking.hostName = "tzupdate-machine"; boot.loader.grub = { enable = true; @@ -32,28 +34,23 @@ builtins.seq moduleList nix = { package = pkgs.lixPackageSets.latest.lix; - nixPath = [ "nixpkgs=${pkgs.path}" ]; + nixPath = [ + "nixpkgs=${pkgs.path}" + "/nix/var/nix/profiles/per-user/root/profile/share/nixos/modules" + ]; settings = { experimental-features = [ "nix-command" "pipe-operator" ]; substituters = lib.mkForce [ ]; hashed-mirrors = null; connect-timeout = 1; + # For my debugging purposes. + show-trace = true; }; }; - services.gotosocial = { - enable = true; - setupPostgresqlDB = true; - settings = { - application-name = "gotosocial-for-${name}"; - host = "${name}.local"; - }; - }; - - dynamicism.for.gotosocial.enable = true; - environment.pathsToLink = [ "/share" ]; + environment.extraOutputsToInstall = [ "modules" ]; environment.variables = { "NIXOS_CONFIG" = "/etc/nixos/configuration.nix"; }; @@ -61,6 +58,7 @@ builtins.seq moduleList environment.shellAliases = { ls = "eza --long --header --group --group-directories-first --classify --binary"; }; + environment.systemPackages = with pkgs; [ eza fd diff --git a/tests/tzupdate/hardware-configuration.nix b/tests/tzupdate/hardware-configuration.nix new file mode 100644 index 0000000..facb35d --- /dev/null +++ b/tests/tzupdate/hardware-configuration.nix @@ -0,0 +1,4 @@ +{ ... }: +{ + +} diff --git a/tests/tzupdate/test-script.py b/tests/tzupdate/test-script.py new file mode 100644 index 0000000..53762a0 --- /dev/null +++ b/tests/tzupdate/test-script.py @@ -0,0 +1,52 @@ +#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 + +machine.wait_for_unit("default.target") +assert "lix" in machine.succeed("nix --version").lower() +machine.log("INIT") + + +machine.succeed("nixos-generate-config") +machine.succeed("mkdir -vp /etc/nixos") +# Dereference is required since that configuration.nix is probably a symlink to the store. +machine.succeed("cp -rv --dereference /run/current-system/sw/share/nixos/configuration.nix /etc/nixos/") + +machine.succeed("env PAGER= nixos-rebuild switch --log-format raw-with-logs --fallback") + +@beartype +def get_interval() -> str: + prop = machine.get_unit_property("tzupdate.timer", "OnCalendar") + print(f"{prop=}") + + return prop + +get_interval() diff --git a/tests/tzupdate/test.nix b/tests/tzupdate/test.nix new file mode 100644 index 0000000..2d42c10 --- /dev/null +++ b/tests/tzupdate/test.nix @@ -0,0 +1,24 @@ +{ ... }: + +{ + name = "nixos-test-dynamicism-tzupdate"; + + extraPythonPackages = p: [ + p.beartype + ]; + + nodes.machine = { name, pkgs, ... }: { + imports = [ ./configuration.nix ]; + + environment.systemPackages = let + configFileTree = pkgs.runCommand "${name}-configuration-dot-nix" { } '' + set -euo pipefail + install -Dm a=r ${./configuration.nix} "$out/share/nixos/configuration.nix" + ''; + in [ + configFileTree + ]; + }; + + testScript = builtins.readFile ./test-script.py; +}