Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/nix/module-eval/builder.nix
atlas e30f5849d5 swarm-builder: export build counts, durations, queue wait and publish failures through the host's collector
The builder pushes its build metrics (builds by kind and outcome, build
duration, queue depth and wait, reply publish failures) to its forwarder's
OTLP receiver, which relays them to the swarm collector.

It also registers the standard store client-certificate gauges
(bao.client_cert.*, cert=builder) on the same exporter. They are the
builder's liveness signal: exported every minute whether or not a build
runs. The cert-metrics table in docs/scheduler/observability.md gains the
builder row.
2026-11-05 00:43:25 +01:00

561 lines
25 KiB
Nix

# `checks.module-eval-builder` — see ./lib.nix for the shared rationale (why
# this suite exists, naming convention, "evaluates not executes"). The swarm
# builder's container, its default and its queue and IdP registrations; its
# store grants are in ./bao-grants.nix beside every other principal's.
{
pkgs,
lib,
self,
nixosSystem,
shared,
}:
let
inherit
(import ./lib.nix {
inherit
pkgs
lib
self
nixosSystem
;
})
hive
hiveWithController
runGroup
;
bare = shared.bareWithController;
# On the store's host, which mints the builder's store identity.
withBuilder = hive {
deploy.bao.enable = true;
deploy.builder.enable = true;
swarm.attic.domain = "cache.example.org";
swarm.attic.cache = "grid";
};
servicesHere = shared.swarmServicesWithController;
# Short-circuits on `assertion`, so only a failing one has its message read.
refusedWithoutIdentity =
m:
lib.any (a: !a.assertion && lib.hasInfix "deploy.builder.enable requires" a.message) m.assertions;
# The one fixture that fires the cache assertion: a domain with no cache name.
halfCache = hive {
deploy.bao.enable = true;
deploy.builder.enable = true;
swarm.attic.domain = "cache.example.org";
swarm.attic.cache = null;
};
refusedHalfCache =
m:
lib.any (
a:
!a.assertion && lib.hasInfix "swarm.attic.domain and services.hyperhive.swarm.attic.cache" a.message
) m.assertions;
# Only the option is read, so the host around it is never evaluated.
cacheNamed = name: (hive { swarm.attic.cache = name; }).services.hyperhive.swarm.attic.cache;
natsHere = shared.natsQueueOnly;
autheliaHere = hive {
deploy.bao.enable = true;
deploy.authelia.enable = true;
};
container = withBuilder.containers.swarm-builder;
inner = container.config;
svc = inner.systemd.services.swarm-builder;
env = svc.environment;
hasOnPath = name: s: lib.any (p: lib.getName p == name) s.path;
# A builder that opts out of pushing: both options null.
withBuilderNoCache = hive {
deploy.bao.enable = true;
deploy.builder.enable = true;
swarm.attic.domain = null;
swarm.attic.cache = null;
};
withoutCache = withBuilderNoCache.containers.swarm-builder.config.systemd.services.swarm-builder;
# A builder on a host that doesn't run the cache and names nothing.
builderDefaults = hive {
deploy.bao.enable = true;
deploy.builder.enable = true;
};
defaultsEnv =
builderDefaults.containers.swarm-builder.config.systemd.services.swarm-builder.environment;
# Hosting the shared services runs the swarm's own cache, which names itself.
atticHere = servicesHere;
atticDeploy = atticHere.services.hyperhive.deploy.attic;
atticSwarm = m: m.services.hyperhive.swarm.attic;
atticUnit = atticHere.containers.swarm-attic.config.systemd.services.swarm-attic;
atticSecret = atticHere.systemd.services.swarm-bao-attic-secret;
atticTokens = atticHere.systemd.services.swarm-bao-attic-tokens;
atticVhost = atticHere.services.hyperhive.gateway.vhosts."attic.t.local";
# The cache switched on while the operator overrides the options.
atticByHand = hiveWithController {
deploy.bao.enable = true;
deploy.attic.enable = true;
swarm.attic.domain = "cache.example.org";
swarm.attic.cache = "grid";
};
# An external cache on a host that hosts the shared services: the switch forced
# off, the two options named.
atticExternal = hiveWithController {
deploy.allSwarmServices = true;
deploy.attic.enable = lib.mkForce false;
swarm.attic.domain = "cache.example.org";
swarm.attic.cache = "grid";
};
atticWarnings =
m:
lib.length (
lib.filter (lib.hasInfix "services.hyperhive.deploy.attic.enable is on while") m.warnings
);
hasAtticHost =
m:
m.containers ? swarm-attic
|| m.systemd.services ? swarm-bao-attic-secret
|| m.systemd.services ? swarm-bao-attic-tokens;
# The check flake on the inputs the builder's unit names, called as
# `nix build` calls it. `hyperhive` is entered through the filtered source's
# own `flake.nix`.
builderCheck =
agentModule:
let
src = env.SWARM_BUILDER_HYPERHIVE_FLAKE;
hyperhive = (import "${src}/flake.nix").outputs (
self.inputs
// {
self = hyperhive // {
outPath = src;
inherit (self) inputs;
};
}
);
in
((import "${env.SWARM_BUILDER_CHECK_FLAKE}/flake.nix").outputs {
inherit hyperhive;
agent.nixosModules.default = agentModule;
}).packages.x86_64-linux.check;
cases = [
{
name = "the swarm builder's container exists only where its host enables it";
ok = !(bare.containers ? swarm-builder) && withBuilder.containers ? swarm-builder;
}
{
# The store's host mints the builder's leaf, so the switch needs no
# manual identity step: the pair is set and the module's identity
# assertion, which nothing else reads in this fixture, passes. `nats` is
# the control that the switch is on.
name = "hosting the shared services turns the builder on, with its store identity";
ok =
servicesHere.services.hyperhive.deploy.builder.enable
&& servicesHere.containers ? swarm-builder
&& servicesHere.services.hyperhive.deploy.bao.builderClientCertFile != null
&& servicesHere.services.hyperhive.deploy.bao.builderClientKeyFile != null
&& !(refusedWithoutIdentity servicesHere)
&& servicesHere.services.hyperhive.deploy.bao.enable
&& servicesHere.services.hyperhive.deploy.nats.enable;
}
{
# A config PR is unreviewed input to an evaluation: the container keeps
# its own netns, and reaches the host's nix daemon through its socket
# alone, with no daemon of its own racing the bind.
name = "the builder's container has its own netns and builds only through the host's daemon socket";
ok =
container.privateNetwork
&& inner.services.hyperhive.swarmContainer.privateNetwork
&& container.bindMounts."/nix/var/nix/daemon-socket".hostPath == "/nix/var/nix/daemon-socket"
&& !inner.systemd.services.nix-daemon.enable
&& !inner.systemd.sockets.nix-daemon.enable
&& inner.systemd.globalEnvironment.NIX_REMOTE == "daemon";
}
{
# The daemon holds its store leaf, handed in by nspawn without a copy on
# disk; the forge token is read per build and reaches only `git`.
name = "the builder loads only its store leaf, runs as a dynamic user and carries no token in its unit";
ok =
lib.elem "builder-bao-key" svc.serviceConfig.LoadCredential
&& env.BAO_CLIENT_KEY == "/run/credentials/swarm-builder.service/builder-bao-key"
&& env.SWARM_BUILDER_OIDC_CLIENT_ID == "swarm-builder"
&& lib.elem "--load-credential=builder-bao-key:/var/lib/swarm-bao-pki/builder-key.pem" container.extraFlags
&& svc.serviceConfig.DynamicUser
&& !(lib.any (lib.hasSuffix "TOKEN") (lib.attrNames env));
}
{
# The daemon pushes its build metrics to the forwarder's OTLP receiver on
# the container's loopback, which relays them to the swarm collector under
# the builder's own client.
name = "the builder exports its metrics through the forwarder's OTLP receiver on the container's loopback";
ok =
let
f = inner.services.hyperhive.swarmJournalForwarder;
in
f.otlpMetricsPort != null
&& env.OTEL_EXPORTER_OTLP_ENDPOINT == "http://127.0.0.1:${toString f.otlpMetricsPort}"
&& lib.hasInfix "service.name=hyperhive-swarm-builder" env.OTEL_RESOURCE_ATTRIBUTES;
}
{
# The closure is pushed with `attic` from the unit, whose config file holds
# the push token: it goes in a runtime directory only the dynamic user can
# enter, and the token is in no variable. The host without a cache is the
# control: same unit, none of it, with `git` still on the path to show
# the lookup finds what is there.
name = "the builder pushes to the configured cache with attic from a private runtime directory, and pushes nowhere without one";
ok =
env.SWARM_BUILDER_ATTIC_DOMAIN == "cache.example.org"
&& env.SWARM_BUILDER_ATTIC_CACHE == "grid"
&& hasOnPath "attic" svc
&& svc.serviceConfig.RuntimeDirectory == "swarm-builder"
&& svc.serviceConfig.RuntimeDirectoryMode == "0700"
&& !(lib.any (lib.hasSuffix "TOKEN") (lib.attrNames env))
&& hasOnPath "git" withoutCache
&& !(hasOnPath "attic" withoutCache)
&& !(withoutCache.environment ? SWARM_BUILDER_ATTIC_DOMAIN)
&& !(withoutCache.environment ? SWARM_BUILDER_ATTIC_CACHE);
}
{
# A domain with no cache name would crash-loop the builder at start, so
# evaluation refuses it. `withBuilder`, with both set, is the control: the
# same assertion list holds the entry and it passes.
name = "the builder refuses a cache domain without a cache name";
ok =
refusedHalfCache halfCache
&& !(refusedHalfCache withBuilder)
&& lib.any (a: lib.hasInfix "swarm.attic.domain and" a.message) withBuilder.assertions;
}
{
# attic refuses a cache name outside its rule, which would fail every push.
# `grid` is the control that the lookup reads a name.
name = "the cache name must follow attic's rule";
ok =
cacheNamed "grid" == "grid"
&& cacheNamed "A-b_c+d" == "A-b_c+d"
&& lib.all (n: !(builtins.tryEval (cacheNamed n)).success) [
"grid.v2"
"-grid"
""
(lib.concatStrings (lib.genList (_: "a") 51))
];
}
{
# The minting unit goes inactive after each run, so a start that pulls it
# runs it again and mints a leaf that is missing; an active
# `RemainAfterExit` unit would satisfy the pull without running.
name = "the builder's container runs the leaf-minting unit before every start";
ok =
let
pki = withBuilder.systemd.services.swarm-bao-pki;
c = withBuilder.systemd.services."container@swarm-builder";
in
pki.serviceConfig.Type == "oneshot"
&& !(pki.serviceConfig.RemainAfterExit or false)
&& lib.elem "multi-user.target" pki.wantedBy
&& lib.elem "swarm-bao-pki.service" c.wants
&& lib.elem "swarm-bao-pki.service" c.after;
}
{
# The check builds on the sources this host was deployed with, so a green check means
# the same hyperhive and nixpkgs base.
name = "the builder builds on the hyperhive and nixpkgs this host was deployed with";
ok =
env.SWARM_BUILDER_HYPERHIVE_FLAKE == withBuilder.services.hyperhive.deploy.hyperhiveFlake
&& env.SWARM_BUILDER_NIXPKGS_FLAKE == withBuilder.services.hyperhive.deploy.builder.nixpkgsFlake
&& lib.hasSuffix "-check-flake" env.SWARM_BUILDER_CHECK_FLAKE
&& env.SWARM_BUILDER_SYSTEM == pkgs.stdenv.hostPlatform.system
&& env.SWARM_BUILDER_CONFIG_ORG == "agent-configs"
&& env.SWARM_BUILDER_FORGE_URL == "https://${withBuilder.services.hyperhive.swarm.forge.domain}";
}
{
# The builder evaluates `builtins.mapAttrs (_: a: a.meta)` over this
# output and prints it as JSON. A derivation or a path in `meta` would
# make that evaluation build or copy something; a function would not
# print at all.
name = "the built-in archetypes carry a module and a meta of plain data, and reviewer can run commands and android-dev installs its toolchain and reverse-engineer turns rea on";
ok =
let
plain =
v:
if lib.isDerivation v || lib.isFunction v || builtins.isPath v then
false
else if lib.isAttrs v then
lib.all plain (lib.attrValues v)
else if lib.isList v then
lib.all plain v
else
true;
archetypes = self.hyperhiveArchetypes;
metas = builtins.mapAttrs (_: a: a.meta) archetypes;
reviewer = metas.reviewer.permissions;
in
lib.all (a: a ? module && a.meta.description != "") (lib.attrValues archetypes)
&& plain metas
&& lib.elem "messaging" reviewer
&& lib.elem "inbox" reviewer
&& lib.elem "execution" reviewer
&& !(lib.elem "meta" reviewer)
&&
lib.elem pkgs.android-tools
(archetypes.android-dev.module { inherit pkgs; }).environment.systemPackages
&& archetypes.reverse-engineer.module.services.hyperhive.agent.rea.enable;
}
{
# `docs.enable` reads `docs.source`, whose default builds from the
# `docs/` the `hyperhive` source has none of; an eval error there fails
# this whole group.
name = "the builder's check evaluates a config with the reference docs on";
ok =
lib.hasSuffix ".drv"
(builderCheck {
services.hyperhive.agent.user.name = "a1";
services.hyperhive.agent.docs.enable = true;
}).drvPath;
}
{
# The responder denies a client id it does not know, and a NATS denial
# arrives as a timeout.
name = "the queue's responder grants the builder's client its subject";
ok = lib.hasInfix "--builder-client swarm-builder" natsHere.containers.swarm-nats.config.systemd.services.swarm-nats-auth.serviceConfig.ExecStart;
}
{
name = "the builder's client is registered as a machine, and the mint stores its secret";
ok =
let
c = lib.findFirst (
c: c.id == "swarm-builder"
) null autheliaHere.services.hyperhive.swarm.authelia.oidc.clients;
in
c != null
&& c.kind == "machine"
&& lib.hasInfix "secret/swarm/services/swarm-builder/oidc/client" (
autheliaHere.containers.swarm-authelia.config.systemd.services.authelia-swarm-secrets.script
);
}
{
# The switch is on wherever the shared services are and the host holds the
# cache's leaf.
# The builder on the same host pushes to it. `bare` is the control that no
# cache renders where nothing enables it.
name = "hosting the shared services runs the swarm's cache, and the builder pushes to it";
ok =
atticDeploy.enable
&& atticHere.containers ? swarm-attic
&& atticHere.services.hyperhive.deploy.bao.atticClientCertFile != null
&& atticHere.services.hyperhive.deploy.bao.atticClientKeyFile != null
&& (atticSwarm atticHere).domain == "attic.t.local"
&& (atticSwarm atticHere).cache == "swarm"
&&
atticHere.containers.swarm-builder.config.systemd.services.swarm-builder.environment.SWARM_BUILDER_ATTIC_DOMAIN
== "attic.t.local"
&&
atticHere.containers.swarm-builder.config.systemd.services.swarm-builder.environment.SWARM_BUILDER_ATTIC_CACHE
== "swarm"
&& !(hasAtticHost bare);
}
{
# The options default to the swarm's own cache on every host, so a
# builder host that runs no cache pushes to it. `atticHere` is the control
# that the cache host reads the same values.
name = "a builder host that doesn't run the cache pushes to the swarm's own by default";
ok =
!(hasAtticHost builderDefaults)
&& (atticSwarm builderDefaults).domain == "attic.t.local"
&& (atticSwarm builderDefaults).cache == "swarm"
&& (atticSwarm bare).domain == (atticSwarm atticHere).domain
&& (atticSwarm bare).cache == (atticSwarm atticHere).cache
&& defaultsEnv.SWARM_BUILDER_ATTIC_DOMAIN == "attic.t.local"
&& defaultsEnv.SWARM_BUILDER_ATTIC_CACHE == "swarm"
&& !(builderDefaults.systemd.services ? swarm-bao-attic-tokens);
}
{
# Names the operator gives win over the cache's own, and the gateway then
# serves the cache under the operator's name. `atticHere` is the control
# that the same lookups find the defaults.
name = "options named by hand win over the cache's defaults";
ok =
atticByHand.services.hyperhive.deploy.attic.enable
&& atticByHand.containers ? swarm-attic
&& (atticSwarm atticByHand).domain == "cache.example.org"
&& (atticSwarm atticByHand).cache == "grid"
&& atticByHand.services.hyperhive.gateway.vhosts ? "cache.example.org"
&& !(atticByHand.services.hyperhive.gateway.vhosts ? "attic.t.local")
&& atticHere.services.hyperhive.gateway.vhosts ? "attic.t.local";
}
{
# The cache running here under a name other than its own warns once, naming
# the fix. `atticHere` (default name) and `atticExternal` (cache off) are
# the controls that the same lookup finds no warning.
name = "the cache running under another name warns once, and the default and an external cache do not";
ok =
atticWarnings atticByHand == 1
&& lib.any (lib.hasInfix "lib.mkForce false") atticByHand.warnings
&& lib.any (lib.hasInfix "cache.example.org") atticByHand.warnings
&& atticWarnings atticHere == 0
&& atticWarnings atticExternal == 0;
}
{
# An operator's external cache on a host that hosts the shared services:
# with the switch forced off nothing of the cache is rendered, and the
# options keep what was named. `atticHere` is the control that the same
# lookups find the cache's pieces where it runs.
name = "with the cache switched off the options are the operator's and no cache runs";
ok =
!atticExternal.services.hyperhive.deploy.attic.enable
&& !(hasAtticHost atticExternal)
&& !(atticExternal.services.hyperhive.gateway.vhosts ? "attic.t.local")
&& (atticSwarm atticExternal).domain == "cache.example.org"
&& (atticSwarm atticExternal).cache == "grid"
&&
atticExternal.containers.swarm-builder.config.systemd.services.swarm-builder.environment.SWARM_BUILDER_ATTIC_DOMAIN
== "cache.example.org"
&& hasAtticHost atticHere
&& atticHere.systemd.services ? swarm-bao-attic-secret
&& atticHere.systemd.services ? swarm-bao-attic-tokens;
}
{
# The hive collector's journal allow-list carries the cache's init units
# on a hive host with the cache and no swarm collector (`atticByHand`).
# Where the swarm collector runs too (`atticHere`) it reads the whole
# journal, so no unit is named and the hive collector has no journald
# receiver; without the cache (`atticExternal`, which also runs the swarm
# collector) the units are absent too. `hive-c0re.service` on `atticByHand`
# is the control that the lookup finds the list.
name = "the hive collector ships the cache's init units only where nothing else reads the host journal";
ok =
let
units =
m: (m.services.opentelemetry-collector.settings.receivers.journald or { units = [ ]; }).units;
initUnits = [
"swarm-bao-attic-secret.service"
"swarm-bao-attic-tokens.service"
];
has = m: lib.all (u: lib.elem u (units m)) initUnits;
hasNone = m: lib.all (u: !(lib.elem u (units m))) initUnits;
in
has atticByHand
&& hasNone atticHere
&& hasNone atticExternal
&& lib.elem "hive-c0re.service" (units atticByHand);
}
{
# The hive collector ships the container units' start output (the rootfs
# check's lines) unless the swarm collector already reads the whole
# journal. `hive-c0re.service` on `atticByHand` is the control that the
# lookup finds the list.
name = "the hive collector ships the container units' journal only where nothing else reads the host journal";
ok =
let
units =
m: (m.services.opentelemetry-collector.settings.receivers.journald or { units = [ ]; }).units;
glob = "container@*.service";
in
lib.elem glob (units atticByHand)
&& !(lib.elem glob (units atticHere))
&& lib.elem "hive-c0re.service" (units atticByHand);
}
{
# Both host units log in as the cache's principal, not the hive's, and
# the container reaches the key through a read-only directory. The
# unit script holds the create calls only in ./lib/attic-init.nix, which a
# script test runs.
name = "the cache's host units use its own store leaf, and the container gets the key read-only";
ok =
atticSecret.environment.BAO_CLIENT_CERT == "/var/lib/swarm-bao-pki/attic.pem"
&& atticSecret.environment.BAO_CLIENT_KEY == "/var/lib/swarm-bao-pki/attic-key.pem"
&& atticTokens.environment.BAO_CLIENT_CERT == "/var/lib/swarm-bao-pki/attic.pem"
&& atticSecret.serviceConfig.Type == "oneshot"
&& lib.elem "container@swarm-attic.service" atticSecret.before
&& lib.elem "container@swarm-attic.service" atticTokens.after
&& lib.elem "swarm-bao-attic-policy.service" atticSecret.after
&& lib.elem "swarm-bao-attic-policy.service" atticTokens.after
&& atticHere.containers.swarm-attic.bindMounts."/run/swarm-attic-secrets".isReadOnly
&& atticHere.containers.swarm-attic.privateNetwork
&& atticUnit.serviceConfig.EnvironmentFile == "/run/swarm-attic-secrets/server.env"
&& lib.hasInfix "--mode monolithic" atticUnit.serviceConfig.ExecStart
&& atticDeploy.retentionPeriod == "14 days";
}
{
# atticd's journal reaches the swarm collector through the container's own
# forwarder, under the cache's store leaf. The host with the switch forced
# off is the control that nothing of it renders there, and `atticHere`
# that the same lookups find it where the cache runs.
name = "the cache's container forwards its journal to the swarm collector as its own client, and only where the cache runs";
ok =
let
inner = atticHere.containers.swarm-attic;
f = inner.config.services.hyperhive.swarmJournalForwarder;
fetch = inner.config.systemd.services.swarm-journal-forwarder-oidc;
collector = inner.config.systemd.services.opentelemetry-collector;
in
f.enable
&& f.serviceName == "attic"
&& f.clientId == "swarm-attic-collector"
&& f.endpoint == "https://${atticHere.services.hyperhive.swarm.otel.domain}/swarm-attic"
&& f.store.secretPath == "secret/swarm/services/swarm-attic-collector/oidc/client"
&& f.store.clientCertFile == "/run/credentials/swarm-journal-forwarder-oidc.service/attic-bao-cert"
&& lib.elem "attic-bao-key" fetch.serviceConfig.LoadCredential
&& lib.elem "--load-credential=attic-bao-cert:/var/lib/swarm-bao-pki/attic.pem" inner.extraFlags
&& !(lib.elem "--link-journal=host" inner.extraFlags)
&& collector.enable
&&
atticHere.services.hyperhive.swarm.otel.serviceProducers.swarm-attic.clientId
== "swarm-attic-collector"
&& lib.hasInfix "services/swarm-attic-collector/oidc/client" atticHere.systemd.services.swarm-bao-attic-policy.script
&& !(atticExternal.containers ? swarm-attic)
&& atticExternal.services.hyperhive.swarm.otel.serviceProducers ? swarm-attic;
}
{
# The exporter reads atticd's database through a read-only URI, as a
# dynamic user and not root, and pushes to the forwarder's OTLP receiver,
# whose port the forwarder opens. The host with the cache off is the
# control that nothing of it renders there.
name = "the cache's container exports its size from a read-only look at the database, as a non-root user, through the forwarder";
ok =
let
inner = atticHere.containers.swarm-attic.config;
svc = inner.systemd.services.swarm-attic-cache-size;
f = inner.services.hyperhive.swarmJournalForwarder;
port = toString f.otlpMetricsPort;
in
svc.serviceConfig.Type == "oneshot"
&& svc.serviceConfig.DynamicUser
&& svc.serviceConfig.User == "swarm-attic"
&& lib.hasInfix "-readonly" svc.script
&& lib.hasInfix "server.db?mode=ro" svc.script
&& !(lib.hasInfix "mode=rw" svc.script)
&& lib.hasInfix "state = 'V'" svc.script
&& lib.hasInfix "hyperhive.attic.cache_bytes" svc.script
&& lib.hasInfix "hyperhive.attic.cache_nars" svc.script
&& lib.hasInfix "127.0.0.1:${port}/v1/metrics" svc.script
&& f.otlpMetricsPort != null
&& inner.systemd.timers ? swarm-attic-cache-size
&& !(atticExternal.containers ? swarm-attic);
}
{
# attic checks its own tokens and a nix client cannot follow a login
# redirect, so the vhost carries no `auth_request`; the tokens ride in a
# header, so it is TLS only; and a store path is one long upload.
name = "the cache's vhost is TLS only, unauthenticated at the gateway and streams uploads, and its name is a swarm service name on every host";
ok =
let
names = m: m.services.hyperhive.swarm.serviceDomains;
in
atticVhost.forceSSL
&& atticVhost.locations."/".proxyPass == "http://unix:/run/swarm-attic/atticd.sock"
&& !(lib.hasInfix "auth_request" atticVhost.locations."/".extraConfig)
&& lib.hasInfix "client_max_body_size 0" atticVhost.locations."/".extraConfig
&& lib.hasInfix "proxy_request_buffering off" atticVhost.locations."/".extraConfig
&& lib.elem "attic.t.local" (names atticHere)
&& names atticHere == names bare
&& names atticExternal == names bare
&& !(lib.elem "cache.example.org" (names atticExternal));
}
];
in
runGroup "builder" cases