Compare commits

..

No commits in common. "6e6948c666e0b8e9ce5ea39d69884c80a597d4ce" and "d76474c5240450bd9c4ceae9993941b900d3d23d" have entirely different histories.

21 changed files with 438 additions and 364 deletions

View file

@ -1,3 +1,5 @@
root = true
[*]
end_of_line = lf
insert_final_newline = true

119
README.md
View file

@ -1,119 +0,0 @@
# Dynix — WIP modular dynamicism for NixOS systems
Dynix is a prototype for modifying an append-only NixOS configuration, and dynamically
## Running the tests
There are currently 3 implemented dynamicism modules: `gotosocial`, `harmonia`, and `distccd`.
Each test uses the [NixOS test infrastructure](https://nixos.org/manual/nixos/unstable/#sec-nixos-tests) to:
1. Setup a virtual machine running NixOS
2. Configure the VM's NixOS to run a given service, with certain settings
3. Verify that the running service is using those settings
4. Use Dynix to change a setting for that service
5. Verify that the running service is now using the new setting
The tests themselves can be run with Nix.
To run the test for, e.g., Gotosocial, you can run:
```bash
$ nix --experimental-features "nix-command flakes pipe-operator pipe-operators" build .#default.tests.gotosocial
```
The experimental [piping operator](https://github.com/NixOS/rfcs/pull/148) is currently used in Dynix, so you must enable the experimental feature `pipe-operator` for Lix or `pipe-operators` for CppNix (you can add both to cover both Nix implementations); flakes are used for locked inputs.
To run a test with fewer experimental features, and without locked inputs, you can use the old CLI:
```bash
$ nix-build --experimental-features "pipe-operator pipe-operators" -A tests.gotosocial
```
All the tests at once can be run with:
```bash
$ nix build --experimental-features "nix-command flakes pipe-operator pipe-operators" .#default.allTests
```
## Gotosocial
This example, implemented in [./modules/dynamicism/gotosocial.nix](./modules/dynamicism/gotosocial.nix), is tested in [./tests/gotosocial](./tests/gotosocial).
The test sets up a VM using NixOS's `services.gotosocial` module with the following *static* [configuration](./tests/gotosocial/configuration.nix):
```nix
{
services.gotosocial = {
enable = true;
setupPostgresqlDB = true;
settings = {
application-name = "gotosocial-for-machine";
host = "gotosocial-machine.local";
};
};
}
```
The automated [test script](./tests/gotosocial/test-script.py):
1. Asserts that that the above *static* configuration is in effect (by extracting the configuration from the running Gotosocial process)
2. Runs `dynix append "services.gotosocial.settings.application-name" "yay!"`, which modifies the append-only configuration file `/etc/nixos/dynamic.nix` in the VM filesystem
3. Runs the dynamic activation script built by `(import <nixpkgs/nixos>).config.dynamicism.applyDynamicConfiguration { }`, which *applies* the dynamic configuration
4. Asserts that the dynamically configured `application-name` is in effect
5. Runs `nixos-rebuild switch` to re-apply the *static* configuration
6. Asserts that the dynamic configuration is *no longer* in effect, and we are back to the static configuration
## Harmonia
This example, implemented in [./modules/dynamicism/harmonia.nix](./modules/dynamicism/harmonia.nix), is tested in [./tests/harmonia](./tests/harmonia).
The test sets up a VM using NixOS's `services.harmonia` module with the following *static* [configuration](./tests/harmonia/configuration.nix)
```nix
{
services.harmonia = {
enable = true;
settings = {
workers = 4;
max_connection_rate = 256;
};
};
}
```
The VM [test script](./tests/harmonia/test-script.py):
1. Asserts that the above *static* configuration is in effect (by extracting the configuration from the running Harmonia process)
2. Runs `dynix append "services.harmonia.settings.workers" 20`, to modify the append-only configuration file `/etc/nixos/dynamic.nix` in the VM filesystem
3. Runs the dynamic activation script built by `(import <nixpkgs/nixos>).config.dynamicism.applyDynamicConfiguration { }`, to *apply* the dynamic configuration
4. Asserts that the dynamically configured `workers` is in effect
5. Runs `dynix append "services.harmonia.settings.max_connection_rate" 100`
6. Runs the dynamic activation script
7. Asserts that *both* `max_connection_rate` and `workers` dynamic values are in effect
8. Runs `nixos-rebuild switch` to re-apply the *static* configuration
9. Asserts that the dynamic configuration is *no longer* in effect, and we are back to the static configuration
## Distccd
This example, implemented in [./modules/dynamicism/distccd.nix](./modules/dynamicism/distccd.nix), is tested in [./tests/distccd](./tests/distccd).
The test sets up a VM using NixOS's `services.distccd` module with the following *static* [configuration](./tests/distccd/configuration.nix):
```nix
{
services.distccd = {
jobTimeout = 900;
maxJobs = 12;
logLevel = "warning";
};
}
```
The VM [test script](./tests/distccd/test-script.py):
1. Asserts that the above *static* configuration is in effect (by extracting the configuration from the running Distccd process)
2. Runs `dynix append "services.distccd.maxJobs" 4`, to modify the append-only configuration file `/etc/nixos/dynamic.nix` in the VM filesystem
3. Runs the dynamic activation script built by `(import <nixpkgs/nixos>).config.dynamicism.applyDynamicConfiguration { }`, to *apply* the dynamic configuration
4. Asserts that the dynamically configured `maxJobs` is in effect
5. Runs `dynix append "services.distccd.logLevel" "error"`
6. Runs the dynamic activation script
7. Asserts that *both* `maxJobs` and `logLevel` dynamic values are in effect
8. Runs `nixos-rebuild switch` to re-apply the *static* configuration
9. Asserts that the dynamic configuration is *no longer* in effect, and we are back to the static configuration.

View file

@ -1,14 +1,14 @@
{
pkgs ? import <nixpkgs> { },
qpkgs ? let
src = fetchTarball "https://github.com/Qyriad/nur-packages/archive/main.tar.gz";
src = fetchTree (builtins.parseFlakeRef "github:Qyriad/nur-packages");
in import src { inherit pkgs; },
}: let
inherit (qpkgs) lib;
dynix = qpkgs.callPackage ./package.nix { }
|> qpkgs.stdlib.mkStdenvPretty;
byStdenv = lib.mapAttrs (stdenvName: stdenv: let
withStdenv = dynix.override { clangStdenv = stdenv; };
withStdenv = dynix.override { inherit stdenv; };
dynix' = withStdenv.overrideAttrs (prev: {
pname = "${prev.pname}-${stdenvName}";
});

View file

@ -47,9 +47,5 @@
devShells = extraDevShells // {
default = devShell;
};
checks = self.packages.${system}.default.tests // {
default = self.packages.${system}.default.allTests;
};
});
}

View file

@ -11,6 +11,7 @@ let
inherit (import ./lib.nix { inherit lib; })
typeCheck
convenientAttrPath
executablePathInStore
concatFoldl
recUpdateFoldl

View file

@ -33,7 +33,7 @@ in {
inherit (self) dynixCommand dynixModules;
};
installPhase = ''
installPhase = lib.dedent ''
runHook preInstall
mkdir -p "$out"

View file

@ -1,7 +1,7 @@
{
pkgs ? import <nixpkgs> { },
qpkgs ? let
src = fetchTarball "https://github.com/Qyriad/nur-packages/archive/main.tar.gz";
src = fetchTree (builtins.parseFlakeRef "github:Qyriad/nur-packages");
in import src { inherit pkgs; },
callPackage ? qpkgs.callPackage,
lib ? qpkgs.lib,

View file

@ -1,11 +1,9 @@
{ ... }:
{
services.distccd = {
enable = true;
jobTimeout = 900;
maxJobs = 12;
logLevel = "warning";
nice = -10;
};

View file

@ -1,5 +1,7 @@
import argparse
import functools
#from pathlib import Path
#from pprint import pformat
import shlex
import textwrap
from typing import Any, cast, TYPE_CHECKING
@ -62,6 +64,7 @@ def get_cli_args() -> argparse.Namespace:
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
@ -74,7 +77,12 @@ def dynix_append(option: str, value: Any):
@beartype
def do_apply():
expr = textwrap.dedent("""
(import <nixpkgs/nixos> { }).config.dynamicism.applyDynamicConfiguration { }
let
nixos = import <nixpkgs/nixos> { };
in nixos.config.dynamicism.applyDynamicConfiguration {
baseConfiguration = /etc/nixos/configuration.nix;
newConfiguration = /etc/nixos/dynamic.nix;
}
""").strip()
machine.succeed(rf"""
@ -82,13 +90,12 @@ def do_apply():
""".strip())
machine.wait_for_unit("default.target")
machine.wait_for_unit("install-dynix.service")
dynix_out = machine.succeed("dynix --version")
assert "dynix" in dynix_out, f"dynix not in {dynix_out=}"
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'
@ -98,16 +105,17 @@ with machine.nested("must succeed: initial nixos-rebuild switch"):
# Config should not have changed.
args = get_cli_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'
#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()
# Only jobs should have changed. The others should still be default.
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'
@ -117,13 +125,6 @@ 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.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=}'
# And this should set everything back.
machine.succeed("env PAGER= nixos-rebuild switch --log-format raw-with-logs --fallback")
args = get_cli_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'

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,7 @@
/**
* Convenience shortcut for running this test from the command-line.
* Normally this test is initialized from /tests/default.nix.
*/
{
pkgs ? import <nixpkgs> { },
}: pkgs.testers.runNixOSTest ./test.nix

View file

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

View file

@ -61,7 +61,12 @@ def dynix_append(option: str, value: str):
@beartype
def do_apply():
expr = textwrap.dedent("""
(import <nixpkgs/nixos> { }).config.dynamicism.applyDynamicConfiguration { }
let
nixos = import <nixpkgs/nixos> { };
in nixos.config.dynamicism.applyDynamicConfiguration {
baseConfiguration = /etc/nixos/configuration.nix;
newConfiguration = /etc/nixos/dynamic.nix;
}
""").strip()
machine.succeed(rf"""
@ -70,6 +75,7 @@ def do_apply():
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")
@ -88,12 +94,6 @@ 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=}"
try:
host = next(line for line in lines if line.startswith("host:"))
except StopIteration:
raise AssertionError(f"no 'host:' found in config file: {textwrap.indent(config_text, " ")}")
assert "gotosocial-machine" in host, f"'gotosocial-machine' should be in {host=}"
new_app_name = "yay!"
dynix_append("services.gotosocial.settings.application-name", f'"{new_app_name}"')
do_apply()

View file

@ -1,6 +1,20 @@
{ ... }:
{ 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;

View file

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

View file

@ -1,5 +1,6 @@
import functools
from pathlib import Path
from pprint import pformat
import shlex
import textwrap
import tomllib
@ -63,7 +64,12 @@ def dynix_append(option: str, value: Any):
@beartype
def do_apply():
expr = textwrap.dedent("""
(import <nixpkgs/nixos> { }).config.dynamicism.applyDynamicConfiguration { }
let
nixos = import <nixpkgs/nixos> { };
in nixos.config.dynamicism.applyDynamicConfiguration {
baseConfiguration = /etc/nixos/configuration.nix;
newConfiguration = /etc/nixos/dynamic.nix;
}
""").strip()
machine.succeed(rf"""
@ -71,13 +77,12 @@ def do_apply():
""".strip())
machine.wait_for_unit("default.target")
machine.wait_for_unit("install-dynix.service")
dynix_out = machine.succeed("dynix --version")
assert "dynix" in dynix_out, f"dynix not in {dynix_out=}"
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"
@ -86,6 +91,7 @@ with machine.nested("must succeed: initial nixos-rebuild switch"):
# 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"
@ -95,6 +101,7 @@ 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"
@ -102,14 +109,16 @@ 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, and workers should be the same as before.
# 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}"
assert int(config_toml['workers']) == new_workers, f"{config_toml['workers']=} != {new_workers}"
# 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'

View file

@ -0,0 +1,67 @@
{ pkgs, lib, config, modulesPath, ... }:
let
name = config.networking.hostName;
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 [
moduleList
dynixFromSearchPath
];
dynamicism.for.tzupdate.enable = true;
services.tzupdate = {
enable = true;
};
system.switch.enable = true;
documentation.enable = false;
networking.hostName = "tzupdate-machine";
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,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;
}