nix: split module-eval into per-subsystem checks
The single module-eval derivation forced ~62 full nixosSystem fixtures live at once to compute its cases list: 10.6GB peak RSS / 5m25s to evaluate, by far the dominant cost in nix flake check. Splits it into 21 independent checks.module-eval-* derivations (1-7 fixtures each) sharing builders/helpers via module-eval/lib.nix, so no single derivation needs more than a handful of fixtures live at once. A few cases spanning two clusters carry a small duplicated fixture rather than threading shared state through lib.nix.
This commit is contained in:
parent
69b70a9c6f
commit
dc418a5223
24 changed files with 3760 additions and 2997 deletions
302
nix/module-eval/secret-publisher.nix
Normal file
302
nix/module-eval/secret-publisher.nix
Normal file
|
|
@ -0,0 +1,302 @@
|
|||
# `checks.module-eval-secret-publisher` — see ./lib.nix for the shared
|
||||
# rationale (why this suite exists, naming convention, "evaluates
|
||||
# not executes").
|
||||
{
|
||||
pkgs,
|
||||
lib,
|
||||
self,
|
||||
nixosSystem,
|
||||
}:
|
||||
let
|
||||
inherit
|
||||
(import ./lib.nix {
|
||||
inherit
|
||||
pkgs
|
||||
lib
|
||||
self
|
||||
nixosSystem
|
||||
;
|
||||
})
|
||||
hive
|
||||
runGroup
|
||||
;
|
||||
|
||||
# The IdP and the store on one machine: the shape where minted plaintext and
|
||||
# a store identity are both present without an operator placing anything.
|
||||
# Two hives in the roster, because the publisher walks it — an arm written
|
||||
# against a single-hive fixture passes on a hardcoded name.
|
||||
secretPublisherHere = hive {
|
||||
deploy.bao.enable = true;
|
||||
deploy.authelia.enable = true;
|
||||
swarm.hives.h2.domain = "h2.t.local";
|
||||
};
|
||||
|
||||
# The IdP with no store on the box and a leaf placed by hand, which is the
|
||||
# deployment this unit exists for: authelia is the one host the store is
|
||||
# guaranteed not to share once either has a machine of its own.
|
||||
#
|
||||
# ⚠️ `enable` is deliberately NOT set here. It used to be, with a comment
|
||||
# saying the default asked whether both ran on this host — which documented
|
||||
# the co-location bug instead of catching it. Leaving it unset is what makes
|
||||
# this fixture exercise the default rather than mask it.
|
||||
secretPublisherRemote = hive {
|
||||
deploy.authelia.enable = true;
|
||||
deploy.swarm-secret-publisher.baoClientCertFile = "/etc/pki/publisher.pem";
|
||||
deploy.swarm-secret-publisher.baoClientKeyFile = "/etc/pki/publisher-key.pem";
|
||||
};
|
||||
|
||||
# The same IdP with the identity taken away. Minting the secrets is not being
|
||||
# able to publish them, and this is the arm that separates the two.
|
||||
secretPublisherNoIdentity = hive { deploy.authelia.enable = true; };
|
||||
# Duplicated from grafana.nix — a case here needs it too.
|
||||
|
||||
# The same UI with the IdP on ANOTHER host and a store leaf placed by hand.
|
||||
# Knowing an IdP is not running one: `swarm.authelia.url` is what says this
|
||||
# swarm has SSO, and nothing about this host does. Identical to the fixture
|
||||
# above in everything the delivery path reads, which is the point.
|
||||
grafanaRemoteAuthelia = hive {
|
||||
deploy.grafana.enable = true;
|
||||
deploy.grafana.plugins = [ ];
|
||||
deploy.grafana.package = pkgs.emptyDirectory;
|
||||
swarm.authelia.url = "https://auth.example.invalid";
|
||||
deploy.bao.clientCertFile = "/etc/pki/bao-client.pem";
|
||||
deploy.bao.clientKeyFile = "/etc/pki/bao-client-key.pem";
|
||||
};
|
||||
# Duplicated from swarm-otel-core.nix — a case here needs it too.
|
||||
|
||||
# The same collector with the IdP on ANOTHER host and a store leaf placed by
|
||||
# hand. Identical to the fixture above in everything the delivery path
|
||||
# reads, which is the point.
|
||||
otelBaoRemoteAuthelia = hive {
|
||||
deploy.swarm-otel.enable = true;
|
||||
swarm.authelia.url = "https://auth.example.invalid";
|
||||
deploy.bao.clientCertFile = "/etc/pki/bao-client.pem";
|
||||
deploy.bao.clientKeyFile = "/etc/pki/bao-client-key.pem";
|
||||
};
|
||||
# Duplicated from core-toggle.nix — a case here needs it too.
|
||||
|
||||
bare = hive { };
|
||||
# Duplicated from bao-matrix-reader.nix — a case here needs it too.
|
||||
|
||||
# The store and a service that reads from it, versus the store alone. The
|
||||
# pair is what makes the reader's absence arm mean anything.
|
||||
baoWithMatrix = hive {
|
||||
deploy.bao.enable = true;
|
||||
deploy.matrix.enable = true;
|
||||
};
|
||||
cases = [
|
||||
{
|
||||
# Both ends of a wire nothing at eval time carries end to end: the
|
||||
# publisher on authelia's host writes the path the reader on Grafana's host
|
||||
# reads, and the two files agree only because both compose it from the same
|
||||
# swarm-wide client id.
|
||||
name = "the publisher writes the swarm service path grafana reads";
|
||||
ok = lib.hasInfix "secret/swarm/services/swarm-grafana/oidc/client" (
|
||||
secretPublisherHere.systemd.services.swarm-secret-publish.script
|
||||
);
|
||||
}
|
||||
{
|
||||
# Registering the client cannot live where the rest of grafana's module
|
||||
# lives: that block is gated on this host RUNNING grafana, so on the split
|
||||
# deployment nothing registered the client, authelia minted no secret, and
|
||||
# every layer below had nothing to carry. The second arm is the control —
|
||||
# a host with no IdP registers nothing.
|
||||
name = "the swarm's grafana client is registered wherever authelia runs";
|
||||
ok =
|
||||
let
|
||||
clients = m: map (c: c.id) m.services.hyperhive.swarm.authelia.oidc.clients;
|
||||
in
|
||||
lib.elem "swarm-grafana" (clients secretPublisherHere)
|
||||
&& !(lib.elem "swarm-grafana" (clients grafanaRemoteAuthelia));
|
||||
}
|
||||
{
|
||||
# The collector's half of the same defect and the same fix: this used to
|
||||
# be gated on `deploy.swarm-otel.enable`, so a split deployment
|
||||
# registered the client nowhere and authelia minted nothing to publish.
|
||||
name = "the swarm's collector client is registered wherever authelia runs";
|
||||
ok =
|
||||
let
|
||||
clients = m: map (c: c.id) m.services.hyperhive.swarm.authelia.oidc.clients;
|
||||
in
|
||||
lib.elem "swarm-collector" (clients secretPublisherHere)
|
||||
&& !(lib.elem "swarm-collector" (clients otelBaoRemoteAuthelia));
|
||||
}
|
||||
{
|
||||
# Both ends of a wire nothing at eval time carries end to end: the
|
||||
# publisher on authelia's host writes the path the reader on the
|
||||
# collector's host reads, and the two files agree only because both
|
||||
# compose it from the same swarm-wide client id. `secretPublisherHere`
|
||||
# already grew this client when `serviceClientIds` did.
|
||||
name = "the publisher writes the swarm service path the collector reads";
|
||||
ok = lib.hasInfix "secret/swarm/services/swarm-collector/oidc/client" (
|
||||
secretPublisherHere.systemd.services.swarm-secret-publish.script
|
||||
);
|
||||
}
|
||||
{
|
||||
# The same hole the controller's case above names, open a second time: the
|
||||
# PKI script grew a third leaf and no case read it.
|
||||
name = "the store mints a leaf for the secret publisher, and the publisher is pointed at it";
|
||||
ok =
|
||||
let
|
||||
m = secretPublisherHere;
|
||||
p = m.services.hyperhive.deploy.swarm-secret-publisher;
|
||||
in
|
||||
lib.hasInfix "secret-publisher.pem" m.systemd.services.swarm-bao-pki.script
|
||||
&& p.baoClientCertFile == "/var/lib/swarm-bao-pki/secret-publisher.pem"
|
||||
&& p.baoClientKeyFile == "/var/lib/swarm-bao-pki/secret-publisher-key.pem";
|
||||
}
|
||||
{
|
||||
# mara caught this by reading, which means no arm existed for it: the
|
||||
# default asked `authelia.enable && bao.enable`, so the split deployment
|
||||
# this unit is FOR defaulted off and published nothing, silently.
|
||||
#
|
||||
# The second clause is the control. Without it this passes on a default
|
||||
# of plain `true`, which would be a different bug with the same symptom
|
||||
# — an IdP-less host claiming it publishes secrets it never mints.
|
||||
name = "the publisher defaults on where secrets are minted, whether or not the store is local";
|
||||
ok =
|
||||
secretPublisherRemote.services.hyperhive.deploy.swarm-secret-publisher.enable
|
||||
&& !bare.services.hyperhive.deploy.swarm-secret-publisher.enable;
|
||||
}
|
||||
{
|
||||
# The one security property of this unit, and why its push cannot be
|
||||
# rewritten into the obvious shape: `bao` is an external binary, so an
|
||||
# argument is world-readable in /proc for the life of the call.
|
||||
# `value=@<path>` hands it the path and bao opens the file itself.
|
||||
#
|
||||
# The second arm is what makes the first mean anything — `value=@` can
|
||||
# sit one line above a command substitution that put the plaintext in
|
||||
# argv anyway.
|
||||
#
|
||||
# ⚠️ Comments are stripped first, and that is not tidiness. A `script`
|
||||
# renders its own comments into the text, and this unit's comments name
|
||||
# the hazard verbatim so the next editor does not reintroduce it. Without
|
||||
# the strip this case reads that warning and fails — a check the artifact
|
||||
# defeats by DESCRIBING the thing it is checked for.
|
||||
name = "the publisher hands bao the secret's path, never the secret";
|
||||
ok =
|
||||
let
|
||||
s = secretPublisherHere.systemd.services.swarm-secret-publish.script;
|
||||
code = lib.concatStringsSep "\n" (
|
||||
lib.filter (l: builtins.match "[[:space:]]*#.*" l == null) (lib.splitString "\n" s)
|
||||
);
|
||||
in
|
||||
lib.hasInfix "value=@" code && !(lib.hasInfix "$(cat" code);
|
||||
}
|
||||
{
|
||||
# Two ends of a wire nothing at eval time carries end to end: this is the
|
||||
# path `swarm_secret_client::queue` resolves for the reader. Both hives
|
||||
# are asserted, so a publisher that knew one name rather than the roster
|
||||
# fails here rather than on the second hive ever added to a swarm.
|
||||
name = "the publisher writes every hive in the roster to that hive's own queue path";
|
||||
ok =
|
||||
let
|
||||
s = secretPublisherHere.systemd.services.swarm-secret-publish.script;
|
||||
in
|
||||
lib.hasInfix "secret/swarm/hives/h1/queue/agent" s
|
||||
&& lib.hasInfix "secret/swarm/hives/h2/queue/agent" s;
|
||||
}
|
||||
{
|
||||
# The producer's end of the read `glue-matrix-bao-token.nix` already did.
|
||||
# Both hives are asserted for the reason the queue case above gives: a
|
||||
# publisher that knew one name rather than the roster would pass on a
|
||||
# single-hive fixture and strand the second hive ever added — which is
|
||||
# the two-hives-never-converge shape this slice exists to close.
|
||||
name = "the publisher mints an appservice token for every hive and writes it to that hive's matrix path";
|
||||
ok =
|
||||
let
|
||||
s = secretPublisherHere.systemd.services.swarm-secret-publish.script;
|
||||
in
|
||||
lib.hasInfix "secret/swarm/hives/h1/matrix/appservice-token" s
|
||||
&& lib.hasInfix "secret/swarm/hives/h2/matrix/appservice-token" s
|
||||
&& lib.hasInfix "/dev/urandom" s;
|
||||
}
|
||||
{
|
||||
# What makes a re-publish idempotent. This principal is granted
|
||||
# `create`/`update` and no `read`, so it cannot ask the store whether a
|
||||
# hive already has a token — with nowhere to keep one, every run would
|
||||
# mint a fresh value and rotate the swarm's token. A state directory is
|
||||
# that somewhere, and nothing else in this unit needs one, so its absence
|
||||
# means exactly this.
|
||||
#
|
||||
# The second arm is the mint's own guard: the state file is only written
|
||||
# when it is missing or empty. Dropping that test leaves a unit that
|
||||
# still has a state directory and still rotates on every boot.
|
||||
name = "the publisher keeps the tokens it minted, and mints only when it holds none";
|
||||
ok =
|
||||
let
|
||||
u = secretPublisherHere.systemd.services.swarm-secret-publish;
|
||||
in
|
||||
lib.hasInfix "matrix-appservice-token" (u.serviceConfig.StateDirectory or "")
|
||||
&& u.serviceConfig.StateDirectoryMode or null == "0700"
|
||||
&& lib.hasInfix "if [ ! -s \"$src\" ]" u.script;
|
||||
}
|
||||
{
|
||||
# A property of the SET, not of one unit: both of these authenticate by
|
||||
# certificate, and `BAO_CLIENT_CERT` is transport rather than identity, so
|
||||
# a script that reaches `bao kv` without a token asks a token helper this
|
||||
# host does not carry and fails before the store ever answers. `-token-only`
|
||||
# is what keeps the token off the helper on the way back out.
|
||||
#
|
||||
# Ordering, not presence: the login has to come first, so the check is
|
||||
# that nothing before it is a data command. Comments are stripped because
|
||||
# both units explain this in prose directly above the code.
|
||||
name = "the cert-identity bao units log in before their first read or write, and keep the token out of the helper";
|
||||
ok =
|
||||
let
|
||||
code =
|
||||
s:
|
||||
lib.concatStringsSep "\n" (
|
||||
lib.filter (l: builtins.match "[[:space:]]*#.*" l == null) (lib.splitString "\n" s)
|
||||
);
|
||||
holdsTokenFirst =
|
||||
s:
|
||||
let
|
||||
c = code s;
|
||||
in
|
||||
lib.hasInfix "bao login" c
|
||||
&& lib.hasInfix "-token-only" c
|
||||
&& !(lib.hasInfix "bao kv" (lib.head (lib.splitString "bao login" c)));
|
||||
in
|
||||
holdsTokenFirst secretPublisherHere.systemd.services.swarm-secret-publish.script
|
||||
&& holdsTokenFirst baoWithMatrix.systemd.services.swarm-bao-matrix-token.script
|
||||
# Controls, so a clean verdict above means something. In order: a bare
|
||||
# read is refused, a read placed before the login is refused, and a
|
||||
# login that exists only in a comment is refused — that last one is the
|
||||
# arm the comment-stripping exists for.
|
||||
&& !(holdsTokenFirst "bao kv get -field=value secret/x")
|
||||
&& !(holdsTokenFirst "bao kv get secret/x\nBAO_TOKEN=\"$(bao login -method=cert -token-only)\"")
|
||||
&& !(holdsTokenFirst "# bao login -method=cert -token-only goes here\nbao kv get secret/x")
|
||||
&& holdsTokenFirst "BAO_TOKEN=\"$(bao login -method=cert -token-only)\"\nbao kv get secret/x";
|
||||
}
|
||||
{
|
||||
# The doctrine three glue files state, as a property a rewrite has to
|
||||
# keep: a client is defined by holding a certificate the store accepts,
|
||||
# never by standing next to the store. Gating this on `deploy.bao.enable`
|
||||
# would have left the unit rendering only on the one deployment that has
|
||||
# no use for it.
|
||||
name = "a publisher holding an identity runs on a host with no store";
|
||||
ok =
|
||||
let
|
||||
m = secretPublisherRemote;
|
||||
in
|
||||
!m.services.hyperhive.deploy.bao.enable
|
||||
&& (m.systemd.services ? swarm-secret-publish)
|
||||
&& (m.systemd.paths ? swarm-secret-publish);
|
||||
}
|
||||
{
|
||||
# What makes the arm above able to fail. Minting the secrets is not being
|
||||
# able to publish them: with no certificate the unit would fail a TLS
|
||||
# handshake on every rotation, so it must not exist at all.
|
||||
name = "an IdP host with no store identity renders no publisher";
|
||||
ok =
|
||||
let
|
||||
m = secretPublisherNoIdentity;
|
||||
in
|
||||
m.services.hyperhive.deploy.authelia.enable
|
||||
&& !(m.systemd.services ? swarm-secret-publish)
|
||||
&& !(m.systemd.paths ? swarm-secret-publish);
|
||||
}
|
||||
];
|
||||
in
|
||||
runGroup "secret-publisher" cases
|
||||
Loading…
Reference in a new issue