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.
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;ingen.delivery.realize { } { nixos = toData; terranix = toData; kubernetes = toData; } projected$ 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).
What you can do
Section titled “What you can do”| Feature | Functions and options | Purpose |
|---|---|---|
| Project | delivery.project { selectNodes; deliveryClasses ? {}; instances ? {}; } cnf values | Collect 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 |
| Realise | delivery.realize { bindings?; refinements?; layerOrder?; extraModules?; } terminals projected | Call one terminal per class per node, and return { <class>.<node> = result; } |
| Terminals | bind.crossing.mkHostedTerminal, bind.crossing.mkOutputsTerminal | Ready-made terminals for NixOS-style systems and for flake outputs |
| Move content | view.compositions.movement, carrier, scopeGraph, viewRelation | Carry values along chosen edge labels, nearest source first |
| See what lost | .contributions, .shadowed, .withheld, .dropped | Every value a view considered, and why each did or did not win |
| Resolve names | view.referenceResolution, neededBy | Resolve a reference to the declaration it names, over the graph |
| Trace a view | view.trace, renderTrace, hashTrace | A printable record of how a view resolved |
| Wire a flake | flakeModules.default with the gen.* options | Compose, project and realise from a flake-parts flake |
What goes where
Section titled “What goes where”| You want to… | Put it in | Delivered by |
|---|---|---|
| Configure a NixOS host for a feature | an aspect’s nixos key | the nixos terminal, to every host that lists the aspect |
| Emit content for another tool, such as terranix or Kubernetes | an aspect key declared as a class | a terminal you supply for that class |
| Add a module to one class at one node | realize’s extraModules.<class>.<node> | that class’s terminal at that node only |
| Add one host’s hardware file | gen.extraModules.<host> | the nixos terminal, for that host only |
| Pass the same argument to every node | realize’s bindings | every terminal, as bindings |
| Pass an argument to one node | realize’s refinements.<node> | that node’s terminals, over the global bindings |
| Share settings with another framework | an aspect key declared as a channel | passed through unchanged, never realised |
| Let the nearest scope’s value win | a movement view over your edge labels | viewRelation’s value |
| Read resolved values in flake modules | nothing: the flake module injects genValues | every flake module, as the genValues argument |
Details
Section titled “Details”Projection
Section titled “Projection”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:
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;ingen.delivery.realize { } { nixos = { name, modules, ... }: "${name}: ${toString (builtins.length modules)} nixos modules"; terranix = { name, modules, ... }: "${name}: ${toString (builtins.length modules)} terranix modules";} projected$ 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.Realisation
Section titled “Realisation”Each terminal receives one record per node:
| Field | Contents |
|---|---|
name | the node’s key |
modules | this class’s modules for the node, unevaluated |
bindings | the layered arguments, where bindings.node is the node itself |
extent | this class’s realised set, for reading peer nodes lazily |
extraModules | modules 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 contributionNaming a layer that does not exist fails too, listing the declared ones.
Extra modules
Section titled “Extra modules”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.
Movement views
Section titled “Movement views”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:
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; }$ 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.
The flake module
Section titled “The flake module”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>.
{ 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" ]; }; };}| Option | Purpose |
|---|---|
gen.tree, gen.modules | a directory of gen modules, and extra inline modules |
gen.specialArgs | extra module arguments for the composition |
gen.aspectCnf | the key categories, the same value your mkAspectSchema call takes |
gen.nodeRegistryPath | the attribute path of the registry holding the hosts; required |
gen.nixpkgs | the nixpkgs whose nixosSystem builds hosts; defaults to inputs.nixpkgs |
gen.terminals | terminals 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.injectPerSystem | which resolved values reach other flake modules, and whether perSystem gets them |
gen.realized | the 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).
Palettes adapted from Catppuccin (Macchiato) (MIT), Tokyo Night (Apache-2.0), gruvbox (MIT), Catppuccin (Latte) (MIT), Rosé Pine (Dawn) (MIT).