Skip to content

Quirks & Pipes

The classic example — multiple aspects declare firewall ports, one aspect opens them.

  1. Declare the quirk

    den.quirks.firewall = {
    description = "Firewall port declarations";
    };
  2. Produce data

    Any aspect can emit data on the firewall key. Producers don’t need to know who consumes it:

    den.aspects.nginx = {
    nixos.services.nginx.enable = true;
    firewall = { ports = [ 80 443 ]; };
    };
    den.aspects.postgres = {
    nixos.services.postgresql.enable = true;
    firewall = { ports = [ 5432 ]; };
    };
  3. Consume data

    A class module receives all firewall entries as a list by naming the quirk in its function arguments:

    den.aspects.networking = {
    nixos = { firewall, lib, ... }: {
    networking.firewall.allowedTCPPorts =
    lib.concatMap (f: f.ports or []) firewall;
    };
    };
  4. Include everything

    den.aspects.igloo = {
    includes = [
    den.aspects.nginx
    den.aspects.postgres
    den.aspects.networking
    ];
    };

    Result: networking.firewall.allowedTCPPorts = [ 80 443 5432 ].

No pipe policy needed — same-scope aggregation works out of the box. If no producers emit data, the consumer receives [].

When you need to process data before consumers see it, use pipe policies. All pipe stages are accessed via den.lib.policy.pipe.

Remove entries that don’t match a predicate:

den.policies.tcp-only = { host, ... }:
let inherit (den.lib.policy) pipe; in
[ (pipe.from "firewall" [
(pipe.filter (e: e.proto == "tcp"))
])
];
den.default.includes = [ den.policies.tcp-only ];

Given producers emitting [{ port = 80; proto = "tcp"; } { port = 53; proto = "udp"; }], consumers see only [{ port = 80; proto = "tcp"; }].

Map each entry to a new shape:

den.policies.label-items = { host, ... }:
let inherit (den.lib.policy) pipe; in
[ (pipe.from "items" [
(pipe.transform (i: { label = "x-${i.name}"; }))
])
];

Reduce all entries to a single value:

den.policies.sum-nums = { host, ... }:
let inherit (den.lib.policy) pipe; in
[ (pipe.from "nums" [
(pipe.fold (acc: n: acc + n) 0)
])
];

The consumer receives a single-element list: [ 60 ] for inputs [ 10 20 30 ].

Add a synthetic entry to the pool:

den.policies.add-default = { host, ... }:
let inherit (den.lib.policy) pipe; in
[ (pipe.from "items" [
(pipe.append { name = "default"; })
])
];

Replace the entire list. At most one pipe.for per pipe per scope:

den.policies.reverse-items = { host, ... }:
let inherit (den.lib.policy) pipe; in
[ (pipe.from "items" [
(pipe.for (vals: lib.reverseList vals))
])
];

Stages compose left-to-right:

pipe.from "items" [
(pipe.filter (i: i.keep))
(pipe.transform (i: { label = "x-${i.name}"; }))
]

Push child-scope data to the parent. A user’s preferences reach the host:

den.quirks.prefs = { description = "User preferences"; };
den.aspects.tux = {
prefs = [{ editor = "vim"; }];
};
den.aspects.igloo = {
includes = [ den.aspects.host-consumer ];
};
den.aspects.host-consumer = {
nixos = { prefs, ... }: {
networking.hostName =
lib.concatMapStringsSep "-" (p: p.editor) prefs;
};
};
den.policies.expose-prefs = { host, user, ... }:
let inherit (den.lib.policy) pipe; in
[ (pipe.from "prefs" [ pipe.expose ]) ];
den.default.includes = [ den.policies.expose-prefs ];

Result: host consumer sees [{ editor = "vim"; }].

Exposed data merges with host-local data. If the host aspect also emits prefs, consumers see both host-local and exposed user entries.

Transform stages run before expose:

pipe.from "items" [
(pipe.filter (i: i.keep))
(pipe.transform (i: { label = "x-${i.name}"; }))
pipe.expose
]

Harvest data from sibling scopes. Siblings are scopes sharing the same parent in the scope tree. This is how you do cross-host aggregation:

den.hosts.x86_64-linux.igloo.users.tux = {};
den.hosts.x86_64-linux.iceberg.users.alice = {};
den.quirks.http-backends = {
description = "HTTP backends";
};
den.aspects.iceberg = {
http-backends = { addr = "10.0.0.2"; port = 80; };
};
den.aspects.igloo = {
includes = [ den.aspects.lb-consumer ];
http-backends = { addr = "10.0.0.1"; port = 8080; };
};
den.aspects.lb-consumer = {
nixos = { http-backends, lib, ... }: {
networking.hostName =
lib.concatMapStringsSep "," (b: "${b.addr}:${toString b.port}")
http-backends;
};
};
den.policies.fleet-backends = { host, ... }:
let inherit (den.lib.policy) pipe; in
[ (pipe.from "http-backends" [
(pipe.collect ({ host, ... }: true))
])
];
den.schema.host.includes = [ den.policies.fleet-backends ];

The predicate in pipe.collect filters which sibling scopes to harvest from. Entity kind matching ensures only scopes of the right kind are included.

Track where collected data came from:

pipe.from "http-backends" [
(pipe.collect ({ host, ... }: true))
pipe.withProvenance
]

Consumers receive [{ value = <data>; source = <context>; }] where source is the full context attrset ({ host = ...; }) of the source scope.

Stages after pipe.collect operate on the combined pool:

pipe.from "http-backends" [
(pipe.collect ({ host, ... }: true))
(pipe.filter (b: b.port != 8080))
]

Route pipe data to specific aspects by identity:

den.policies.route-firewall = { host, ... }:
let inherit (den.lib.policy) pipe; in
[ (pipe.from "firewall" [
(pipe.to [ den.aspects.networking ])
])
];

Only the named aspects receive the data. Other consumers of the same pipe see the unmodified pool.

Combine pipe.filter, pipe.append, pipe.for, and pipe.to to deliver a custom attrset to a specific aspect:

den.quirks.secrets = { description = "Secret paths"; };
den.policies.postgres-secrets = { host, ... }:
let inherit (den.lib.policy) pipe; in
[ (pipe.from "secrets" [
(pipe.filter (_: false)) # discard the pool
(pipe.append { db-password = "/run/secrets/pg-pass"; })
(pipe.for lib.mergeAttrsList) # merge into a single attrset
(pipe.to [ den.aspects.postgres ])
])
];
den.schema.host.includes = [ den.policies.postgres-secrets ];

The consumer receives a single merged attrset instead of a list:

den.aspects.postgres = {
nixos = { secrets, ... }: {
services.postgresql.settings.password_file = secrets.db-password;
};
};

This pattern works because pipe.for replaces the list with the fold result, and pipe.to delivers only to the named aspect.

Quirk values can depend on a host’s NixOS config. Declare a function taking { config, ... } as the quirk value:

den.aspects.my-service = {
nixos.services.my-service.enable = true;
firewall = { config, ... }: {
ports = [ config.services.my-service.port ];
};
};

Local thunks are resolved lazily inside evalModules. Cross-host thunks (via pipe.collect) are resolved eagerly against the source host’s config.

Contribute Community Sponsor