skip to content

Delivery

One service, api, declares its port once. Its aspect carries content for three targets: a NixOS web server, a cloud firewall rule and a Kubernetes service. Delivery hands each target’s modules to that target’s terminal, and every module reads the same port.

one-service.nix
let
gen = (builtins.getFlake "github:sini/gen").lib.mkGenLibs { };
inherit (gen.merge) evalModuleTree mkOption types;
cnf.keySemantics = {
nixos.category = "class";
terranix.category = "class";
kubernetes.category = "class";
};
declared = evalModuleTree { } [
((gen.aspects.mkAspectSchema cnf).mkAspectModule { })
{ options.services = mkOption { type = types.attrsOf types.raw; default = { }; }; }
{
aspects.api.nixos = { node, ... }: {
services.nginx.virtualHosts.${node.name}.listen = [ { port = node.port; } ];
};
aspects.api.terranix = { node, ... }: {
resource.aws_security_group_rule.${node.name} = { from_port = node.port; to_port = node.port; };
};
aspects.api.kubernetes = { node, ... }: {
services.${node.name}.spec.ports = [ { port = node.port; } ];
};
services.api = { name = "api"; port = 8080; aspects = [ "api" ]; };
}
];
projected = gen.delivery.project { selectNodes = values: values.services; } cnf declared.config;
# A data terminal: merge the class's modules into plain configuration, giving each module `node`.
toData = { modules, bindings, ... }:
(evalModuleTree { } ([ { freeformType = types.lazyAttrsOf types.anything; } ]
++ map (module: (gen.bind.wrap { bindings.node = bindings.node; } module).module) modules)).config;
in
gen.delivery.realize { } { nixos = toData; terranix = toData; kubernetes = toData; } projected
Terminal window
$ nix eval --impure --json --file one-service.nix
{
"kubernetes": { "api": { "services": { "api": { "spec": { "ports": [ { "port": 8080 } ] } } } } },
"nixos": { "api": { "services": { "nginx": { "virtualHosts": { "api": { "listen": [ { "port": 8080 } ] } } } } } },
"terranix": { "api": { "resource": { "aws_security_group_rule": { "api": { "from_port": 8080, "to_port": 8080 } } } } }
}

A class is any target an aspect key is declared for: nixos, terranix, kubernetes, or a name of your own. A terminal is a function that turns one node’s modules for one class into a result. realize calls one terminal per class, and you supply each one.

The terminal here merges modules into plain data, so the example runs without nixpkgs, terranix or a Kubernetes toolchain. In a real flake the nixos terminal calls nixosSystem, and gen-bind’s mkHostedTerminal wraps any evaluator that takes modules. gen ships no terranix or Kubernetes terminal: for those classes you write the terminal, for example one that calls terranix or nixidy.

Composition needs no nixpkgs. nixpkgs enters only inside a terminal, and what reaches a terminal is values: the node’s modules and its bindings. Here node is injected into each module with bind.wrap (see Typed Configuration).

FeatureFunctions and optionsPurpose
Projectdelivery.project { selectNodes; deliveryClasses ? {}; instances ? {}; } cnf valuesCollect each node’s modules per class from its aspects, keyed under the delivery class deliveryClasses names for a node’s authored class (identity when absent); a reached parametric aspect through its instances
Realisedelivery.realize { bindings?; refinements?; layerOrder?; extraModules?; } terminals projectedCall one terminal per class per node, and return { <class>.<node> = result; }
Terminalsbind.crossing.mkHostedTerminal, bind.crossing.mkOutputsTerminalReady-made terminals for NixOS-style systems and for flake outputs
Move contentview.compositions.movement, carrier, scopeGraph, viewRelationCarry values along chosen edge labels, nearest source first
See what lost.contributions, .shadowed, .withheld, .droppedEvery value a view considered, and why each did or did not win
Resolve namesview.referenceResolution, neededByResolve a reference to the declaration it names, over the graph
Trace a viewview.trace, renderTrace, hashTraceA printable record of how a view resolved
Wire a flakeflakeModules.default with the gen.* optionsCompose, project and realise from a flake-parts flake
You want to…Put it inDelivered by
Configure a NixOS host for a featurean aspect’s nixos keythe nixos terminal, to every host that lists the aspect
Emit content for another tool, such as terranix or Kubernetesan aspect key declared as a classa terminal you supply for that class
Add a module to one class at one noderealize’s extraModules.<class>.<node>that class’s terminal at that node only
Add one host’s hardware filegen.extraModules.<host>the nixos terminal, for that host only
Pass the same argument to every noderealize’s bindingsevery terminal, as bindings
Pass an argument to one noderealize’s refinements.<node>that node’s terminals, over the global bindings
Share settings with another frameworkan aspect key declared as a channelpassed through unchanged, never realised
Let the nearest scope’s value wina movement view over your edge labelsviewRelation’s value
Read resolved values in flake modulesnothing: the flake module injects genValuesevery flake module, as the genValues argument

project reads each node’s aspects list and collects, per class, the modules of those aspects. A key counts as a class only when cnf declares it category = "class" and the aspect gives it content. A node with no content for a class does not appear under that class.

project follows includes: a node receives the content of the aspects its list names and of everything they include, breadth-first, each aspect once. Content written inline at an include position is delivered too. A reached parametric aspect (a guard) is delivered through its instances: pass gen-aspects’ instancesFor relation as instances, and each node receives the instances the relation lists for it, never another node’s. project reads the relation and never mints an instance. Two receiver-rooted queries through gen-scope’s resolve produce what a node receives: a membership query over one lift of the whole graph gives the set the node reaches, and an order query over a scope holding only that set gives the element list, the node’s members in declared order and then each vertex’s include sites in site order, each at its first reach. If the relation lists an instance at a node that the node’s includes never reach, or an include leads to a vertex the membership query never reaches, the node refuses by name. Where the relation lists no instance of a reached guard, project reads the relation’s declined lists: a guard whose condition was decided false there delivers nothing, as an include that never existed. Any other empty reach refuses by name, rather than guess: the relation never walked the guard there (it was handed other members or another tree), or the guard’s condition read a coordinate the node does not supply under the open world. Declare the coordinate set (entityKinds) to make such an absence false. Here a web server and a database host share an ssh aspect, the web server also carries a firewall rule, and the terminals count modules:

platform-delivery.nix
let
gen = (builtins.getFlake "github:sini/gen").lib.mkGenLibs { };
inherit (gen.merge) evalModuleTree mkOption types;
cnf = {
keySemantics.nixos.category = "class";
keySemantics.terranix.category = "class";
};
result = evalModuleTree { } [
((gen.aspects.mkAspectSchema cnf).mkAspectModule { })
{ options.hosts = mkOption { type = types.attrsOf types.raw; default = { }; }; }
{
aspects.ssh.nixos.services.openssh.enable = true;
aspects.webserver.includes = [ "ssh" ];
aspects.webserver.nixos.services.nginx.enable = true;
aspects.webserver.terranix.resource.aws_security_group_rule.https.to_port = 443;
aspects.database.includes = [ "ssh" ];
aspects.database.nixos.services.postgresql.enable = true;
hosts.web-1.aspects = [ "webserver" ];
hosts.db-1.aspects = [ "database" ];
}
];
projected = gen.delivery.project { selectNodes = values: values.hosts; } cnf result.config;
in
gen.delivery.realize { } {
nixos = { name, modules, ... }:
"${name}: ${toString (builtins.length modules)} nixos modules";
terranix = { name, modules, ... }:
"${name}: ${toString (builtins.length modules)} terranix modules";
} projected
Terminal window
$ nix eval --impure --json --file platform-delivery.nix
{
"nixos": { "db-1": "db-1: 2 nixos modules", "web-1": "web-1: 2 nixos modules" },
"terranix": { "web-1": "web-1: 1 terranix modules" }
}

db-1 has no terranix content, so it does not appear under terranix.

selectNodes names where your nodes live in values. It is an option with no default: a call that reads the nodes without it fails:

gen-delivery: project: no node selector — `selectNodes` is required and has no default. It names WHICH resolved attrset of the caller's values holds the node instances.

Each terminal receives one record per node:

FieldContents
namethe node’s key
modulesthis class’s modules for the node, unevaluated
bindingsthe layered arguments, where bindings.node is the node itself
extentthis class’s realised set, for reading peer nodes lazily
extraModulesmodules addressed to this class at this node

Bindings fold in the order projection, global, refinement (defaultLayerOrder), so a per-node refinement overrides a global binding. layerOrder changes the order, and it must list every layer exactly once. Omitting one fails, because it would delete that layer’s contribution:

gen-delivery: realize: layerOrder omits contribution layer(s) refinement — the order is DECLARED and TOTAL over projection, global, refinement, so an omitted layer deletes its contribution

Naming a layer that does not exist fails too, listing the declared ones.

extraModules is addressed by class, then node: { <class>.<node> = [ module ]; }. A module at extraModules.nixos.db-1 reaches only the nixos terminal for db-1, as that terminal’s extraModules field. This is also how a value crosses from one class to another: the caller adapts it and addresses it to the target class.

Extra modules add to a realisation and never create one. An address that names no point of the realisation fails: a class with no terminal, a node the projection does not carry, or a node with no content for that class.

gen-delivery: realize: extraModules.terranix.db-1 addresses a node with no declared terranix content — …

A node-keyed set such as { db-1 = [ module ]; } fails with an error naming the class-major shape. Derive the addresses from the projection, never from realize’s own output: the addresses are checked before the output exists, so a set read back from it recurses forever.

A movement view carries values along edges with a declared precedence. Here web-1 sits in us-east, which sits in prod, and both scopes declare firewall ports:

nearest-firewall.nix
let
gen = (builtins.getFlake "github:sini/gen").lib.mkGenLibs { };
view = gen.view;
scope = gen.scope;
labels = view.edgeLabels { letters = [ "within" ]; };
carrier = view.carrier {
inherit labels;
relations = view.relations { names = [ "firewall" ]; };
relatumLabels = view.relatumLabels { names = [ ]; };
labelWellFormedness = scope.wellFormed { alphabet = labels.letters; expression = "within*"; };
labelOrder = scope.labelOrder { alphabet = labels.letters; layers = [ [ "within" ] ]; endOfPath = -1; };
dataOrder = view.dataOrder { channel = "firewall"; keyOf = _: "firewall"; };
};
firewall = view.compositions.movement {
channel = "firewall"; relation = "firewall"; root = "web-1"; direction = "outbound";
admission = carrier.labelWellFormedness; order = carrier.labelOrder; wellFormed = _: true;
empty = [ ]; tieSet = view.tieSets.union; combine = view.combines.listAppend;
dedup = view.dedups.byDatum;
};
graph = view.scopeGraph {
inherit carrier;
scopes = [ "web-1" "us-east" "prod" ];
edges.within = id: { web-1 = [ "us-east" ]; us-east = [ "prod" ]; }.${id} or [ ];
data = [
{ scope = "us-east"; relation = "firewall"; datum = [ 443 ]; }
{ scope = "prod"; relation = "firewall"; datum = [ 22 ]; }
];
};
moved = view.viewRelation {
engine = scope; definition = firewall; marks = _: [ ]; inherit graph;
orderMark = scope.labelOrder { alphabet = labels.letters; layers = [ labels.letters ]; endOfPath = 0; };
};
in
{ inherit (moved) value; shadowedFrom = map (s: s.scope) moved.shadowed; }
Terminal window
$ nix eval --impure --json --file nearest-firewall.nix
{ "shadowedFrom": ["prod"], "value": [443] }

us-east is one edge away and prod is two, so us-east wins. Each shadowed entry records the losing value, its scope, its distance and the full path, so a reader can see why it lost. The carrier’s labelWellFormedness decides which paths are admitted at all (here within*), and its labelOrder decides which admitted path wins; both are built by scope, the resolution calculus viewRelation lifts the graph into and walks it with (engine). This is name resolution over a scope graph, as in Scopes as Types.

flakeModules.default runs the whole route inside a flake-parts flake. It composes gen.tree and gen.modules, projects over the registry at gen.nodeRegistryPath, and realises through gen.terminals. Only nixos is wired automatically: its results become nixosConfigurations, through a default terminal over gen.nixpkgs. Every other class needs a terminal in gen.terminals.<class>, and its results are read at gen.realized.<class>.

flake.nix
{
inputs = {
gen.url = "github:sini/gen";
nixpkgs.follows = "gen/nixpkgs";
flake-parts.url = "github:hercules-ci/flake-parts";
};
outputs = inputs@{ flake-parts, ... }:
flake-parts.lib.mkFlake { inherit inputs; } {
imports = [ inputs.gen.flakeModules.default ];
systems = [ "x86_64-linux" ];
gen = {
modules = [ ./modules.nix ];
aspectCnf = import ./aspect-cnf.nix;
nodeRegistryPath = [ "hosts" ];
};
};
}
OptionPurpose
gen.tree, gen.modulesa directory of gen modules, and extra inline modules
gen.specialArgsextra module arguments for the composition
gen.aspectCnfthe key categories, the same value your mkAspectSchema call takes
gen.nodeRegistryPaththe attribute path of the registry holding the hosts; required
gen.nixpkgsthe nixpkgs whose nixosSystem builds hosts; defaults to inputs.nixpkgs
gen.terminalsterminals per class; a default nixos terminal is added
gen.extraModules.<node>extra NixOS modules for one host, passed to realize as extraModules.nixos.<node>
gen.inject, gen.injectPerSystemwhich resolved values reach other flake modules, and whether perSystem gets them
gen.realizedthe realised results, read-only, for classes other than nixos

Without nodeRegistryPath the flake fails with an error listing the top-level keys it could have named. A path that stops at the wrong place fails at the segment that does not match.


Provided by gen-delivery (projection and realisation), gen-view (movement views, reference resolution, traces), gen-bind (terminals) and the gen hub (the flake module).

palette
dark
light
↑↓ select↵ apply esc close

Palettes adapted from Catppuccin (Macchiato) (MIT), Tokyo Night (Apache-2.0), gruvbox (MIT), Catppuccin (Latte) (MIT), Rosé Pine (Dawn) (MIT).