Compare commits

...

18 commits

Author SHA1 Message Date
Qyriad
d76474c524 how did THAT break it 2026-02-17 20:17:24 +01:00
Qyriad
3765e918d6 IT WORKS 2026-02-17 19:48:53 +01:00
Qyriad
da509d97c7 packaging: split derivations correctly to avoid Rust rebuilds for module
changes
2026-02-17 16:12:12 +01:00
Qyriad
26397ccf37 packaging: use fenix for dev shell 2026-02-17 16:10:10 +01:00
Qyriad
f46c2a9934 packaging: convert split derivation to hybrid split/multi-derivation 2026-02-17 13:13:36 +01:00
Qyriad
4aac9a8dba maint(deps): update Cargo.lock 2026-02-16 19:39:48 +01:00
Qyriad
dfdf027bc6 restore old files now! 2026-02-16 19:05:02 +01:00
Qyriad
76b5ac628d start new dynamic apply function 2026-02-16 18:22:04 +01:00
Qyriad
3ed2f2e1a8 add distccd 2026-02-16 12:23:38 +01:00
Qyriad
af46de5628 include post-change configuration in allActivationScripts passthru 2026-02-13 20:47:30 +01:00
Qyriad
4268754afb little more cleanup 2026-02-13 20:47:30 +01:00
Qyriad
15641360ca make dynamicism/default.nix a stub for dynamicism.nix 2026-02-13 20:47:30 +01:00
Qyriad
d7a0cbefe5 tests: refactor 2026-02-13 20:47:30 +01:00
Qyriad
8a6bd41baa remove old unused files for now 2026-02-13 12:22:04 +01:00
Qyriad
8dba8e7ce8 working on harmonia 2026-02-11 13:16:34 +01:00
Qyriad
1f466b63d3 tests.basic -> tests.gotosocial 2026-02-10 14:20:46 +01:00
Qyriad
68e9b9a1e4 significantly improve purity 2026-02-10 14:18:45 +01:00
Qyriad
45a7d43f77 flake.lock: Update
Flake lock file updates:

• Updated input 'nixpkgs':
    'github:NixOS/nixpkgs/ed142ab1b3a092c4d149245d0c4126a5d7ea00b0' (2026-01-20)
  → 'github:NixOS/nixpkgs/fef9403a3e4d31b0a23f0bacebbec52c248fbb51' (2026-02-08)
• Updated input 'qyriad-nur':
    'github:Qyriad/nur-packages/c08309f918a0528ceb23659c0cc4a3c901fe8afa' (2026-01-02)
  → 'github:Qyriad/nur-packages/23716e0347215a721f9489515a0c3dc91122c7d5' (2026-02-06)
2026-02-09 14:17:27 +01:00
47 changed files with 1564 additions and 727 deletions

View file

@ -11,5 +11,5 @@ indent_style = space
indent_size = 4 indent_size = 4
[*.nix] [*.nix]
indent_style = tab indent_style = space
indent_size = 2 indent_size = 2

84
Cargo.lock generated
View file

@ -69,9 +69,9 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]] [[package]]
name = "bitflags" name = "bitflags"
version = "2.10.0" version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
[[package]] [[package]]
name = "cfg-if" name = "cfg-if"
@ -87,9 +87,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]] [[package]]
name = "clap" name = "clap"
version = "4.5.54" version = "4.5.58"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6e6ff9dcd79cff5cd969a17a545d79e84ab086e444102a591e288a8aa3ce394" checksum = "63be97961acde393029492ce0be7a1af7e323e6bae9511ebfac33751be5e6806"
dependencies = [ dependencies = [
"clap_builder", "clap_builder",
"clap_derive", "clap_derive",
@ -97,9 +97,9 @@ dependencies = [
[[package]] [[package]]
name = "clap_builder" name = "clap_builder"
version = "4.5.54" version = "4.5.58"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa42cf4d2b7a41bc8f663a7cab4031ebafa1bf3875705bfaf8466dc60ab52c00" checksum = "7f13174bda5dfd69d7e947827e5af4b0f2f94a4a3ee92912fba07a66150f21e2"
dependencies = [ dependencies = [
"anstream", "anstream",
"anstyle", "anstyle",
@ -109,9 +109,9 @@ dependencies = [
[[package]] [[package]]
name = "clap_derive" name = "clap_derive"
version = "4.5.49" version = "4.5.55"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5"
dependencies = [ dependencies = [
"heck", "heck",
"proc-macro2", "proc-macro2",
@ -121,9 +121,9 @@ dependencies = [
[[package]] [[package]]
name = "clap_lex" name = "clap_lex"
version = "0.7.7" version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831"
[[package]] [[package]]
name = "colorchoice" name = "colorchoice"
@ -158,6 +158,8 @@ dependencies = [
"fs-err", "fs-err",
"itertools", "itertools",
"libc", "libc",
"regex",
"regex-lite",
"serde", "serde",
"serde_json", "serde_json",
"tap", "tap",
@ -190,9 +192,9 @@ dependencies = [
[[package]] [[package]]
name = "fs-err" name = "fs-err"
version = "3.2.2" version = "3.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf68cef89750956493a66a10f512b9e58d9db21f2a573c079c0bdf1207a54a7" checksum = "73fde052dbfc920003cfd2c8e2c6e6d4cc7c1091538c3a24226cec0665ab08c0"
dependencies = [ dependencies = [
"autocfg", "autocfg",
] ]
@ -271,9 +273,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]] [[package]]
name = "libc" name = "libc"
version = "0.2.180" version = "0.2.182"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112"
[[package]] [[package]]
name = "linux-raw-sys" name = "linux-raw-sys"
@ -307,9 +309,9 @@ dependencies = [
[[package]] [[package]]
name = "memchr" name = "memchr"
version = "2.7.6" version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]] [[package]]
name = "nix" name = "nix"
@ -385,9 +387,9 @@ checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b"
[[package]] [[package]]
name = "proc-macro2" name = "proc-macro2"
version = "1.0.105" version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "535d180e0ecab6268a3e718bb9fd44db66bbbc256257165fc699dadf70d16fe7" checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [ dependencies = [
"unicode-ident", "unicode-ident",
] ]
@ -406,9 +408,9 @@ dependencies = [
[[package]] [[package]]
name = "quote" name = "quote"
version = "1.0.43" version = "1.0.44"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a" checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
] ]
@ -423,10 +425,22 @@ dependencies = [
] ]
[[package]] [[package]]
name = "regex-automata" name = "regex"
version = "0.4.13" version = "1.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index" 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 = [ dependencies = [
"aho-corasick", "aho-corasick",
"memchr", "memchr",
@ -434,10 +448,16 @@ dependencies = [
] ]
[[package]] [[package]]
name = "regex-syntax" name = "regex-lite"
version = "0.8.8" version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index" 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]] [[package]]
name = "rustix" name = "rustix"
@ -555,9 +575,9 @@ dependencies = [
[[package]] [[package]]
name = "syn" name = "syn"
version = "2.0.114" version = "2.0.116"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" checksum = "3df424c70518695237746f84cede799c9c58fcb37450d7b23716568cc8bc69cb"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@ -679,9 +699,9 @@ dependencies = [
[[package]] [[package]]
name = "unicode-ident" name = "unicode-ident"
version = "1.0.22" version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]] [[package]]
name = "unicode-linebreak" name = "unicode-linebreak"
@ -915,6 +935,6 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]] [[package]]
name = "zmij" name = "zmij"
version = "1.0.16" version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfcd145825aace48cff44a8844de64bf75feec3080e0aa5cdbde72961ae51a65" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"

View file

@ -11,12 +11,19 @@ path = "src/main.rs"
name = "dynix" name = "dynix"
path = "src/lib.rs" path = "src/lib.rs"
[features]
default = ["regex-full"]
regex-full = ["dep:regex"]
regex-lite = ["dep:regex-lite"]
[dependencies] [dependencies]
clap = { version = "4.5.54", features = ["color", "derive"] } clap = { version = "4.5.54", features = ["color", "derive"] }
command-error = "0.8.0" command-error = "0.8.0"
fs-err = "3.2.2" fs-err = "3.2.2"
itertools = "0.14.0" itertools = "0.14.0"
libc = { version = "0.2.180", features = ["extra_traits"] } 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 = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149" serde_json = "1.0.149"
tap = "1.0.1" tap = "1.0.1"

View file

@ -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";
}

View file

@ -5,71 +5,14 @@
in import src { inherit pkgs; }, in import src { inherit pkgs; },
}: let }: let
inherit (qpkgs) lib; inherit (qpkgs) lib;
dynix = qpkgs.callPackage ./package.nix { }
dynix = qpkgs.callPackage ./package.nix { }; |> qpkgs.stdlib.mkStdenvPretty;
byStdenv = lib.mapAttrs (stdenvName: stdenv: let byStdenv = lib.mapAttrs (stdenvName: stdenv: let
withStdenv = dynix.override { inherit stdenv; }; withStdenv = dynix.override { inherit stdenv; };
dynix' = withStdenv.overrideAttrs (prev: { dynix' = withStdenv.overrideAttrs (prev: {
pname = "${prev.pname}-${stdenvName}"; pname = "${prev.pname}-${stdenvName}";
}); });
in dynix') qpkgs.validStdenvs; in dynix') qpkgs.validStdenvs;
in dynix.overrideAttrs (prev: lib.recursiveUpdate prev {
evalNixos = import (pkgs.path + "/nixos"); passthru = { inherit byStdenv; };
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";
};
};
}) })

29
flake.lock generated
View file

@ -1,5 +1,21 @@
{ {
"nodes": { "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": { "flake-utils": {
"inputs": { "inputs": {
"systems": "systems" "systems": "systems"
@ -21,11 +37,11 @@
"nixpkgs": { "nixpkgs": {
"flake": false, "flake": false,
"locked": { "locked": {
"lastModified": 1768875095, "lastModified": 1770537093,
"narHash": "sha256-dYP3DjiL7oIiiq3H65tGIXXIT1Waiadmv93JS0sS+8A=", "narHash": "sha256-pF1quXG5wsgtyuPOHcLfYg/ft/QMr8NnX0i6tW2187s=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "ed142ab1b3a092c4d149245d0c4126a5d7ea00b0", "rev": "fef9403a3e4d31b0a23f0bacebbec52c248fbb51",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -38,11 +54,11 @@
"qyriad-nur": { "qyriad-nur": {
"flake": false, "flake": false,
"locked": { "locked": {
"lastModified": 1767357308, "lastModified": 1770385314,
"narHash": "sha256-PWDIBupTHASnzwPUuafIhwBCFKxjsSVj4QRAdX5y/z4=", "narHash": "sha256-zDlvon/yF9STxGi3l38j9EgTFHBHOjCJlP8mMX7zw5M=",
"owner": "Qyriad", "owner": "Qyriad",
"repo": "nur-packages", "repo": "nur-packages",
"rev": "c08309f918a0528ceb23659c0cc4a3c901fe8afa", "rev": "23716e0347215a721f9489515a0c3dc91122c7d5",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -53,6 +69,7 @@
}, },
"root": { "root": {
"inputs": { "inputs": {
"fenix": "fenix",
"flake-utils": "flake-utils", "flake-utils": "flake-utils",
"nixpkgs": "nixpkgs", "nixpkgs": "nixpkgs",
"qyriad-nur": "qyriad-nur" "qyriad-nur": "qyriad-nur"

View file

@ -5,6 +5,10 @@
flake = false; flake = false;
}; };
flake-utils.url = "github:numtide/flake-utils"; flake-utils.url = "github:numtide/flake-utils";
fenix = {
url = "github:nix-community/fenix";
flake = false;
};
qyriad-nur = { qyriad-nur = {
url = "github:Qyriad/nur-packages"; url = "github:Qyriad/nur-packages";
flake = false; flake = false;
@ -15,11 +19,13 @@
self, self,
nixpkgs, nixpkgs,
flake-utils, flake-utils,
fenix,
qyriad-nur, qyriad-nur,
}: flake-utils.lib.eachDefaultSystem (system: let }: flake-utils.lib.eachDefaultSystem (system: let
pkgs = import nixpkgs { inherit system; }; pkgs = import nixpkgs { inherit system; };
qpkgs = import qyriad-nur { inherit pkgs; }; qpkgs = import qyriad-nur { inherit pkgs; };
inherit (qpkgs) lib; inherit (qpkgs) lib;
fenixLib = import fenix { inherit pkgs; };
dynix = import ./default.nix { inherit pkgs qpkgs; }; dynix = import ./default.nix { inherit pkgs qpkgs; };
extraVersions = lib.mapAttrs' (stdenvName: value: { extraVersions = lib.mapAttrs' (stdenvName: value: {
@ -27,7 +33,7 @@
inherit value; inherit value;
}) dynix.byStdenv; }) dynix.byStdenv;
devShell = import ./shell.nix { inherit pkgs qpkgs dynix; }; devShell = import ./shell.nix { inherit pkgs qpkgs dynix fenixLib; };
extraDevShells = lib.mapAttrs' (stdenvName: value: { extraDevShells = lib.mapAttrs' (stdenvName: value: {
name = "${stdenvName}-dynix"; name = "${stdenvName}-dynix";
inherit value; inherit value;

View file

@ -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";
}
]

View file

@ -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
{ {
# imports = [ ./dynamicism.nix ];
# 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
'';
};
} }

View file

@ -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;
};
};
}

View file

@ -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
];
}

View file

@ -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; };
};
};
}

View file

@ -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; };
};
};
}

View file

@ -4,12 +4,12 @@
t = lib.types; t = lib.types;
in lib.fix (self: { in lib.fix (self: {
/** Perform module-system type checking and resolving on a single option value. */ /** Perform module-system type checking and resolving on a single option value. */
typeCheck = loc: option: value: typeCheck = loc: optionType: value:
assert lib.isOptionType option; assert lib.isOptionType optionType;
assert lib.isList loc; assert lib.isList loc;
assert lib.all lib.isString loc; assert lib.all lib.isString loc;
let let
merged = lib.modules.mergeDefinitions loc option [ { merged = lib.modules.mergeDefinitions loc optionType [ {
inherit value; inherit value;
file = "«inline»"; file = "«inline»";
} ]; } ];
@ -18,6 +18,19 @@ in lib.fix (self: {
/** Either a list of strings, or a dotted string that will be split. */ /** Either a list of strings, or a dotted string that will be split. */
convenientAttrPath = t.coercedTo t.str (lib.splitString ".") (t.listOf t.str); 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; concatFoldl = f: list: lib.foldl' (acc: value: acc ++ (f value)) [ ] list;
recUpdateFoldl = f: list: lib.foldl' (acc: value: lib.recursiveUpdate 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; recUpdateFoldlAttrs = f: attrs: lib.foldlAttrs (acc: name: value: lib.recursiveUpdate acc (f name value)) { } attrs;

View file

@ -1,8 +1,8 @@
{ {
name, name,
pkgs,
lib, lib,
config, config,
host,
... ...
}: }:
let let
@ -14,26 +14,15 @@ let
mkEnableOption mkEnableOption
literalExpression literalExpression
; ;
inherit (lib.types)
mkOptionType
;
t = lib.types; t = lib.types;
/** Either a list of strings, or a dotted string that will be split. */ inherit (import ./lib.nix { inherit lib; })
convenientAttrPath = t.coercedTo t.str (lib.splitString ".") (t.listOf t.str); convenientAttrPath
executablePathInStore
recUpdateFoldl
;
executablePathInStore = mkOptionType { pkgs = host.pkgs;
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
);
};
in in
{ {
options = { options = {
@ -59,13 +48,22 @@ in
default = lib.attrNames config.unitDropins; default = lib.attrNames config.unitDropins;
}; };
configFile = mkOption { finalSettings = mkOption {
type = t.pathInStore; type = t.attrsOf t.raw;
internal = true; 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 { unitDropins = mkOption {
type = t.attrsOf t.pathInStore; type = t.attrsOf t.package;
internal = true; internal = true;
}; };
@ -82,17 +80,19 @@ in
text = let text = let
doEdits = config.unitDropins doEdits = config.unitDropins
|> lib.mapAttrsToList (service: dropin: '' |> 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 doReloads = config.unitDropins
|> lib.mapAttrsToList (service: _: '' |> lib.mapAttrsToList (service: _: ''
systemctl reload-or-restart "${service}" systemctl reload-or-restart "${service}"
''); '')
in [ |> lib.concatStringsSep "\n";
doEdits in ''
doReloads ${doEdits}
] |> lib.concatLists
|> lib.concatStringsSep "\n"; ${doReloads}
'';
}; };
}; };
} }

View file

@ -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}
'';
};
}

View file

@ -1,4 +0,0 @@
{ pkgs, ... }:
{
nix.package = pkgs.lixPackageSets.latest.lix;
}

View file

@ -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 <nixpkgs/nixos> {{ 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=}")

View file

@ -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;
};
})

View file

@ -1,6 +1,8 @@
{ {
lib, lib,
stdenv, clangStdenv,
callPackage,
linkFarm,
rustHooks, rustHooks,
rustPackages, rustPackages,
versionCheckHook, versionCheckHook,
@ -8,79 +10,151 @@
rustPlatform, rustPlatform,
cargo, cargo,
}: let }: let
stdenv = clangStdenv;
cargoToml = lib.importTOML ./Cargo.toml; cargoToml = lib.importTOML ./Cargo.toml;
cargoPackage = cargoToml.package; cargoPackage = cargoToml.package;
in stdenv.mkDerivation (finalAttrs: let
in stdenv.mkDerivation (self: { self = finalAttrs.finalPackage;
in {
pname = cargoPackage.name; pname = cargoPackage.name;
version = cargoPackage.version; version = cargoPackage.version;
strictDeps = true; strictDeps = true;
__structuredAttrs = true; __structuredAttrs = true;
outputs = [ "out" "modules" ];
doCheck = true; doCheck = true;
doInstallCheck = true; doInstallCheck = true;
src = lib.fileset.toSource { phases = [ "unpackPhase" "patchPhase" "installPhase" ];
root = ./.;
fileset = lib.fileset.unions [ src = linkFarm "dynix-source" {
./Cargo.toml inherit (self) dynixCommand dynixModules;
./Cargo.lock
./src
];
}; };
cargoDeps = rustPlatform.importCargoLock { installPhase = lib.dedent ''
lockFile = ./Cargo.lock; 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 ++ [ runHook postInstall
cargo '';
];
nativeInstallCheckInputs = [ #
versionCheckHook # SUB-DERIVATONS
]; #
passthru.mkDevShell = { dynixCommand = stdenv.mkDerivation (finalAttrs: let
mkShell, commandSelf = finalAttrs.finalPackage;
}: let in {
mkShell' = mkShell.override { stdenv = stdenv; }; pname = "${self.pname}-command";
in mkShell' { inherit (self) version;
name = "${self.pname}-devshell-${self.version}"; inherit (self) strictDeps __structuredAttrs;
inputsFrom = [ self.finalPackage ]; inherit (self) doCheck doInstallCheck;
packages = [
rustPackages.rustc
rustPackages.rustfmt
];
};
passthru.tests.clippy = self.finalPackage.overrideAttrs (prev: { src = lib.fileset.toSource {
pname = "${self.pname}-clippy"; root = ./.;
fileset = lib.fileset.unions [
./Cargo.toml
./Cargo.lock
./src
];
};
nativeCheckInputs = prev.nativeCheckInputs or [ ] ++ [ cargoDeps = rustPlatform.importCargoLock {
rustPackages.clippy lockFile = ./Cargo.lock;
};
nativeBuildInputs = rustHooks.asList ++ [
cargo
]; ];
dontConfigure = true; nativeInstallCheckInputs = [
dontBuild = true; versionCheckHook
doCheck = true; ];
dontFixup = true;
dontInstallCheck = true;
checkPhase = lib.trim '' meta = {
echo "cargoClippyPhase()" mainProgram = "dynix";
cargo clippy --all-targets --profile "$cargoCheckType" -- --deny warnings };
''; });
installPhase = lib.trim '' dynixModules = stdenv.mkDerivation (finalAttrs: let
touch "$out" 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 = { 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"; mainProgram = "dynix";
outputsToInstall = [ "out" "modules" ];
}; };
})) }))

View file

@ -1,23 +1,26 @@
{ {
pkgs ? import <nixpkgs> { }, pkgs ? import <nixpkgs> {
config = {
checkMeta = true;
allowAliases = false;
};
},
qpkgs ? let qpkgs ? let
src = fetchTarball "https://github.com/Qyriad/nur-packages/archive/main.tar.gz"; src = fetchTarball "https://github.com/Qyriad/nur-packages/archive/main.tar.gz";
in import src { inherit pkgs; }, in import src { inherit pkgs; },
dynix ? import ./default.nix { inherit pkgs qpkgs; }, 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 }: let
inherit (pkgs) lib; inherit (pkgs) lib;
mkDevShell = dynix: qpkgs.callPackage dynix.mkDevShell { }; mkDevShell = dynix: qpkgs.callPackage dynix.mkDevShell { inherit fenixToolchain; };
devShell = mkDevShell dynix; devShell = mkDevShell dynix;
byStdenv = lib.mapAttrs (lib.const mkDevShell) dynix.byStdenv; byStdenv = lib.mapAttrs (lib.const mkDevShell) dynix.byStdenv;
in devShell.overrideAttrs (prev: lib.recursiveUpdate prev { in devShell.overrideAttrs (prev: {
passthru = { inherit byStdenv; }; passthru = { inherit byStdenv; };
env.PYTHONPATH = [
"${pkgs.python3Packages.beartype}/${pkgs.python3.sitePackages}"
] |> lib.concatStringsSep ":";
packages = prev.packages or [ ] ++ [
pkgs.python3Packages.beartype
];
}) })

View file

@ -1,4 +1,7 @@
use std::sync::Arc; use std::{
env,
sync::{Arc, LazyLock},
};
use clap::ColorChoice; use clap::ColorChoice;
@ -52,6 +55,7 @@ impl FromStr for NixOsOption {
pub struct AppendCmd { pub struct AppendCmd {
#[arg(required = true)] #[arg(required = true)]
pub name: Arc<str>, pub name: Arc<str>,
#[arg(required = true)] #[arg(required = true)]
pub value: Arc<str>, pub value: Arc<str>,
} }
@ -67,6 +71,28 @@ pub enum Subcommand {
Delta(DeltaCmd), Delta(DeltaCmd),
} }
static DEFAULT_PATH: LazyLock<Box<OsStr>> = 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)] #[derive(Debug, Clone, PartialEq, clap::Parser)]
#[command(version, about, author)] #[command(version, about, author)]
#[command(arg_required_else_help(true), args_override_self(true))] #[command(arg_required_else_help(true), args_override_self(true))]
@ -75,8 +101,10 @@ pub struct Args {
#[arg(long, global(true), default_value = "auto")] #[arg(long, global(true), default_value = "auto")]
pub color: ColorChoice, pub color: ColorChoice,
// FIXME: default to /etc/configuration.nix, or something? /// The .nix file with dynamic overrides to modify.
#[arg(long, global(true), default_value = "./configuration.nix")] /// [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<OsStr>, pub file: Arc<OsStr>,
#[command(subcommand)] #[command(subcommand)]

View file

@ -1,4 +1,7 @@
use std::{iter, sync::Arc}; use std::{
iter,
sync::{Arc, LazyLock},
};
pub(crate) mod prelude { pub(crate) mod prelude {
#![allow(unused_imports)] #![allow(unused_imports)]
@ -41,14 +44,38 @@ pub mod line;
mod nixcmd; mod nixcmd;
pub use line::Line; pub use line::Line;
pub mod source; 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 serde::{Deserialize, Serialize};
use crate::source::SourceFile;
pub const ASCII_WHITESPACE: &[char] = &['\t', '\n', '\x0C', '\r', ' ']; 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<Regex> = 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+\((?<priority>[\d-]+)\)").unwrap()
});
#[tracing::instrument(level = "debug")] #[tracing::instrument(level = "debug")]
pub fn do_delta(args: Arc<Args>, delta_args: DeltaCmd) -> Result<(), BoxDynError> { pub fn do_delta(args: Arc<Args>, delta_args: DeltaCmd) -> Result<(), BoxDynError> {
todo!(); todo!();
@ -65,17 +92,15 @@ pub fn do_append(args: Arc<Args>, append_args: AppendCmd) -> Result<(), BoxDynEr
filepath.to_path_buf() 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(); let mut opts = File::options();
opts.read(true) opts.read(true)
.write(true) .write(true)
.create(false) .create(false)
.custom_flags(libc::O_CLOEXEC); .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 = pri - 1;
let new_pri_line = get_next_prio_line( let new_pri_line = get_next_prio_line(
@ -85,7 +110,7 @@ pub fn do_append(args: Arc<Args>, append_args: AppendCmd) -> Result<(), BoxDynEr
append_args.value.into(), 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)?; write_next_prio(source_file, new_pri_line)?;
@ -108,35 +133,30 @@ pub fn expr_for_configuration(source_file: &Path) -> OsString {
.collect() .collect()
} }
pub fn get_where(option_name: &str, configuration_nix: &Path) -> Result<Box<Path>, BoxDynError> { fn maybe_extract_prio_from_line(line: &SourceLine) -> Option<i64> {
let expr = expr_for_configuration(configuration_nix); MK_OVERRIDE_RE
let attrpath = format!("options.{}.definitionsWithLocations", option_name); .captures(line.text_ref())
.map(|caps| caps.name("priority").unwrap().as_str())
let output = nixcmd::NixEvalExpr { expr, attrpath } .map(|prio_str| {
.into_command() i64::from_str(prio_str).unwrap_or_else(|e| {
.output_checked_utf8()?; panic!(
let stdout = output.stdout(); "lib.mkOverride called with non-integer {}: {}. Nix source code is wrong!\n{}",
prio_str, e, line,
let definitions: Box<[DefinitionWithLocation]> = serde_json::from_str(&stdout)?; );
let last_location = definitions.into_iter().last().unwrap(); })
})
Ok(Box::from(last_location.file))
} }
pub fn get_highest_prio(option_name: &str, source: SourceFile) -> Result<i64, BoxDynError> { pub fn get_where(dynamic_nix: SourceFile) -> Result<i64, BoxDynError> {
// Get the current highest priority. 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()); Ok(prio)
// 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)
} }
pub fn get_next_prio_line( pub fn get_next_prio_line(
@ -146,15 +166,19 @@ pub fn get_next_prio_line(
new_value: Arc<str>, new_value: Arc<str>,
) -> Result<SourceLine, BoxDynError> { ) -> Result<SourceLine, BoxDynError> {
let source_lines = source.lines()?; let source_lines = source.lines()?;
let last_line = source_lines.last(); let penultimate = source_lines.get(source_lines.len() - 2);
assert_eq!(last_line.map(SourceLine::text).as_deref(), Some("]")); // FIXME: don't rely on whitespace lol
let last_line = last_line.unwrap(); debug_assert_eq!(penultimate.map(SourceLine::text).as_deref(), Some(" ];"));
let penultimate = penultimate.unwrap();
let new_generation = 0 - new_prio;
let new_line = SourceLine { let new_line = SourceLine {
line: last_line.line, line: penultimate.line,
path: source.path(), path: source.path(),
text: Arc::from(format!( 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 { let new_mod_start = SourceLine {
line: new_line.line.prev(), line: new_line.line.prev(),
path: source.path(), path: source.path(),
text: Arc::from(" {"), text: Arc::from(" {"),
}; };
let new_mod_end = SourceLine { let new_mod_end = SourceLine {
line: new_line.line.next(), line: new_line.line.next(),
path: source.path(), path: source.path(),
text: Arc::from(" }"), text: Arc::from(" }"),
}; };
source.insert_lines(&[new_mod_start, new_line, new_mod_end])?; source.insert_lines(&[new_mod_start, new_line, new_mod_end])?;

View file

@ -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"
''

View file

@ -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 <nixpkgs/nixos> {{ }};
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=}"

View file

@ -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;
}

View file

@ -1,8 +1,79 @@
{ {
pkgs ? import <nixpkgs> { }, pkgs ? import <nixpkgs> { },
lib ? pkgs.lib, qpkgs ? let
}: lib.makeScope lib.callPackageWith (self: let src = fetchTree (builtins.parseFlakeRef "github:Qyriad/nur-packages");
inherit (pkgs.testers) runNixOSTest; in import src { inherit pkgs; },
in { callPackage ? qpkgs.callPackage,
basic = runNixOSTest ./basic/test.nix; 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;
}) })

View file

@ -0,0 +1,14 @@
{ ... }:
{
services.distccd = {
enable = true;
jobTimeout = 900;
maxJobs = 12;
nice = -10;
};
dynamicism.for.distccd.enable = true;
networking.hostName = "distccd-machine";
}

View file

@ -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 <nixpkgs/nixos> { };
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=}'

22
tests/distccd/test.nix Normal file
View file

@ -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;
}

View file

@ -0,0 +1,59 @@
{ pkgs, lib, modulesPath, ... }:
let
moduleList = import "${modulesPath}/module-list.nix";
dynixFromSearchPath = let
res = builtins.tryEval <dynix>;
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
];
}

View file

@ -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"
''

View file

@ -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;
}

View file

@ -0,0 +1,7 @@
/** Dummy hardware configuration.
* Will be replaced with the real one in the test VM.
*/
{ ... }:
{
}

View file

@ -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 <nixpkgs/nixos> { };
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=}"

28
tests/gotosocial/test.nix Normal file
View file

@ -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;
}

View file

@ -0,0 +1,30 @@
{ lib, modulesPath, ... }:
let
moduleList = import "${modulesPath}/module-list.nix";
dynixFromSearchPath = let
res = builtins.tryEval <dynix>;
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";
}

View file

@ -0,0 +1,4 @@
{ ... }:
{
}

View file

@ -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 <nixpkgs/nixos> { };
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'

22
tests/harmonia/test.nix Normal file
View file

@ -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;
}

View file

@ -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;
}

View file

@ -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 [ ];
}

View file

@ -1,28 +1,30 @@
{ pkgs, lib, config, modulesPath, ... }: { pkgs, lib, config, modulesPath, ... }:
let let
name = config.networking.hostName; 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 <dynix>;
in lib.optional res.success res.value;
in in
assert builtins.pathExists nixosLibPath;
builtins.seq lib
builtins.seq modulesPath
builtins.seq moduleList
{ {
imports = moduleList ++ [ imports = [
(modulesPath + "/testing/test-instrumentation.nix") "${modulesPath}/testing/test-instrumentation.nix"
./hardware-configuration.nix
] ++ lib.concatLists [ ] ++ lib.concatLists [
(optionalPath ./hardware-configuration.nix) moduleList
(optionalPath ./dynamicism) dynixFromSearchPath
(optionalPath ../../modules/dynamicism)
]; ];
dynamicism.for.tzupdate.enable = true;
services.tzupdate = {
enable = true;
};
system.switch.enable = true; system.switch.enable = true;
documentation.enable = false; documentation.enable = false;
networking.hostName = "machine"; networking.hostName = "tzupdate-machine";
boot.loader.grub = { boot.loader.grub = {
enable = true; enable = true;
@ -32,28 +34,23 @@ builtins.seq moduleList
nix = { nix = {
package = pkgs.lixPackageSets.latest.lix; 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 = { settings = {
experimental-features = [ "nix-command" "pipe-operator" ]; experimental-features = [ "nix-command" "pipe-operator" ];
substituters = lib.mkForce [ ]; substituters = lib.mkForce [ ];
hashed-mirrors = null; hashed-mirrors = null;
connect-timeout = 1; 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.pathsToLink = [ "/share" ];
environment.extraOutputsToInstall = [ "modules" ];
environment.variables = { environment.variables = {
"NIXOS_CONFIG" = "/etc/nixos/configuration.nix"; "NIXOS_CONFIG" = "/etc/nixos/configuration.nix";
}; };
@ -61,6 +58,7 @@ builtins.seq moduleList
environment.shellAliases = { environment.shellAliases = {
ls = "eza --long --header --group --group-directories-first --classify --binary"; ls = "eza --long --header --group --group-directories-first --classify --binary";
}; };
environment.systemPackages = with pkgs; [ environment.systemPackages = with pkgs; [
eza eza
fd fd

View file

@ -0,0 +1,4 @@
{ ... }:
{
}

View file

@ -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()

24
tests/tzupdate/test.nix Normal file
View file

@ -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;
}