Skip to content

Generic batteries

The other pages of these docs write every rule by hand, so you can see how yen works. Some rules come back in every fleet: each host reads the keys of the other hosts, each address has one owner, sections of a file come in an order. yen.lib.batteries has them ready.

A battery never adds a word to your fleet. You write every key, with your own names. The battery is the value. It reads only the fixed args scope and sinks, and it takes the sinks it reads as refs. Here are the words of the example: a fleet of hosts. Each host has an ssh key and an address. The batteries know none of these names.

modules/fleet.nix
# Your words: a fleet of hosts. Each host has an ssh key and an address. The batteries know none of these names.
{ yen, lib, ... }:
let
str = yen.lib.str;
in
{
yen.kinds.fleet = {
spawns.host = _: yen.host;
sinks.out.reduce = yen.lib.reduce.merge;
};
yen.kinds.host.fields = {
sshKey = str;
addr = str;
};
yen.fleet.main = { };
yen.flake = { out, ... }: out;
yen.host = {
nas = {
sshKey = "ssh-ed25519 AAAAnas";
addr = "10.0.0.1";
};
desk = {
sshKey = "ssh-ed25519 AAAAdesk";
addr = "10.0.0.2";
};
laptop = {
sshKey = "ssh-ed25519 AAAAlaptop";
addr = "10.0.0.3";
};
};
}

Each host gives its key to the fleet. The fleet reduces with elements, so each key keeps the scope that gave it. Each host then reads the keys of the others. This is the same rule as known_hosts, WireGuard peers, Syncthing devices or remote builders, written by hand in Facts from other hosts.

modules/known-hosts.nix
# mesh: every host gives its key to the fleet; each host reads the keys of the others.
{ yen, ... }:
let
inherit (yen.lib.batteries) mesh;
in
{
yen.kinds.fleet.sinks.keys.reduce = yen.lib.reduce.elements; # (1) an element knows which host gave it
yen.kinds.host.sinks.known = {
reduce = yen.lib.reduce.one; # (2)
out =
{ scope, sinks, ... }:
{
known.${scope.name} = sinks.known;
};
};
yen.aspects.key = {
at = yen.kinds.host;
keys = { host, ... }: host.sshKey;
};
yen.aspects.known-hosts = {
at = yen.kinds.host;
known = mesh {
# (3)
from = yen.kinds.fleet.sinks.keys;
build = keys: builtins.sort builtins.lessThan keys;
};
};
}

(1) is the sink of the fleet. (2) is the sink of each host: its reduce is one, so a host has exactly one value. At (3), mesh gives the value of the key known. It is the records of sink from, except the ones that this scope or a scope under it gave. build makes content of them: here, a sorted list. Each host has the keys of the other two:

result
{
desk = [
"ssh-ed25519 AAAAlaptop"
"ssh-ed25519 AAAAnas"
];
laptop = [
"ssh-ed25519 AAAAdesk"
"ssh-ed25519 AAAAnas"
];
nas = [
"ssh-ed25519 AAAAdesk"
"ssh-ed25519 AAAAlaptop"
];
}
modules/addresses.nix
# claim: each address has one owner. Two hosts with one address is an error, and the fleet has no outputs.
{ yen, ... }:
{
yen.kinds.fleet.sinks.addrs = {
reduce = yen.lib.batteries.claim { key = addr: addr; }; # (1)
out =
{ sinks, ... }:
{
addrs = builtins.attrNames sinks.addrs;
};
};
yen.aspects.addr = {
at = yen.kinds.host;
addrs = { host, ... }: host.addr; # (2)
};
}

At (1), claim is the reduce of the sink. Each key has one owner, and two owners is an error. At (2), every host gives its address. When a new host takes the address of the nas, the fleet has an error and no outputs:

result
[
{
code = "claim";
error = "claim: 10.0.0.1 has 2 owners: fleet:main/host:backup, fleet:main/host:nas";
from = "fleet:main";
sink = "addrs";
}
]

With rank, the highest value wins and there is no error: for example rank = claim: claim.priority, when two virtual machines ask for one GPU.

modules/readme.nix
# ordered: README sections from any file, each with before and after. The battery finds one order.
{ yen, ... }:
{
yen.kinds.fleet.sinks.readme = {
reduce = yen.lib.batteries.ordered; # (1)
out =
{ sinks, ... }:
{
readme = builtins.concatStringsSep "\n" sinks.readme;
};
};
yen.aspects.readme-intro = {
at = yen.kinds.fleet;
readme = {
name = "intro";
value = "# My fleet";
};
};
yen.aspects.readme-hosts = {
at = yen.kinds.fleet;
readme = {
name = "hosts";
after = [ "intro" ]; # (2)
value = "## Hosts";
};
};
# (3) written later, in another file: it goes between the two
yen.aspects.readme-usage = {
at = yen.kinds.fleet;
readme = {
name = "usage";
after = [ "intro" ];
before = [ "hosts" ];
value = "## Usage";
};
};
}

At (1), ordered is the reduce of the sink. Its elements are { name; value; before; after; }, and it finds an order that meets all of them. (2) puts a section after intro. (3) is written in another file and goes between the two:

result
''
# My fleet
## Usage
## Hosts''

A cycle, a name that is given two times, or an unknown name in before or after is an error.

A sink can collect modules of any module system: NixOS, flake-parts, sini/files, or plain lib.evalModules.

modules/modules.nix
# eval-modules, configurations, imports: module systems other than yen's, fed by sinks.
{ yen, lib, ... }:
let
inherit (yen.lib.batteries) eval-modules configurations imports;
in
{
# each host collects service settings as modules; eval-modules merges them with the module system
yen.kinds.host.sinks.settings.out = configurations {
# (1)
from = yen.kinds.host.sinks.settings;
output = "settings";
build =
_: modules:
(lib.evalModules {
modules = [ { freeformType = lib.types.attrsOf lib.types.anything; } ] ++ modules;
}).config;
};
# the same with eval-modules, at the fleet: one report from every host's report module
yen.kinds.fleet.sinks.report.out = eval-modules {
# (2)
from = yen.kinds.fleet.sinks.report;
inherit lib;
modules = [ { options.hosts = lib.mkOption { type = lib.types.listOf lib.types.str; }; } ];
result = e: { report = e.config; };
};
# imports: a sink of modules as one module, here for a docs sink
yen.kinds.fleet.sinks.notes.out =
{ sinks, ... }:
{
notes = imports { from = yen.kinds.fleet.sinks.notes; } { inherit sinks; }; # (3)
};
yen.aspects.ssh-settings = {
at = yen.kinds.host;
settings.ssh.port = 22;
report =
{ host, ... }:
{
hosts = [ host.name ];
};
notes = "note";
};
yen.aspects.nas-settings = {
at = yen.host.nas;
settings.ssh.ciphers = [ "chacha20" ];
};
}

(1), configurations, gives one flake output per scope, <output>.<scope name>, from a builder you pass (nixosSystem, darwinSystem, any function). (2), eval-modules, evaluates the modules with fixed modules first. (3), imports, turns a sink of modules into one module for another sink. This is what they give:

result
{
report = {
hosts = [
"nas"
"laptop"
"desk"
];
};
settings = {
desk = {
ssh = {
port = 22;
};
};
laptop = {
ssh = {
port = 22;
};
};
nas = {
ssh = {
ciphers = [
"chacha20"
];
port = 22;
};
};
};
}

A battery is a plain Nix function. If none fits, write one in your fleet. Use the same shape, and it stays as reusable as these:

  • Take the sinks you read as refs, such as from = yen.kinds.fleet.sinks.keys, and read them with sinks.${from.__sink}.
  • Read the scope with scope, never with an arg named after a kind.
  • Return a value. Do not write yen.kinds.… or yen.aspects.… keys: those are the user’s names.

This is all of mesh:

mesh =
{ from, build }:
{ scope, sinks, ... }:
build (map (e: e.value) (builtins.filter (e: !own scope e) sinks.${from.__sink}));
Battery Value of Does
mesh { from; build; } any key of an aspect the records of sink from, except the ones that this scope or a scope under it gave; build makes content of them
claim { key; rank ? ; } reduce of a sink each key has one owner; two owners is an error, unless rank picks one
ordered reduce of a sink elements { name; value; before; after; } in an order that meets all of them
perSystem.out { from; group; systemOf ? ; } out of a sink at a system scope the reduced sink as flake output <group>.<system>
perSystem.inputs { inputs; system; } anywhere each input read at one system: packages.<system> over legacyPackages.<system>, so (…).nixpkgs.hello
ContributeCommunitySponsor