swarm-bao: refuse a remote reader that named seven of the eight leaves

The four-way client-cert split gives each store reader its own leaf, and
three of the four readers render only where their own leaf exists. On a
host that mints its own PKI glue-bao-tls.nix defaults all eight, so there
is nothing to do; on a hand-configured remote-store hive, omitting one
pair used to mean that unit silently did not render — a privilege-
narrowing unit absent from a green build, with the missing unit as the
only evidence.

Each of the three now asserts its own pair, shaped after
swarm-grafana.nix's haveClientIdentity assertion and named to the pair it
needs. What differs from Grafana's is the gate: these fire only where the
host demonstrably reads the store (it holds deploy.bao.clientCertFile and
clientKeyFile) and the consumer is on. A host with no store identity is
the supported no-store deployment and still evaluates; the collector's
no-secret degrade is untouched, because that host holds no clientCertFile
either.

Also rewords three passive-voice sentences in docs/swarm/secrets.md that
vale flagged, and documents what the refusal costs and where it stays
silent.
This commit is contained in:
atlas 2026-09-23 09:56:43 +02:00 • committed by mara
commit d3e4951cc8
6 changed files with 627 additions and 276 deletions

View file

@ -250,7 +250,7 @@ round trip cheaper and costs a second delivery unit, a second way for the file
to be wrong, and a gate deciding between them — and the gate is the expensive to be wrong, and a gate deciding between them — and the gate is the expensive
part, because whatever it's wrong about is an outage nobody can read. The store part, because whatever it's wrong about is an outage nobody can read. The store
exists so the only secrets a host holds out of band are **certificates**, and exists so the only secrets a host holds out of band are **certificates**, and
everything else is read with them. a host reads everything else with them.
Two things follow, and `swarm-grafana.nix` asserts both rather than degrading: Two things follow, and `swarm-grafana.nix` asserts both rather than degrading:
running Grafana requires `swarm.authelia.url` (this module disables its local running Grafana requires `swarm.authelia.url` (this module disables its local
@ -319,7 +319,7 @@ internal CA deletes that file and names its own paths in
`deploy.bao.serverCertFile` / `clientCaFile`; the store itself has no opinion. `deploy.bao.serverCertFile` / `clientCaFile`; the store itself has no opinion.
A hive that reads from a store on **another** machine names the reader's half — A hive that reads from a store on **another** machine names the reader's half —
`clientCertFile`, `clientKeyFile`, `serverCaFile` — and places that leaf by hand, `clientCertFile`, `clientKeyFile`, `serverCaFile` — and places that leaf by hand,
plus one leaf per principal it runs (the options are listed below). Those are the plus one leaf per principal it runs (the table below names the options). Those are the
credentials that can't come out of the store, being what opens it; everything credentials that can't come out of the store, being what opens it; everything
else a hive needs does. else a hive needs does.
@ -336,20 +336,55 @@ each and each holds a leaf, a role and a policy of its own:
| `swarm-bao-grafana-oidc` | `grafanaOidcClientCertFile` / `grafanaOidcClientKeyFile` | `swarm/services/<grafana client id>/oidc/client` | | `swarm-bao-grafana-oidc` | `grafanaOidcClientCertFile` / `grafanaOidcClientKeyFile` | `swarm/services/<grafana client id>/oidc/client` |
| `swarm-bao-otel-oidc` | `otelOidcClientCertFile` / `otelOidcClientKeyFile` | `swarm/services/<collector client id>/oidc/client` | | `swarm-bao-otel-oidc` | `otelOidcClientCertFile` / `otelOidcClientKeyFile` | `swarm/services/<collector client id>/oidc/client` |
The first two are written **per hive**, because the path they read carries a hive The first two exist **per hive**, because the path they read carries a hive
name and every hive runs its own reader. Their subjects are name and every hive runs its own reader. Their subjects are
`<deploy.bao.matrixTokenCommonNamePrefix>-<hive>` and `<deploy.bao.matrixTokenCommonNamePrefix>-<hive>` and
`<deploy.bao.queueAgentCommonNamePrefix>-<hive>`; `swarm.nix` reserves both `<deploy.bao.queueAgentCommonNamePrefix>-<hive>`; `swarm.nix` reserves both
composed spellings as hive names, so a hive can't be named into another hive's composed spellings as hive names, so nobody can name a hive into another hive's
role. The other two read a path that names a swarm service rather than a hive, so role. The other two read a path that names a swarm service rather than a hive, so
one role each is enough and their subjects are the flat one role each is enough and their subjects are the flat
`deploy.bao.grafanaOidcCommonName` and `deploy.bao.otelOidcCommonName`. `deploy.bao.grafanaOidcCommonName` and `deploy.bao.otelOidcCommonName`.
On a host that mints its own PKI, `glue-bao-tls.nix` signs all four and defaults On a host that mints its own PKI, `glue-bao-tls.nix` signs all four and defaults
all eight options, and there is nothing to do. Elsewhere each leaf is issued from all eight options, and there is nothing to do. Elsewhere you issue each leaf from
that CA out of band and named here — one file per principal rather than one file that CA out of band and name it here — one file per principal rather than one file
shared by four, which is the whole of what this buys. shared by four, which is the whole of what this buys.
Forgetting one of the eight elsewhere isn't a quiet degrade. Three of the four
readers used to render only where their leaf existed, so a hand-configured
remote-store hive that named the hive's own `clientCertFile` and missed a
principal's pair just lost that unit; `swarm-grafana.nix` was alone in refusing
the build. All four now refuse it, each naming its own option pair — see
[what a missing leaf costs](#what-a-missing-leaf-costs) below.
### What a missing leaf costs
Each of the four modules asserts its own pair, on the same condition: **this host
already reads the store** — it holds `deploy.bao.clientCertFile` and
`clientKeyFile` — **and the consumer is on, and this principal's pair is null**.
The refusal names the two options, so an operator acts on the message without
opening the nix.
That condition fires on exactly one shape, the hand-configured remote-store hive
that named seven of the eight. It stays silent everywhere else, and each half of
that matters on its own:
- **A host with no store identity at all** holds no `clientCertFile` either, so
the first clause is false. That's the supported no-store deployment, and also
the one an operator passes through while bringing a hive up — neither should
fail to evaluate.
- **A host that runs the store** gets all eight from `glue-bao-tls.nix` as
defaults, so the third clause is false. Nothing to set, nothing to refuse.
- **A consumer that's off** — no homeserver, no collector, no Grafana — has no
unit to skip, so the second clause is false. Only the queue-credential reader
has no toggle of its own to check: every hive runs one for its own agents, so
reading the store at all is what asks for it.
The collector keeps a separate degrade underneath this, unchanged: a host with no
store identity runs a collector with no OIDC client secret and still receives
telemetry. The assertion doesn't touch that shape, because that host has no
`clientCertFile` either.
## How a reader reaches the store ## How a reader reaches the store
Every reader dials the same URL — `https://bao.<swarm domain>:<port>` — and on Every reader dials the same URL — `https://bao.<swarm domain>:<port>` — and on

View file

@ -49,6 +49,14 @@ let
haveClientIdentity = haveClientIdentity =
baoDeploy.matrixTokenClientCertFile != null && baoDeploy.matrixTokenClientKeyFile != null; baoDeploy.matrixTokenClientCertFile != null && baoDeploy.matrixTokenClientKeyFile != null;
# Does this host read the store at all — the hive's own leaf, which is the
# one thing a remote-store deployment has always had to place by hand. Only
# used to decide whether a missing per-principal leaf is a mistake or a
# deployment that has no store: a host holding neither is the supported
# no-store shape, and one holding this pair but not the pair above named
# seven of the eight options and stopped.
hiveReaderIdentity = baoDeploy.clientCertFile != null && baoDeploy.clientKeyFile != null;
# Where the token lives in the store. A path, not a convention to guess at: # Where the token lives in the store. A path, not a convention to guess at:
# whoever writes it and whoever reads it must agree, and the agreement # whoever writes it and whoever reads it must agree, and the agreement
# belongs in one visible place. # belongs in one visible place.
@ -73,7 +81,50 @@ let
matrixMachine = "hive-matrix"; matrixMachine = "hive-matrix";
in in
{ {
config = lib.mkIf (hyperhiveCfg.enable && haveClientIdentity && deployCfg.matrix.enable) { config = lib.mkMerge [
# ⚠️ A SEPARATE arm from the unit below, and that separation is the whole
# mechanism: the unit's arm is gated on `haveClientIdentity`, so an
# assertion written inside it could never be reached in the state it
# exists to report.
#
# Shaped after ./swarm-grafana.nix's `haveClientIdentity` assertion — the
# same refusal, named to this principal's own pair. What differs is the
# gate. Grafana asserts wherever Grafana runs, because a Grafana with no
# store identity has no way in at all; a homeserver with no store identity
# is a hive that has no store, which is supported. So this one additionally
# requires `hiveReaderIdentity`: the host demonstrably reads the store, and
# named every option but this pair.
(lib.mkIf (hyperhiveCfg.enable && deployCfg.matrix.enable && hiveReaderIdentity) {
assertions = [
{
assertion = haveClientIdentity;
message = ''
This host reads the swarm secret store (services.hyperhive.deploy.bao.clientCertFile
is set) and runs a homeserver, so it needs the matrix appservice
token reader's own client identity: set both
services.hyperhive.deploy.bao.matrixTokenClientCertFile
services.hyperhive.deploy.bao.matrixTokenClientKeyFile
swarm-bao-matrix-token.service fetches this hive's appservice token
out of the store, and without these it is not rendered at all —
leaving the homeserver authenticating hive-c0re against whatever is
already on disk, which is a 401 on every request naming nothing.
⚠️ This reader's OWN leaf, not deploy.bao.clientCertFile. That one is
the hive's, and its grant reads every secret in the store; this role
reads the one appservice-token path. Pointing this option at the
hive's leaf would evaluate, deploy and log in — and undo the split.
On a hive that runs the store, glue-bao-tls.nix supplies both as
defaults and there is nothing to do. Elsewhere the leaf is issued
from that CA out of band and named here — see docs/swarm/secrets.md.
'';
}
];
})
(lib.mkIf (hyperhiveCfg.enable && haveClientIdentity && deployCfg.matrix.enable) {
# Same rule as the unit's own gate: this reader exists on a host that has a # Same rule as the unit's own gate: this reader exists on a host that has a
# client identity and a homeserver, which is not every host that runs the # client identity and a homeserver, which is not every host that runs the
# store, so the store's module cannot name it. # store, so the store's module cannot name it.
@ -202,5 +253,6 @@ in
${deployCfg.matrix.appserviceRegistrationScript} ${deployCfg.matrix.appserviceRegistrationScript}
''; '';
}; };
}; })
];
} }

View file

@ -51,6 +51,14 @@ let
haveClientIdentity = haveClientIdentity =
baoDeploy.queueAgentClientCertFile != null && baoDeploy.queueAgentClientKeyFile != null; baoDeploy.queueAgentClientCertFile != null && baoDeploy.queueAgentClientKeyFile != null;
# Does this host read the store at all — the hive's own leaf, which is the
# one thing a remote-store deployment has always had to place by hand. Only
# used to decide whether a missing per-principal leaf is a mistake or a
# deployment that has no store: a host holding neither is the supported
# no-store shape, and one holding this pair but not the pair above named
# seven of the eight options and stopped.
hiveReaderIdentity = baoDeploy.clientCertFile != null && baoDeploy.clientKeyFile != null;
credentialDir = toString deployCfg.hive-controller.queue.agentCredentialDir; credentialDir = toString deployCfg.hive-controller.queue.agentCredentialDir;
secretFile = "${credentialDir}/secret"; secretFile = "${credentialDir}/secret";
clientIdFile = "${credentialDir}/client_id"; clientIdFile = "${credentialDir}/client_id";
@ -94,7 +102,54 @@ in
}; };
}; };
config = lib.mkIf (hyperhiveCfg.enable && haveClientIdentity) { config = lib.mkMerge [
# ⚠️ A SEPARATE arm from the unit below, and that separation is the whole
# mechanism: the unit's arm is gated on `haveClientIdentity`, so an
# assertion written inside it could never be reached in the state it
# exists to report.
#
# Shaped after ./swarm-grafana.nix's `haveClientIdentity` assertion — the
# same refusal, named to this principal's own pair. Where Grafana's gate
# is `deploy.grafana.enable`, this reader has no toggle of its own to
# check: every hive runs one for its own agents, so reading the store at
# all is what asks for it. Hence `hiveReaderIdentity` alone — a host
# holding no hive leaf has no store to read and nothing is missing.
(lib.mkIf (hyperhiveCfg.enable && hiveReaderIdentity) {
assertions = [
{
assertion = haveClientIdentity;
message = ''
This host reads the swarm secret store (services.hyperhive.deploy.bao.clientCertFile
is set), so it needs the agent queue credential reader's own client
identity: set both
services.hyperhive.deploy.bao.queueAgentClientCertFile
services.hyperhive.deploy.bao.queueAgentClientKeyFile
swarm-bao-queue-agent.service fetches this hive's agent queue
credential out of the store, and without these it is not rendered
at all — leaving hive-c0re with no queue credential and no unit
that would ever have written one.
Every hive runs this reader for its own agents, so unlike the other
three principals there is no per-service toggle that turns it off:
a hive that reads the store at all is a hive that needs this pair.
⚠️ This reader's OWN leaf, not deploy.bao.clientCertFile. That one
is the hive's, and its grant reads every secret in the store; this
role reads the one queue-credential path. Pointing this option at
the hive's leaf would evaluate, deploy and log in — and undo the
split.
On a hive that runs the store, glue-bao-tls.nix supplies both as
defaults and there is nothing to do. Elsewhere the leaf is issued
from that CA out of band and named here — see docs/swarm/secrets.md.
'';
}
];
})
(lib.mkIf (hyperhiveCfg.enable && haveClientIdentity) {
# Same rule as the unit's own gate: this reader exists on any host holding # Same rule as the unit's own gate: this reader exists on any host holding
# a client identity, which is not every host that runs the store, so the # a client identity, which is not every host that runs the store, so the
# store's module cannot name it. # store's module cannot name it.
@ -233,5 +288,6 @@ in
chmod 0644 ${lib.escapeShellArg clientIdFile} chmod 0644 ${lib.escapeShellArg clientIdFile}
''; '';
}; };
}; })
];
} }

View file

@ -242,12 +242,14 @@ let
# A reader of the store is defined by holding a certificate the store # A reader of the store is defined by holding a certificate the store
# accepts, never by standing next to it — the rule # accepts, never by standing next to it — the rule
# ./glue-matrix-bao-token.nix states in full. Unlike Grafana's identical- # ./glue-matrix-bao-token.nix states in full. Unlike Grafana's identical-
# looking flag, this one stays outside `assertions`: the store-reading unit # looking flag, this one does not gate an assertion on its own: the
# below simply does not render without it, the same choice # store-reading unit below simply does not render without it, the same choice
# ./glue-matrix-bao-token.nix and ./glue-queue-agent-credential.nix make for # ./glue-matrix-bao-token.nix and ./glue-queue-agent-credential.nix make for
# their own optional readers, because a collector with no client identity is # their own readers, because a collector with no client identity is
# `haveCollectorSecret = false` above, and that is already a supported, # `haveCollectorSecret = false` above, and that is already a supported,
# merely degraded shape rather than a service with no way in at all. # merely degraded shape rather than a service with no way in at all. What
# IS asserted is narrower and lives in `assertions` below — see
# `hiveReaderIdentity`.
# #
# 🩸 The collector's OWN leaf, not `clientCertFile` — the hive's, which four # 🩸 The collector's OWN leaf, not `clientCertFile` — the hive's, which four
# units used to share. Bao matches a cert-auth role on the CN, so one leaf for # units used to share. Bao matches a cert-auth role on the CN, so one leaf for
@ -257,6 +259,16 @@ let
haveClientIdentity = haveClientIdentity =
baoDeploy.otelOidcClientCertFile != null && baoDeploy.otelOidcClientKeyFile != null; baoDeploy.otelOidcClientCertFile != null && baoDeploy.otelOidcClientKeyFile != null;
# Does this host read the store at all — the hive's own leaf, which is the
# one thing a remote-store deployment has always had to place by hand. It is
# what separates the degrade the comment above describes from the mistake the
# assertion below reports: a collector on a host holding NO store identity is
# the supported shape, and one on a host that demonstrably reads the store
# named seven of the eight options and stopped. Before the four-way split
# that second host rendered this unit off `clientCertFile`, so it is a silent
# regression rather than a choice anyone made.
hiveReaderIdentity = baoDeploy.clientCertFile != null && baoDeploy.clientKeyFile != null;
# Where the publisher on authelia's host leaves this client's secret — # Where the publisher on authelia's host leaves this client's secret —
# composed from the same swarm-wide `clientId` the registration in # composed from the same swarm-wide `clientId` the registration in
# ./glue-swarm-otel-oidc-client.nix uses, so a rename cannot leave one of # ./glue-swarm-otel-oidc-client.nix uses, so a rename cannot leave one of
@ -732,11 +744,14 @@ in
# value. # value.
# #
# ⚠️ Renders only where `haveClientIdentity` holds, unlike # ⚠️ Renders only where `haveClientIdentity` holds, unlike
# ./swarm-grafana.nix's equivalent unit. That module asserts the identity # ./swarm-grafana.nix's equivalent unit. That module refuses any host that
# because a Grafana with none has no way in at all; this collector without # runs Grafana without the identity, because a Grafana with none has no way
# one is `haveCollectorSecret = false` above — already a supported, # in at all; this collector without one is `haveCollectorSecret = false`
# merely degraded shape, so the unit that would fetch a credential simply # above — already a supported, merely degraded shape, so the unit that would
# does not exist rather than refusing the build for want of one. # fetch a credential simply does not exist rather than refusing the build
# for want of one. The one case that IS refused is the host that already
# holds the hive's leaf and is missing only this pair, which is a silent
# regression rather than that degrade — `hiveReaderIdentity` above.
systemd.services.swarm-bao-otel-oidc = lib.mkIf haveClientIdentity { systemd.services.swarm-bao-otel-oidc = lib.mkIf haveClientIdentity {
description = "fetch the swarm collector's OIDC client secret from the swarm secret store"; description = "fetch the swarm collector's OIDC client secret from the swarm secret store";
# Every one of these names a unit that exists only where the store runs. # Every one of these names a unit that exists only where the store runs.
@ -860,6 +875,45 @@ in
systemd.services."container@${cfg.machine}" = caTrust.containerOrdering; systemd.services."container@${cfg.machine}" = caTrust.containerOrdering;
assertions = [ assertions = [
{
# Shaped after ./swarm-grafana.nix's `haveClientIdentity` assertion —
# the same refusal, named to this principal's own pair. What differs is
# the `!hiveReaderIdentity ||` guard, and it is what keeps the degrade
# the flag's own comment describes intact: Grafana refuses any host
# that runs it without a leaf, because a Grafana with no SSO has no way
# in at all, while a collector with none still receives telemetry. So
# this fires only where the host already reads the store and is missing
# this one pair — which before the four-way split rendered the unit off
# `clientCertFile`, and now silently does not.
assertion = !hiveReaderIdentity || haveClientIdentity;
message = ''
This host reads the swarm secret store (services.hyperhive.deploy.bao.clientCertFile
is set) and runs the swarm collector, so it needs the collector's own
client identity: set both
services.hyperhive.deploy.bao.otelOidcClientCertFile
services.hyperhive.deploy.bao.otelOidcClientKeyFile
swarm-bao-otel-oidc.service fetches the collector's OIDC client
secret out of the store, and without these it is not rendered at all
— so this collector would authenticate to nothing, quietly, on a host
that has everything it needs to fetch the secret.
A collector on a host holding NO store identity is a different and
supported shape: it runs without a client secret and still receives
telemetry. That is not this host.
⚠️ The collector's OWN leaf, not deploy.bao.clientCertFile. That one
is the hive's, and its grant reads every secret in the store; this
role reads the one path this collector's client secret lives at.
Pointing this option at the hive's leaf would evaluate, deploy and
log in — and undo the split.
On a hive that runs the store, glue-bao-tls.nix supplies both as
defaults and there is nothing to do. Elsewhere the leaf is issued
from that CA out of band and named here — see docs/swarm/secrets.md.
'';
}
# ⚠️ An assertion that this collector has "somewhere to send" was REMOVED # ⚠️ An assertion that this collector has "somewhere to send" was REMOVED
# rather than relaxed: it read the store's *per-host* enable, so it # rather than relaxed: it read the store's *per-host* enable, so it
# rejected at eval the very deployment the stores are reached by domain # rejected at eval the very deployment the stores are reached by domain

View file

@ -53,6 +53,37 @@ let
# needs it, and defining it here rather than importing keeps each group's # needs it, and defining it here rather than importing keeps each group's
# fixture set its own, as ./lib.nix asks. # fixture set its own, as ./lib.nix asks.
matrixNoBaoIdentity = hive { deploy.matrix.enable = true; }; matrixNoBaoIdentity = hive { deploy.matrix.enable = true; };
# 🩸 The hand-configured remote reader that named seven of the eight options.
# `baoRemoteReader` above is the same deployment done right; this one holds
# the hive's own leaf, so it demonstrably reads the store, and is missing both
# per-principal pairs. Before the four-way split this host rendered both units
# off `clientCertFile` alone, so what it has now is a silent regression rather
# than any shape an operator chose — which is what the refusal arms below are
# about. Kept as one fixture rather than two because both refusals fire on it
# and each arm names which.
baoRemoteReaderMissingLeaves = hive {
deploy.matrix.enable = true;
deploy.bao.clientCertFile = "/etc/pki/bao-client.pem";
deploy.bao.clientKeyFile = "/etc/pki/bao-client-key.pem";
};
# The same omission on a hive that does NOT run a homeserver. Separates the
# matrix refusal's `deploy.matrix.enable` clause from the queue refusal, which
# has no toggle to check — an arm below reads exactly one refusal off it.
remoteReaderNoMatrixMissingLeaves = hive {
deploy.bao.clientCertFile = "/etc/pki/bao-client.pem";
deploy.bao.clientKeyFile = "/etc/pki/bao-client-key.pem";
};
# Did a module refuse this host, and over which option. An assertion is a
# config VALUE until something forces it — `.config` never throws — so a
# fixture in a state the module refuses stays evaluable and the refusal reads
# back as data. Matched on the option name the message names rather than on
# its prose, because the option name is the part an operator has to act on
# and a message that stopped naming it would be the actual defect. The same
# helper ./grafana.nix uses for `swarm-grafana.nix`'s own refusal.
refusedOver = m: option: lib.any (a: !a.assertion && lib.hasInfix option a.message) m.assertions;
cases = [ cases = [
{ {
# A login failure is the store being unreachable, sealed, or not yet # A login failure is the store being unreachable, sealed, or not yet
@ -373,6 +404,63 @@ let
in in
!(g ? admin_execute) || g.admin_execute == [ ]; !(g ? admin_execute) || g.admin_execute == [ ];
} }
{
# 🩸 The arm the whole refusal exists for. Both readers are gated on their
# own leaf, so the way this regresses is the build going green and three
# units rendering where four should — which no presence check on a
# rendered unit can see, because the unit that is missing is the evidence.
# Read as a refusal naming each option, so an operator acts on the message
# without opening the nix.
name = "a remote reader missing the per-principal leaves is refused, naming both options";
ok =
refusedOver baoRemoteReaderMissingLeaves "matrixTokenClientCertFile"
&& refusedOver baoRemoteReaderMissingLeaves "matrixTokenClientKeyFile"
&& refusedOver baoRemoteReaderMissingLeaves "queueAgentClientCertFile"
&& refusedOver baoRemoteReaderMissingLeaves "queueAgentClientKeyFile";
}
{
# The matrix refusal's own gate, which the queue refusal does not have.
# Without this arm the two are indistinguishable on the fixture above.
name = "the queue refusal needs no homeserver, and the matrix refusal stays quiet without one";
ok =
refusedOver remoteReaderNoMatrixMissingLeaves "queueAgentClientCertFile"
&& !(refusedOver remoteReaderNoMatrixMissingLeaves "matrixTokenClientCertFile");
}
{
# ⚠️ The arm that keeps the refusal from being worse than the silence it
# replaced. A hive with NO store identity is the supported no-store
# deployment and also the state an operator passes through bringing a hive
# up — neither may fail to evaluate. Asserted as "no refusal names any of
# the four options", not as "this one fixture is fine", because the way
# this breaks is a gate widened to the principal's leaf alone.
name = "a hive with no store identity at all is refused over none of the per-principal leaves";
ok = lib.all (option: !(refusedOver matrixNoBaoIdentity option)) [
"matrixTokenClientCertFile"
"matrixTokenClientKeyFile"
"queueAgentClientCertFile"
"queueAgentClientKeyFile"
];
}
{
# The other half of the same guard, and the one an operator meets far more
# often: on the store's own host ./host-modules/glue-bao-tls.nix mkDefaults
# all eight, so there is nothing to name and nothing to refuse. Paired with
# the fully-named remote reader, which is the same deployment done by hand.
name = "neither a store host nor a correctly-named remote reader is refused";
ok =
lib.all
(
m:
lib.all (option: !(refusedOver m option)) [
"matrixTokenClientCertFile"
"queueAgentClientCertFile"
]
)
[
baoWithMatrix
baoRemoteReader
];
}
]; ];
in in
runGroup "bao-matrix-reader" cases runGroup "bao-matrix-reader" cases

View file

@ -34,6 +34,40 @@ let
deploy.forgejo.sso.clientSecretFile = "/var/lib/forgejo-oidc/by-hand.secret"; deploy.forgejo.sso.clientSecretFile = "/var/lib/forgejo-oidc/by-hand.secret";
}; };
# 🩸 The shape the degrade above must not swallow: a collector on a host that
# demonstrably READS the store — it holds the hive's own leaf — and is missing
# only the collector's own pair. Before the four-way split this host rendered
# the reading unit off `clientCertFile` alone, so the silence it gets now is a
# regression rather than the supported degrade `otelNoIdentity` stands for. The
# two fixtures differ in exactly that one pair, which is what lets the arms
# below separate a refusal from a degrade.
otelReaderMissingLeaf = 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";
};
# The same deployment named in full, which must stay buildable. Pairs with the
# fixture above so the refusal is pinned to the omission and not to reading a
# remote store at all.
otelReaderNamedInFull = 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";
deploy.bao.otelOidcClientCertFile = "/etc/pki/bao-otel-oidc.pem";
deploy.bao.otelOidcClientKeyFile = "/etc/pki/bao-otel-oidc-key.pem";
};
# Did the module refuse this host, and over which option. An assertion is a
# config VALUE until something forces it — `.config` never throws — so a
# fixture in a refused state stays evaluable and the refusal reads back as
# data. Matched on the option name the message names rather than on its prose:
# the option name is the part an operator has to act on, and a message that
# stopped naming it would be the actual defect.
refusedOver = m: option: lib.any (a: !a.assertion && lib.hasInfix option a.message) m.assertions;
# authelia somewhere else, the credential delivered by hand. Whether this # authelia somewhere else, the credential delivered by hand. Whether this
# collector authenticates must follow the credential, never another # collector authenticates must follow the credential, never another
# service's placement. # service's placement.
@ -162,6 +196,38 @@ let
&& !(builtins.elem "otlphttp/victoriametrics" used) && !(builtins.elem "otlphttp/victoriametrics" used)
&& lib.all (e: s.exporters ? ${e}) used; && lib.all (e: s.exporters ? ${e}) used;
} }
{
# 🩸 The refusal, and the only way this module's omission is visible at
# all: the reading unit is gated on its own leaf, so the regression is a
# green build with the unit absent — and the missing unit is the evidence.
# Read as a refusal naming both options, so an operator acts on the
# message without opening the nix.
name = "a store reader missing the collector's own leaf is refused, naming both options";
ok =
refusedOver otelReaderMissingLeaf "otelOidcClientCertFile"
&& refusedOver otelReaderMissingLeaf "otelOidcClientKeyFile";
}
{
# ⚠️ The arm that keeps the refusal from eating the degrade beside it. A
# collector on a host with NO store identity still receives every hive's
# telemetry and is a supported deployment — `otelNoIdentity` is that
# fixture, and it differs from the refused one only in the hive's own
# leaf. Widening the gate to the collector's leaf alone would reject it,
# which is what this pins.
name = "a collector with no store identity at all is not refused over the collector's leaf";
ok =
!(refusedOver otelNoIdentity "otelOidcClientCertFile")
&& !(refusedOver otelNoIdentity "otelOidcClientKeyFile");
}
{
# The other two shapes that must stay buildable: the same remote reader
# named in full, and a host that mints its own PKI and therefore has all
# eight as defaults from ./host-modules/glue-bao-tls.nix.
name = "neither a fully-named remote reader nor a store host is refused";
ok =
!(refusedOver otelReaderNamedInFull "otelOidcClientCertFile")
&& !(refusedOver otelRemoteAuthelia "otelOidcClientCertFile");
}
]; ];
in in
runGroup "swarm-otel-identity" cases runGroup "swarm-otel-identity" cases