Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb0afcd256 | ||
|
|
d4313fc34d | ||
|
|
ca8fc4ca64 | ||
|
|
e5224a6725 |
8 changed files with 1057 additions and 41 deletions
|
|
@ -64,7 +64,8 @@ because nothing outside that container ever reads them. **That's the test worth
|
||||||
applying to any secret added here** — and the client secret's plaintext half is
|
applying to any secret added here** — and the client secret's plaintext half is
|
||||||
the one row that fails it, which is the entire reason a delivery step exists.
|
the one row that fails it, which is the entire reason a delivery step exists.
|
||||||
|
|
||||||
Two telemetry collectors exist, and they land on opposite sides of that test.
|
Three telemetry collectors exist, and they don't land on the same side of
|
||||||
|
that test.
|
||||||
|
|
||||||
The **hive's** collector needs no delivery step. It authenticates to the swarm's
|
The **hive's** collector needs no delivery step. It authenticates to the swarm's
|
||||||
collector as its own hive, and it's a host unit rather than a container, so on
|
collector as its own hive, and it's a host unit rather than a container, so on
|
||||||
|
|
@ -94,6 +95,27 @@ uses for its non-optional one. The operator can still deliver the secret by
|
||||||
hand, at `services.hyperhive.deploy.swarm-otel.clientSecretFile`, on a
|
hand, at `services.hyperhive.deploy.swarm-otel.clientSecretFile`, on a
|
||||||
collector this unit never reaches.
|
collector this unit never reaches.
|
||||||
|
|
||||||
|
The **secret store's own** collector — the forwarder inside the `swarm-bao`
|
||||||
|
container — needs a delivery step too, and it takes the same route with one
|
||||||
|
principal of its own: `swarm-bao-forwarder-oidc.service` reads
|
||||||
|
`swarm/services/<client-id>/oidc/client` under this host's certificate and
|
||||||
|
lands it in the container's tree, where `LoadCredential` hands it to the
|
||||||
|
collector. The client id is its own
|
||||||
|
(`services.hyperhive.swarm.bao.otel.clientId`), registered by
|
||||||
|
`glue-swarm-bao-otel-oidc-client.nix` wherever authelia runs, and the swarm
|
||||||
|
collector admits it on a receiver of its own — one identity per principal, so
|
||||||
|
this forwarder borrows neither the swarm collector's client nor
|
||||||
|
`swarm-controller`'s.
|
||||||
|
|
||||||
|
⚠️ **This one has no degraded mode, and the difference is structural.** The
|
||||||
|
route it exports over accepts an authenticated request and nothing else, so a
|
||||||
|
forwarder without the secret has nothing to fall back to: the unit fails and
|
||||||
|
retries, and the collector refuses to start until the file arrives rather than
|
||||||
|
exporting into a 401 while reporting healthy. Its ordering differs from the
|
||||||
|
unit above it for the same structural reason — it reads the store inside the
|
||||||
|
container it delivers into, so it runs **after** that container rather than
|
||||||
|
before it, and only a first boot ever waits.
|
||||||
|
|
||||||
### Minting the queue's callout nkeys
|
### Minting the queue's callout nkeys
|
||||||
|
|
||||||
`deploy.nats.autoGenerateCallout` mints both keypairs on the host before the queue
|
`deploy.nats.autoGenerateCallout` mints both keypairs on the host before the queue
|
||||||
|
|
@ -254,6 +276,16 @@ doesn't render the reading unit without them, the shape
|
||||||
`glue-matrix-bao-token.nix` and `glue-queue-agent-credential.nix` use for
|
`glue-matrix-bao-token.nix` and `glue-queue-agent-credential.nix` use for
|
||||||
their own optional reads.
|
their own optional reads.
|
||||||
|
|
||||||
|
The **fifth reader** is the secret store forwarder's OIDC client secret:
|
||||||
|
`swarm-bao-forwarder-oidc.service`, on the store's own host, registered by
|
||||||
|
`glue-swarm-bao-otel-oidc-client.nix` on authelia's. It differs from the
|
||||||
|
fourth in both directions. It renders unconditionally, because the export it
|
||||||
|
authenticates has no unauthenticated mode to degrade into — and it orders
|
||||||
|
itself **after** `container@swarm-bao`, because the store it reads runs in the
|
||||||
|
container it delivers into. Nothing circular sits behind that: the identity it
|
||||||
|
logs in with is this host's static `swarm-bao-pki` leaf, not anything the store
|
||||||
|
mints.
|
||||||
|
|
||||||
A service's secret is one value for the whole swarm rather than one per hive, so
|
A service's secret is one value for the whole swarm rather than one per hive, so
|
||||||
it lives under the `services` prefix, and a hive's read policy grants that prefix
|
it lives under the `services` prefix, and a hive's read policy grants that prefix
|
||||||
whole. That's because nothing in a swarm's configuration records which hive runs
|
whole. That's because nothing in a swarm's configuration records which hive runs
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@
|
||||||
./glue-matrix-ctl-bao-identity.nix
|
./glue-matrix-ctl-bao-identity.nix
|
||||||
./glue-queue-agent-credential.nix
|
./glue-queue-agent-credential.nix
|
||||||
./glue-secret-publisher-bao-identity.nix
|
./glue-secret-publisher-bao-identity.nix
|
||||||
|
./glue-swarm-bao-otel-oidc-client.nix
|
||||||
./glue-swarm-otel-oidc-client.nix
|
./glue-swarm-otel-oidc-client.nix
|
||||||
./swarm-authelia.nix
|
./swarm-authelia.nix
|
||||||
./swarm-bao.nix
|
./swarm-bao.nix
|
||||||
|
|
|
||||||
56
nix/host-modules/glue-swarm-bao-otel-oidc-client.nix
Normal file
56
nix/host-modules/glue-swarm-bao-otel-oidc-client.nix
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
# Glue: register the secret store's own journal forwarder as an OIDC client
|
||||||
|
# wherever authelia runs.
|
||||||
|
#
|
||||||
|
# ONE PAIRING PER FILE — swarm-bao's forwarder ← authelia, and nothing else.
|
||||||
|
# Deleting this leaves a forwarder authelia has never heard of: the token
|
||||||
|
# endpoint refuses it, nothing is minted, the publisher has nothing to copy
|
||||||
|
# into the store, and the collector's export is a 401 nobody asked for.
|
||||||
|
#
|
||||||
|
# ⚠️ A SECOND client beside ./glue-swarm-otel-oidc-client.nix's, not a reuse
|
||||||
|
# of it. That one is the swarm collector's identity for what IT pushes; this
|
||||||
|
# one belongs to the collector inside the store's container, which is a
|
||||||
|
# different principal on a different host — one identity per principal, the
|
||||||
|
# rule `swarm-controller.nix` states over its own `queueClientId`.
|
||||||
|
#
|
||||||
|
# ⚠️ Gated on authelia being HERE, and deliberately NOT on this host running
|
||||||
|
# the store. A client is a row in THIS host's provider config, so it can only
|
||||||
|
# be declared where that config is rendered, and ./swarm-bao.nix's `config`
|
||||||
|
# hangs off `deploy.bao.enable` — the split ./glue-grafana-oidc-client.nix and
|
||||||
|
# ./glue-swarm-otel-oidc-client.nix each made for the same reason. Read either
|
||||||
|
# file's own comment for the property this one shares with them.
|
||||||
|
{
|
||||||
|
lib,
|
||||||
|
config,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
let
|
||||||
|
hyperhiveCfg = config.services.hyperhive;
|
||||||
|
deployCfg = hyperhiveCfg.deploy;
|
||||||
|
baoCfg = hyperhiveCfg.swarm.bao;
|
||||||
|
in
|
||||||
|
{
|
||||||
|
config = lib.mkIf (hyperhiveCfg.enable && deployCfg.authelia.enable) {
|
||||||
|
# One declaration, two readers: `clientId` is a read-only option
|
||||||
|
# ./swarm-bao.nix owns, and ./swarm-otel.nix builds this principal's
|
||||||
|
# authenticator audience from the same option.
|
||||||
|
services.hyperhive.swarm.authelia.oidc.clients = [
|
||||||
|
{
|
||||||
|
id = baoCfg.otel.clientId;
|
||||||
|
description = "HyperHive secret store journal forwarder";
|
||||||
|
kind = "machine";
|
||||||
|
redirectUris = [ ];
|
||||||
|
# Its own id as its own permitted audience — the self-referential
|
||||||
|
# form `swarm-authelia.nix`'s `hiveClients` uses, which is the shape
|
||||||
|
# to copy here: this forwarder's token lands on an `oidc/*` receiver
|
||||||
|
# of the swarm collector, exactly as a hive's does.
|
||||||
|
audience = [ baoCfg.otel.clientId ];
|
||||||
|
# ⚠️ What makes the token READABLE by that receiver at all. Authelia's
|
||||||
|
# default is an opaque handle, and an `oidc` extension verifies
|
||||||
|
# offline against `/jwks.json` — so without this the export fails with
|
||||||
|
# a message about the verifier rather than about the token. Same
|
||||||
|
# value, same reason, as every hive's client.
|
||||||
|
accessTokenSignedResponseAlg = "RS256";
|
||||||
|
}
|
||||||
|
];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -429,19 +429,23 @@ let
|
||||||
// metricsListener;
|
// metricsListener;
|
||||||
|
|
||||||
# Metrics get their own listener rather than a flag on the one above, and
|
# Metrics get their own listener rather than a flag on the one above, and
|
||||||
# that follows from what a scraper can express: `swarm.otel.scrapeTargets`
|
# that follows from what a scraper can express: a prometheus scrape config
|
||||||
# carries no scheme and no credential, while the API listener is TLS and —
|
# carries no client certificate, while the API listener is TLS and — once a
|
||||||
# once a client CA is set — demands a client certificate. The collector
|
# client CA is set — demands one. A scraper cannot reach it at all.
|
||||||
# cannot reach it at all.
|
|
||||||
#
|
#
|
||||||
# `metrics_only` narrows this one to the metrics path (every other path 404s)
|
# `metrics_only` narrows this one to the metrics path (every other path 404s)
|
||||||
# and the unauthenticated access is confined to loopback. **Deliberately
|
# and the unauthenticated access is confined to loopback. **Deliberately
|
||||||
# unauthenticated for now** — a tracked follow-up owns giving the collector a
|
# unauthenticated** — the only process that can dial 127.0.0.1 here is one
|
||||||
# credential, since no scrape option can carry one today.
|
# already inside this netns, which is the container's own collector and the
|
||||||
|
# store itself. Widening this address, or dropping
|
||||||
|
# `tls_require_and_verify_client_cert` on the API listener to scrape THAT
|
||||||
|
# instead, would each trade that property away for nothing.
|
||||||
#
|
#
|
||||||
# Exists only where a collector does: an endpoint with no reader is exposure
|
# Unconditional, and that is the change this carries: the reader is now the
|
||||||
# bought for nothing.
|
# collector in this very container (below), which renders wherever the store
|
||||||
metricsListener = lib.optionalAttrs scrapeHere {
|
# does. There is no longer a host where this endpoint has no reader, so there
|
||||||
|
# is nothing left for it to be a function of.
|
||||||
|
metricsListener = {
|
||||||
metrics = {
|
metrics = {
|
||||||
type = "tcp";
|
type = "tcp";
|
||||||
address = "127.0.0.1:${toString baoDeploy.metricsPort}";
|
address = "127.0.0.1:${toString baoDeploy.metricsPort}";
|
||||||
|
|
@ -453,12 +457,99 @@ let
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
scrapeHere = deployCfg.swarm-otel.enable;
|
# What the container's own collector dials. Loopback inside a netns this
|
||||||
|
# container SHARES with the host (`privateNetwork = false` below), so the
|
||||||
|
# scrape crosses no network and needs no bind address wider than the one
|
||||||
|
# above.
|
||||||
|
metricsScrapeTarget = "127.0.0.1:${toString baoDeploy.metricsPort}";
|
||||||
|
|
||||||
|
# ⚠️ Neither the listener above nor the forwarder below has a condition of
|
||||||
|
# its own, and they lost theirs for the same reason at different times.
|
||||||
|
#
|
||||||
|
# The listener used to be gated on `scrapeHere = deployCfg.swarm-otel.enable`
|
||||||
|
# — "does the swarm's collector happen to run on THIS host" — because the
|
||||||
|
# thing reading it was that collector, over loopback, which only works when
|
||||||
|
# the two land together. mara's ruling (*"move the existing scraper
|
||||||
|
# to the local collector"*) removes the premise rather than the symptom: the
|
||||||
|
# scraper is now in this container, so the reader is wherever the store is,
|
||||||
|
# and the metrics path stops being silently dead on every host that does not
|
||||||
|
# also run swarm-otel.
|
||||||
|
#
|
||||||
|
# The forwarder never had a defensible one. "Does the swarm have a collector"
|
||||||
|
# is not a question that can be false (mara: "the swarm always has at least
|
||||||
|
# one instance of all the swarm level services"), so both earlier gates —
|
||||||
|
# `otel.enable`, then `deploy.swarm-otel.enable` — asked about the wrong host
|
||||||
|
# and rendered no forwarder wherever the answer happened to be no.
|
||||||
|
#
|
||||||
|
# The only condition that belongs on either is the one already enclosing this
|
||||||
|
# block: the store is deployed on this host.
|
||||||
|
|
||||||
|
# The swarm collector's own name, served by the gateway — the address
|
||||||
|
# `swarm.otel.domain` exists to be, resolved by dnsmasq on a co-located host
|
||||||
|
# and over the real network otherwise, with no loopback-vs-remote knob to get
|
||||||
|
# wrong. The path selects WHICH receiver: this container is a swarm-level
|
||||||
|
# producer and not a hive, so it is the swarm tier's own route, the same one
|
||||||
|
# ./swarm-controller.nix exports to. `/` on that vhost answers 404.
|
||||||
|
#
|
||||||
|
# The port suffix follows ./hive-forge/default.nix: elided on the canonical
|
||||||
|
# 443 so this renders byte for byte like the tier's other two consumers of
|
||||||
|
# this name, present when an operator moved the gateway's TLS port.
|
||||||
|
otelFirstHop = "https://${swarmOtelCfg.domain}${gatewayPortSuffix}/${swarmOtelCfg.storeProducerName}";
|
||||||
|
|
||||||
|
# The forwarder's own credential, and the three names it takes on the way
|
||||||
|
# in. The shape is ./swarm-otel.nix's `swarm-bao-otel-oidc` — the unit that
|
||||||
|
# already reads an OIDC client secret out of the store with this host's
|
||||||
|
# certificate and lands it in a collector's container — because that route
|
||||||
|
# is proven and there is no second one worth inventing.
|
||||||
|
#
|
||||||
|
# Where the publisher on authelia's host leaves it. The `services` segment
|
||||||
|
# is `swarm-secret-client`'s `path::Kind::Service`: a swarm service's client
|
||||||
|
# is registered once for the whole swarm, so its secret is one value. The
|
||||||
|
# prefix is also exactly what a hive certificate's read grant covers, so a
|
||||||
|
# path outside it answers 403 however correct it looks.
|
||||||
|
forwarderStoreSecretPath = "secret/swarm/services/${cfg.otel.clientId}/oidc/client";
|
||||||
|
# At rest in the container's tree, written by the host unit below — under
|
||||||
|
# /var/lib rather than /run, because a secret that evaporates on reboot
|
||||||
|
# turns a working export into an intermittent one.
|
||||||
|
forwarderSecretInContainer = "/var/lib/swarm-bao-otel-oidc/${cfg.otel.clientId}.secret";
|
||||||
|
forwarderHostSecretPath = "/var/lib/nixos-containers/${cfg.machine}${forwarderSecretInContainer}";
|
||||||
|
forwarderHostSecretDir = builtins.dirOf forwarderHostSecretPath;
|
||||||
|
# The collector runs under `DynamicUser` and opens `client_secret_file`
|
||||||
|
# itself, so there is no uid to hand a 0400 file to. `LoadCredential` reads
|
||||||
|
# it as root before the sandbox exists and re-exposes it under a path that
|
||||||
|
# does not depend on which uid the unit got; the collector expands
|
||||||
|
# `${env:CREDENTIALS_DIRECTORY}` at load, so this id is the only spelling.
|
||||||
|
forwarderCredentialId = "oidc-client-secret";
|
||||||
|
# Named once and read three times — the extension that defines it, the
|
||||||
|
# exporter that names it, and `service.extensions`, where an omission makes
|
||||||
|
# it inert rather than an error.
|
||||||
|
forwarderAuthName = "oauth2client/${swarmOtelCfg.storeProducerName}";
|
||||||
|
autheliaCfg = hyperhiveCfg.swarm.authelia;
|
||||||
|
|
||||||
|
# Outbound TLS for that collector: it dials the swarm collector's gateway
|
||||||
|
# name and authelia's token endpoint, both leaves of the runtime-generated
|
||||||
|
# hive CA, which cannot be baked into a derivation. Same helper, same two
|
||||||
|
# halves and the same `SSL_CERT_FILE` consumption as ./swarm-otel.nix's own
|
||||||
|
# container — a Go process whose trust variable REPLACES the store, which is
|
||||||
|
# what the bundle (system CAs ++ our anchors) is for.
|
||||||
|
caTrust = import ./lib/hive-ca-trust.nix {
|
||||||
|
inherit lib;
|
||||||
|
tlsCfg = deployCfg.hive-controller.tls;
|
||||||
|
gatewayCfg = hyperhiveCfg.gateway;
|
||||||
|
};
|
||||||
|
|
||||||
|
swarmOtelCfg = hyperhiveCfg.swarm.otel;
|
||||||
|
gatewayPortSuffix =
|
||||||
|
let
|
||||||
|
p = hyperhiveCfg.gateway.httpsPort;
|
||||||
|
in
|
||||||
|
lib.optionalString (p != 443) ":${toString p}";
|
||||||
|
|
||||||
# Non-zero is what SERVES the endpoint at all — the switch is a duration, not
|
# Non-zero is what SERVES the endpoint at all — the switch is a duration, not
|
||||||
# a boolean, so a zero here is an openbao that answers 404 on a listener
|
# a boolean, so a zero here is an openbao that answers 404 on a listener
|
||||||
# configured to do nothing else.
|
# configured to do nothing else. Unconditional for the same reason the
|
||||||
telemetry = lib.optionalAttrs scrapeHere {
|
# listener above is: its reader ships with the store now.
|
||||||
|
telemetry = {
|
||||||
telemetry = {
|
telemetry = {
|
||||||
prometheus_retention_time = "24h";
|
prometheus_retention_time = "24h";
|
||||||
disable_hostname = true;
|
disable_hostname = true;
|
||||||
|
|
@ -852,6 +943,29 @@ in
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
otel.clientId = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
readOnly = true;
|
||||||
|
default = "swarm-bao-collector";
|
||||||
|
description = ''
|
||||||
|
OAuth2 client id the collector inside the store's container
|
||||||
|
authenticates as, and — self-referentially, the shape every hive's
|
||||||
|
client already uses — the audience it asks its token for.
|
||||||
|
|
||||||
|
**Its own, not the swarm collector's and not
|
||||||
|
`swarm-controller`'s.** One identity per principal: this forwarder
|
||||||
|
runs wherever the store runs, which is not where either of those
|
||||||
|
two runs, and the receiver it pushes to
|
||||||
|
(`swarm.otel.storeProducerName`) admits this id alone.
|
||||||
|
|
||||||
|
Swarm-wide and read-only because three hosts have to agree on it:
|
||||||
|
authelia registers the client
|
||||||
|
(`glue-swarm-bao-otel-oidc-client.nix`), the swarm collector checks
|
||||||
|
the audience (`swarm-otel.nix`), and the store's host reads the
|
||||||
|
minted secret back out of the store under a path composed from it.
|
||||||
|
Two spellings present as a healthy-looking 401.
|
||||||
|
'';
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
# ⚠️ Gated on `deploy.bao.enable`, and that is load-bearing rather than
|
# ⚠️ Gated on `deploy.bao.enable`, and that is load-bearing rather than
|
||||||
|
|
@ -940,15 +1054,21 @@ in
|
||||||
"openbao"
|
"openbao"
|
||||||
"swarm-bao-certs"
|
"swarm-bao-certs"
|
||||||
"swarm-bao-token"
|
"swarm-bao-token"
|
||||||
|
"swarm-bao-forwarder-oidc"
|
||||||
];
|
];
|
||||||
|
|
||||||
services.hyperhive.swarm.otel.scrapeTargets = lib.mkIf scrapeHere {
|
# 🚫 No `swarm.otel.scrapeTargets.bao` entry any more, and its absence is
|
||||||
# Path and query, not just `host:port`: openbao serves no `/metrics`
|
# the deliverable rather than a tidy-up. That option is read only by the
|
||||||
# at all, and `/v1/sys/metrics` answers JSON unless the format is
|
# SWARM collector, over loopback, so declaring the store there meant its
|
||||||
# asked for. A scrape of the default path 404s, which reads as a
|
# metrics existed exactly on the hosts that also ran swarm-otel and
|
||||||
# dead exporter rather than a wrong address.
|
# nowhere else — silently, since a store with no entry looks identical to
|
||||||
bao = "127.0.0.1:${toString baoDeploy.metricsPort}/v1/sys/metrics?format=prometheus";
|
# one nothing scrapes. The scrape moved into this container's own
|
||||||
};
|
# collector (see `receivers.prometheus` below), which travels with the
|
||||||
|
# store.
|
||||||
|
#
|
||||||
|
# Logs are untouched by that move: `journaldUnits` above still names this
|
||||||
|
# store's units for the shared collector, and the two are retired
|
||||||
|
# together once every sibling swarm container has a collector of its own.
|
||||||
|
|
||||||
# ⚠️ The one nginx exception to this file's header, and it is one because
|
# ⚠️ The one nginx exception to this file's header, and it is one because
|
||||||
# it never terminates. `ssl_preread` reads the SNI off the ClientHello
|
# it never terminates. `ssl_preread` reads the SNI off the ClientHello
|
||||||
|
|
@ -982,6 +1102,113 @@ in
|
||||||
# still proves nothing — bao answers nothing without a client
|
# still proves nothing — bao answers nothing without a client
|
||||||
# certificate its CA signed.
|
# certificate its CA signed.
|
||||||
services.hyperhive.network.exposeHostPorts = [ cfg.port ];
|
services.hyperhive.network.exposeHostPorts = [ cfg.port ];
|
||||||
|
|
||||||
|
# THE delivery unit for the forwarder's own OIDC client secret, copied
|
||||||
|
# in shape from ./swarm-otel.nix's `swarm-bao-otel-oidc`: a cert login
|
||||||
|
# that fails LOUDLY, since every state it fails on is one a retry fixes,
|
||||||
|
# then a read, then a root-owned file in the container's tree that
|
||||||
|
# `LoadCredential` re-exposes to the collector's dynamic user.
|
||||||
|
#
|
||||||
|
# ⚠️ ORDERED AFTER the store's container and deliberately NOT before it,
|
||||||
|
# which is the one line that differs from the unit it copies. That one
|
||||||
|
# reads a store in the container NEXT DOOR; this one reads the store in
|
||||||
|
# the very container it is delivering into, so "before" is a wait on a
|
||||||
|
# process that cannot start until this finishes. There is no bootstrap
|
||||||
|
# cycle behind it — the identity used here is this host's static
|
||||||
|
# `swarm-bao-pki` certificate, not anything the store mints — only an
|
||||||
|
# ordering one, and the cost is bounded: the file is under /var/lib, so
|
||||||
|
# it survives reboots and only a FIRST boot has the collector starting
|
||||||
|
# before it exists. `LoadCredential` refuses to start a unit whose
|
||||||
|
# source is missing, so that boot is a collector that restarts, says so
|
||||||
|
# each time, and comes up the moment this lands. Loud and self-healing
|
||||||
|
# rather than silently exporting without a credential.
|
||||||
|
systemd.services.swarm-bao-forwarder-oidc = {
|
||||||
|
description = "fetch the secret store forwarder's OIDC client secret from the store";
|
||||||
|
after = [
|
||||||
|
"swarm-bao-pki.service"
|
||||||
|
"container@${cfg.machine}.service"
|
||||||
|
];
|
||||||
|
wants = [ "container@${cfg.machine}.service" ];
|
||||||
|
requires = [ "swarm-bao-pki.service" ];
|
||||||
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
path = [
|
||||||
|
baoDeploy.package
|
||||||
|
pkgs.coreutils
|
||||||
|
];
|
||||||
|
# Sized for the race this loses: the secret is minted by authelia and
|
||||||
|
# copied in by the publisher, both of which may be a host away and
|
||||||
|
# neither of which this boot waits on. `StartLimit*` are `[Unit]`
|
||||||
|
# settings — systemd ignores them under `[Service]` — and the window
|
||||||
|
# has to exceed `RestartSec × burst`.
|
||||||
|
startLimitBurst = 4;
|
||||||
|
startLimitIntervalSec = 300;
|
||||||
|
serviceConfig = {
|
||||||
|
Type = "oneshot";
|
||||||
|
RemainAfterExit = true;
|
||||||
|
SyslogIdentifier = "swarm-bao-forwarder-oidc";
|
||||||
|
TimeoutStartSec = 30;
|
||||||
|
Restart = "on-failure";
|
||||||
|
RestartSec = 15;
|
||||||
|
};
|
||||||
|
environment = {
|
||||||
|
BAO_ADDR = "https://${cfg.domain}:${toString cfg.port}";
|
||||||
|
BAO_CLIENT_CERT = baoDeploy.clientCertFile;
|
||||||
|
BAO_CLIENT_KEY = baoDeploy.clientKeyFile;
|
||||||
|
}
|
||||||
|
# Absent means the system trust store, which is what a deployment with
|
||||||
|
# a real CA wants and what a self-signed one must not be left with.
|
||||||
|
// lib.optionalAttrs (baoDeploy.serverCaFile != null) {
|
||||||
|
BAO_CACERT = baoDeploy.serverCaFile;
|
||||||
|
};
|
||||||
|
script = ''
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# `bao`'s own message is the only thing separating a missing value
|
||||||
|
# from a refused identity from an unreachable store, and this unit
|
||||||
|
# retries on all three — so it reports which one rather than
|
||||||
|
# asserting all three in a sentence of ours.
|
||||||
|
err="$(mktemp)"
|
||||||
|
trap 'rm -f "$err"' EXIT
|
||||||
|
|
||||||
|
# Cert auth is a login, not a transport setting: the `BAO_CLIENT_*`
|
||||||
|
# variables only decide which certificate the handshake presents.
|
||||||
|
# Without a token `bao` asks its token helper, and that is a `sh`
|
||||||
|
# this unit's `path` does not carry — `-token-only` answers on
|
||||||
|
# stdout and skips the helper on both sides.
|
||||||
|
if ! BAO_TOKEN="$(bao login -method=cert -token-only 2>"$err")"; then
|
||||||
|
echo "could not log in to the swarm secret store with this host's certificate." >&2
|
||||||
|
cat "$err" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
export BAO_TOKEN
|
||||||
|
|
||||||
|
# ⚠️ Non-zero, where the sibling unit one module over exits 0 on this
|
||||||
|
# branch. That collector treats a missing secret as a supported
|
||||||
|
# degrade; this one has no such mode — the receiver it pushes to
|
||||||
|
# takes an authenticated request and nothing else — so "not
|
||||||
|
# published yet" is a state to retry out of, not to settle into.
|
||||||
|
if ! secret="$(bao kv get -field=value ${lib.escapeShellArg forwarderStoreSecretPath} 2>"$err")"; then
|
||||||
|
echo "the store did not return ${forwarderStoreSecretPath}: the forwarder has no client secret yet." >&2
|
||||||
|
cat "$err" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$secret" ]; then
|
||||||
|
echo "the store returned an empty ${forwarderStoreSecretPath}." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# root-owned 0400, written with a shell builtin and never handed to
|
||||||
|
# a program: `printf` is bash's own, so the plaintext never becomes
|
||||||
|
# an argument in /proc the way `install <<<"$secret"` or an `echo`
|
||||||
|
# from `path` would.
|
||||||
|
install -d -m 0755 ${lib.escapeShellArg forwarderHostSecretDir}
|
||||||
|
umask 077
|
||||||
|
printf '%s\n' "$secret" > ${lib.escapeShellArg forwarderHostSecretPath}
|
||||||
|
chown root:root ${lib.escapeShellArg forwarderHostSecretPath}
|
||||||
|
chmod 0400 ${lib.escapeShellArg forwarderHostSecretPath}
|
||||||
|
'';
|
||||||
|
};
|
||||||
})
|
})
|
||||||
|
|
||||||
(lib.mkIf (hyperhiveCfg.enable && deployCfg.bao.enable && haveServerTls) {
|
(lib.mkIf (hyperhiveCfg.enable && deployCfg.bao.enable && haveServerTls) {
|
||||||
|
|
@ -1366,6 +1593,11 @@ in
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
# The CA bind source is written at runtime by a host unit, so the
|
||||||
|
# container has to start after it — otherwise nspawn sets up a mount
|
||||||
|
# over a file that does not exist yet.
|
||||||
|
systemd.services."container@${cfg.machine}" = caTrust.containerOrdering;
|
||||||
|
|
||||||
containers.${cfg.machine} = {
|
containers.${cfg.machine} = {
|
||||||
autoStart = true;
|
autoStart = true;
|
||||||
ephemeral = false;
|
ephemeral = false;
|
||||||
|
|
@ -1415,7 +1647,11 @@ in
|
||||||
hostPath = bootstrapTokenDir;
|
hostPath = bootstrapTokenDir;
|
||||||
isReadOnly = true;
|
isReadOnly = true;
|
||||||
};
|
};
|
||||||
};
|
}
|
||||||
|
# The hive CA, for the forwarder's two outbound calls: the swarm
|
||||||
|
# collector's gateway name and authelia's token endpoint. Empty when
|
||||||
|
# the gateway is not self-signed, so the whole path drops out.
|
||||||
|
// caTrust.bindMount;
|
||||||
|
|
||||||
# The seal talks to the TPM through the kernel's resource manager, so
|
# The seal talks to the TPM through the kernel's resource manager, so
|
||||||
# the device has to cross the container boundary or the store cannot
|
# the device has to cross the container boundary or the store cannot
|
||||||
|
|
@ -1428,12 +1664,37 @@ in
|
||||||
];
|
];
|
||||||
|
|
||||||
config =
|
config =
|
||||||
{ ... }:
|
# `config` is the CONTAINER's, shadowing the host's inside this
|
||||||
|
# function — nothing in here reads the host's, and the journal
|
||||||
|
# assertion below has to read the setting on this side of the
|
||||||
|
# boundary. `lib` and `pkgs` still come from the enclosing scope, as
|
||||||
|
# every other expression in this block already does.
|
||||||
|
{ config, ... }:
|
||||||
{
|
{
|
||||||
imports = [
|
imports = [
|
||||||
(import ./swarm-container-resolver.nix {
|
(import ./swarm-container-resolver.nix {
|
||||||
inherit (networkCfg) bridgeIp;
|
inherit (networkCfg) bridgeIp;
|
||||||
dnsConsumers = [ "openbao.service" ];
|
# The forwarder resolves two swarm names of its own — the
|
||||||
|
# collector it exports to and the identity provider it mints
|
||||||
|
# a token at — so it belongs in this list for the reason the
|
||||||
|
# module states: an unordered resolver write is a cold-boot
|
||||||
|
# race that shows up arbitrarily far from its cause.
|
||||||
|
dnsConsumers = [
|
||||||
|
"openbao.service"
|
||||||
|
"opentelemetry-collector.service"
|
||||||
|
];
|
||||||
|
})
|
||||||
|
]
|
||||||
|
# `SSL_CERT_FILE` REPLACES the trust store rather than adding to
|
||||||
|
# it, so a failed assembly yields an empty pool and every TLS call
|
||||||
|
# fails while the unit looks healthy. That is why this is the
|
||||||
|
# shared helper — it carries the `Requires` and the non-empty
|
||||||
|
# check — and not a local `cat`.
|
||||||
|
++ [
|
||||||
|
(caTrust.trustBundle {
|
||||||
|
inherit pkgs;
|
||||||
|
name = cfg.machine;
|
||||||
|
consumers = [ "opentelemetry-collector" ];
|
||||||
})
|
})
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -1586,6 +1847,220 @@ in
|
||||||
# Restarting is an operator action with an unseal on the far side of
|
# Restarting is an operator action with an unseal on the far side of
|
||||||
# it, which is why nothing here tries to be clever about it.
|
# it, which is why nothing here tries to be clever about it.
|
||||||
|
|
||||||
|
# This container's own journal forwarder, copied from an agent
|
||||||
|
# container's (nix/agent-modules/otel.nix) because every container
|
||||||
|
# is supposed to ship its own logs to the next hop rather than
|
||||||
|
# leave a *different* container's collector to find them.
|
||||||
|
#
|
||||||
|
# That older shape is what `--link-journal=host` above and the
|
||||||
|
# `swarm.otel.journaldUnits` entry near the top of this file serve:
|
||||||
|
# the journal lands in the host tree, where the swarm collector —
|
||||||
|
# itself a container — reads it through a unit allowlist. Both stay
|
||||||
|
# for now, because every sibling swarm container still rides that
|
||||||
|
# collector; they come out once each of them has one of these.
|
||||||
|
#
|
||||||
|
# ⚠️ NO `units` allowlist here, and that is the point rather than a
|
||||||
|
# simplification. A shared collector needs one because the journal
|
||||||
|
# it reads holds six containers' units plus the host's own; this
|
||||||
|
# one reads only openbao and the two oneshots beside it, so there
|
||||||
|
# is nothing foreign to separate out — and a list of unit names is
|
||||||
|
# a thing to get wrong, which ships nothing while looking healthy.
|
||||||
|
assertions = [
|
||||||
|
{
|
||||||
|
# Sibling of the agent forwarder's identical assertion, and it
|
||||||
|
# exists because the failure is silent at every layer: with a
|
||||||
|
# volatile journal the receiver below finds an empty directory,
|
||||||
|
# reads nothing, and the collector starts clean and stays
|
||||||
|
# healthy forever.
|
||||||
|
assertion =
|
||||||
|
!(lib.elem config.services.journald.storage [
|
||||||
|
"volatile"
|
||||||
|
"none"
|
||||||
|
]);
|
||||||
|
message = ''
|
||||||
|
${cfg.machine} forwards its own journal to the swarm's
|
||||||
|
collector — but services.journald.storage is
|
||||||
|
"${config.services.journald.storage}" in this container.
|
||||||
|
|
||||||
|
The forwarder reads /var/log/journal, which journald only writes
|
||||||
|
when it stores persistently: with "volatile" the journal lives in
|
||||||
|
/run/log/journal and with "none" there is none at all. Either way
|
||||||
|
the forwarder would ship nothing while looking healthy.
|
||||||
|
'';
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
services.opentelemetry-collector = {
|
||||||
|
enable = true;
|
||||||
|
# Contrib, and not a preference: `journald` is a contrib
|
||||||
|
# receiver. The upstream default build has no way to read a
|
||||||
|
# journal at all.
|
||||||
|
package = pkgs.opentelemetry-collector-contrib;
|
||||||
|
# Runs `otelcol validate` at build time. ⚠️ A parser, not a
|
||||||
|
# wiring check — it accepts a pipeline naming a component the
|
||||||
|
# build lacks, and the collector then dies at startup. Same
|
||||||
|
# caveat as every other tier; see nix/host-modules/otel.nix for
|
||||||
|
# the measurement.
|
||||||
|
validateConfigFile = true;
|
||||||
|
settings = {
|
||||||
|
# `journalctl --follow --lines=0` (what the receiver runs under
|
||||||
|
# the hood) ships only what is written *after* it starts, so
|
||||||
|
# every collector start has a silent gap at the front. This
|
||||||
|
# persists the read cursor, so a restart resumes instead of
|
||||||
|
# re-opening that gap; `start_at` stays `end` (its default),
|
||||||
|
# since a cursor covers every run after the first and
|
||||||
|
# `beginning` without one would re-ship the whole journal on
|
||||||
|
# every restart. `create_directory` is required for a
|
||||||
|
# build-time reason rather than a convenience one:
|
||||||
|
# `validateConfigFile` above runs `otelcol validate` in the nix
|
||||||
|
# sandbox, before systemd's `StateDirectory=` has created the
|
||||||
|
# path, and the validator refuses with "directory must exist".
|
||||||
|
extensions.file_storage = {
|
||||||
|
directory = "/var/lib/opentelemetry-collector";
|
||||||
|
create_directory = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
receivers.journald = {
|
||||||
|
# ⚠️ STATED, and it must stay stated: the receiver's own
|
||||||
|
# default is the RUNTIME journal (`/run/log/journal`), and
|
||||||
|
# every entry here is under /var/log/journal — which
|
||||||
|
# `--link-journal=host` above makes the host's directory for
|
||||||
|
# this machine id. Dropping this line leaves a collector that
|
||||||
|
# validates, starts, reports healthy and forwards nothing.
|
||||||
|
directory = "/var/log/journal";
|
||||||
|
storage = "file_storage";
|
||||||
|
};
|
||||||
|
|
||||||
|
# The scrape that used to belong to the swarm collector, now
|
||||||
|
# one hop shorter and one host less fussy — mara:
|
||||||
|
# *"move the existing scraper to the local collector"*.
|
||||||
|
#
|
||||||
|
# ⚠️ `metrics_path` and `params` are STATED, and both have to
|
||||||
|
# be: openbao serves no `/metrics` at all, and
|
||||||
|
# `/v1/sys/metrics` answers JSON unless the format is asked
|
||||||
|
# for. A scrape of the default path 404s and a scrape without
|
||||||
|
# the query parses as nothing — each reads as a dead exporter
|
||||||
|
# rather than as a wrong request, which is how the previous
|
||||||
|
# owner of this scrape learned to spell it
|
||||||
|
# (./swarm-otel.nix's `loopbackScrapeConfig`, still the
|
||||||
|
# renderer for every other service's target).
|
||||||
|
#
|
||||||
|
# No credential, unlike the swarm collector's
|
||||||
|
# `publishedScrapeTargets` jobs: the target is the loopback
|
||||||
|
# listener above, inside this container's own netns, which is
|
||||||
|
# what lets the scrape stay unauthenticated without the API
|
||||||
|
# listener's client-cert requirement being touched.
|
||||||
|
receivers.prometheus.config.scrape_configs = [
|
||||||
|
{
|
||||||
|
job_name = "bao";
|
||||||
|
static_configs = [ { targets = [ metricsScrapeTarget ]; } ];
|
||||||
|
metrics_path = "/v1/sys/metrics";
|
||||||
|
params.format = [ "prometheus" ];
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
# Identity the hop above cannot supply: a host-side reader can
|
||||||
|
# say which machine a line came from, and only a collector
|
||||||
|
# inside this container can say it was the secret store's. No
|
||||||
|
# `hive` or `swarm` label — the swarm tier upserts `hive` from
|
||||||
|
# the receiver that accepted the record, precisely so it comes
|
||||||
|
# from something the sender cannot write.
|
||||||
|
processors.resource.attributes = [
|
||||||
|
{
|
||||||
|
key = "service.name";
|
||||||
|
value = cfg.machine;
|
||||||
|
action = "upsert";
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
# `otlphttp` unconditionally, unlike the agent forwarder's
|
||||||
|
# protocol-derived exporter name: the swarm receiver speaks
|
||||||
|
# OTLP/HTTP protobuf whatever this tier's *upstream*
|
||||||
|
# protocol is, which is the same reason
|
||||||
|
# ./hive-c0re/environment.nix pins the protocol it hands out.
|
||||||
|
# `endpoint` is a BASE the exporter appends `/v1/logs` to.
|
||||||
|
exporters.otlphttp = {
|
||||||
|
endpoint = otelFirstHop;
|
||||||
|
# The receiver at that address accepts an OIDC token and
|
||||||
|
# nothing else (./swarm-otel.nix's `oidc/*` authenticators),
|
||||||
|
# so this is what makes the hop deliver rather than collect
|
||||||
|
# 401s — unconditional, because a forwarder that exports
|
||||||
|
# unauthenticated has no working mode to fall back to.
|
||||||
|
auth.authenticator = forwarderAuthName;
|
||||||
|
};
|
||||||
|
|
||||||
|
# The push side, opposite in direction to the receiver's
|
||||||
|
# `oidc/*`: those VALIDATE a token arriving, this OBTAINS one
|
||||||
|
# to send. Hence `oauth2client`, and hence a client id and
|
||||||
|
# secret rather than an issuer and an audience to check.
|
||||||
|
extensions.${forwarderAuthName} = {
|
||||||
|
client_id = cfg.otel.clientId;
|
||||||
|
# A path, never a value — and one systemd resolves at
|
||||||
|
# runtime, so neither the secret nor the credentials
|
||||||
|
# directory is rendered into the nix store.
|
||||||
|
client_secret_file = "\${env:CREDENTIALS_DIRECTORY}/${forwarderCredentialId}";
|
||||||
|
token_url = "${toString autheliaCfg.url}/api/oidc/token";
|
||||||
|
# ⚠️ THE AUDIENCE HAS TO BE REQUESTED, not merely granted.
|
||||||
|
# Registering it on the client only makes it permissible; a
|
||||||
|
# token minted without asking carries `aud: []` and the
|
||||||
|
# receiver refuses it — with a config that reads perfectly
|
||||||
|
# at both ends.
|
||||||
|
endpoint_params.audience = cfg.otel.clientId;
|
||||||
|
};
|
||||||
|
|
||||||
|
service.pipelines.logs = {
|
||||||
|
receivers = [ "journald" ];
|
||||||
|
processors = [ "resource" ];
|
||||||
|
exporters = [ "otlphttp" ];
|
||||||
|
};
|
||||||
|
|
||||||
|
# A SECOND pipeline, not a second receiver on the logs one: a
|
||||||
|
# pipeline is typed, and a prometheus receiver in `logs` is a
|
||||||
|
# config the collector rejects at startup rather than one that
|
||||||
|
# quietly ships metrics as log records.
|
||||||
|
#
|
||||||
|
# Same `resource` processor and same `otlphttp` exporter, so
|
||||||
|
# the store's metrics carry the same `service.name` its logs
|
||||||
|
# do and leave by the same hop — no otel bypass, one exit from
|
||||||
|
# this container. The exporter treats `endpoint` as a base and
|
||||||
|
# appends the signal's own path, so `/v1/metrics` here and
|
||||||
|
# `/v1/logs` above come from the one address.
|
||||||
|
service.pipelines.metrics = {
|
||||||
|
receivers = [ "prometheus" ];
|
||||||
|
processors = [ "resource" ];
|
||||||
|
exporters = [ "otlphttp" ];
|
||||||
|
};
|
||||||
|
|
||||||
|
# An extension configured but not listed here is INERT — the
|
||||||
|
# receiver's `storage: file_storage` above would name a
|
||||||
|
# component the collector never starts, and the exporter's
|
||||||
|
# authenticator would push unauthenticated while the
|
||||||
|
# collector reports healthy.
|
||||||
|
service.extensions = [
|
||||||
|
"file_storage"
|
||||||
|
forwarderAuthName
|
||||||
|
];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
# How the secret crosses the sandbox. The host unit
|
||||||
|
# `swarm-bao-forwarder-oidc` leaves a root-owned 0400 file at this
|
||||||
|
# path in the container's tree; systemd reads it as root before
|
||||||
|
# the sandbox exists and re-exposes it to whichever uid
|
||||||
|
# `DynamicUser` picked, under the directory the extension above
|
||||||
|
# names through `${env:CREDENTIALS_DIRECTORY}`.
|
||||||
|
#
|
||||||
|
# ⚠️ Unconditional, and a missing source is a unit that refuses to
|
||||||
|
# start. That is the intended shape rather than an oversight: this
|
||||||
|
# forwarder has one way to deliver, so a boot where the secret has
|
||||||
|
# not arrived yet is a collector that says so and retries, not one
|
||||||
|
# that quietly exports into a 401. The restart is what closes the
|
||||||
|
# first-boot window the host unit's own comment describes.
|
||||||
|
systemd.services.opentelemetry-collector.serviceConfig = {
|
||||||
|
LoadCredential = [ "${forwarderCredentialId}:${forwarderSecretInContainer}" ];
|
||||||
|
Restart = "on-failure";
|
||||||
|
RestartSec = 15;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,24 @@ let
|
||||||
# bound once here (rather than copy-pasted at both sites) is the only way
|
# bound once here (rather than copy-pasted at both sites) is the only way
|
||||||
# the two can't drift apart.
|
# the two can't drift apart.
|
||||||
swarmTierName = cfg.producerName;
|
swarmTierName = cfg.producerName;
|
||||||
|
|
||||||
|
# The SECOND swarm-tier producer: the collector inside the secret store's
|
||||||
|
# container (`swarm-bao.nix`). It gets an owner of its own rather than
|
||||||
|
# pushing into `swarmTierName`'s route, because the audience that route
|
||||||
|
# checks is `swarm-controller`'s own client id — one identity per principal,
|
||||||
|
# so a second principal brings its own client, its own audience and
|
||||||
|
# therefore its own authenticator and receiver.
|
||||||
|
#
|
||||||
|
# Named after the store's container, so `otlp/swarm-bao` reads as the thing
|
||||||
|
# that pushes into it. No `reserved-names.nix` entry is needed for it the
|
||||||
|
# way `swarm` has one: the name CONTAINS `swarm`, which
|
||||||
|
# `nix/reserved-hive-fragments.nix` forbids as a substring of any hive name,
|
||||||
|
# so no hive can ever own these components. The assertion below checks that
|
||||||
|
# fragment is still listed rather than trusting it.
|
||||||
|
storeProducerName = baoCfg.machine;
|
||||||
|
storeClientId = baoCfg.otel.clientId;
|
||||||
|
reservedHiveFragments = import ../reserved-hive-fragments.nix;
|
||||||
|
|
||||||
# Every `<owner>` no hive may take. Read from `nix/reserved-names.nix`, the
|
# Every `<owner>` no hive may take. Read from `nix/reserved-names.nix`, the
|
||||||
# same file the daemons are handed as `HIVE_RESERVED_NAMES`, because agent
|
# same file the daemons are handed as `HIVE_RESERVED_NAMES`, because agent
|
||||||
# names and hive names are ONE namespace going forward — a locally-owned
|
# names and hive names are ONE namespace going forward — a locally-owned
|
||||||
|
|
@ -363,6 +381,50 @@ in
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
storeProducerPort = lib.mkOption {
|
||||||
|
type = lib.types.port;
|
||||||
|
default = 4391;
|
||||||
|
description = ''
|
||||||
|
Port the receiver for the **secret store's own forwarder** listens
|
||||||
|
on, at `127.0.0.1`. Reached through the gateway at
|
||||||
|
`https://''${domain}/''${storeProducerName}/`, the same
|
||||||
|
path-per-producer shape a hive and the swarm-tier producer above
|
||||||
|
both use.
|
||||||
|
|
||||||
|
Its own receiver, and not a second sender into
|
||||||
|
{option}`services.hyperhive.swarm.otel.producerPort`, because an
|
||||||
|
`oidc` authenticator checks exactly ONE audience: that receiver
|
||||||
|
admits the audience `swarm-controller` registers for itself, and
|
||||||
|
the store's forwarder is a different principal with a client of its
|
||||||
|
own. Sharing the route would mean sharing that client — which is
|
||||||
|
the thing the store exists to make unnecessary.
|
||||||
|
|
||||||
|
⚠️ A reserved value rather than an offset from
|
||||||
|
{option}`services.hyperhive.swarm.otel.port`, for the reason stated
|
||||||
|
on `producerPort`: that range grows with the hive count and would
|
||||||
|
eventually walk into any fixed offset from it. The collision
|
||||||
|
assertion below covers this port too.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
storeProducerName = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
readOnly = true;
|
||||||
|
default = baoCfg.machine;
|
||||||
|
defaultText = lib.literalExpression "services.hyperhive.swarm.bao.machine";
|
||||||
|
description = ''
|
||||||
|
Path and component name of the store forwarder's own receiver
|
||||||
|
(`otlp/''${storeProducerName}`, `oidc/''${storeProducerName}`).
|
||||||
|
Published so `swarm-bao.nix` addresses the receiver by name rather
|
||||||
|
than repeating the string, exactly as `producerName` above is
|
||||||
|
published for `swarm-controller.nix`.
|
||||||
|
|
||||||
|
Read-only and derived from the store's container name: two
|
||||||
|
spellings of a name both ends have to agree on is a 404 on a
|
||||||
|
request that authenticated perfectly.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
producerName = lib.mkOption {
|
producerName = lib.mkOption {
|
||||||
type = lib.types.str;
|
type = lib.types.str;
|
||||||
readOnly = true;
|
readOnly = true;
|
||||||
|
|
@ -619,6 +681,9 @@ in
|
||||||
# request reaches the receiver, which knows nothing about the
|
# request reaches the receiver, which knows nothing about the
|
||||||
# path it was found at.
|
# path it was found at.
|
||||||
"/${swarmTierName}/".proxyPass = "http://127.0.0.1:${toString cfg.producerPort}/";
|
"/${swarmTierName}/".proxyPass = "http://127.0.0.1:${toString cfg.producerPort}/";
|
||||||
|
# The secret store's forwarder, on its own route for its own
|
||||||
|
# audience — same trailing slashes, same reason.
|
||||||
|
"/${storeProducerName}/".proxyPass = "http://127.0.0.1:${toString cfg.storeProducerPort}/";
|
||||||
# There is no swarm-wide inbox, and a closed door is the honest
|
# There is no swarm-wide inbox, and a closed door is the honest
|
||||||
# description of that. Every other route into this collector
|
# description of that. Every other route into this collector
|
||||||
# belongs to exactly one hive or the swarm-tier producer above.
|
# belongs to exactly one hive or the swarm-tier producer above.
|
||||||
|
|
@ -873,6 +938,25 @@ in
|
||||||
Put it back, or give this module a different swarmTierName.
|
Put it back, or give this module a different swarmTierName.
|
||||||
'';
|
'';
|
||||||
}
|
}
|
||||||
|
{
|
||||||
|
# The store forwarder's components are named after a container whose
|
||||||
|
# name merely CONTAINS the reserved word, so what protects them is
|
||||||
|
# the substring list rather than the equality one. Same reason the
|
||||||
|
# assertion above exists: the list lives in another file, and a guard
|
||||||
|
# whose subject can be edited elsewhere has to assert its own case is
|
||||||
|
# still covered.
|
||||||
|
assertion = lib.any (f: lib.hasInfix f storeProducerName) reservedHiveFragments;
|
||||||
|
message = ''
|
||||||
|
nix/reserved-hive-fragments.nix forbids no substring of
|
||||||
|
'${storeProducerName}', which the swarm collector needs kept out of
|
||||||
|
the hive namespace: it names components `<kind>/<owner>`, so a hive
|
||||||
|
called '${storeProducerName}' would replace the secret store
|
||||||
|
forwarder's own receiver and pipeline entry.
|
||||||
|
|
||||||
|
Put the fragment back, or give the store's container a name one of
|
||||||
|
them covers.
|
||||||
|
'';
|
||||||
|
}
|
||||||
{
|
{
|
||||||
# A published target that is not an `https://host/path` url. Without
|
# A published target that is not an `https://host/path` url. Without
|
||||||
# this the split returns null and the failure surfaces as
|
# this the split returns null and the failure surfaces as
|
||||||
|
|
@ -974,6 +1058,7 @@ in
|
||||||
others = [
|
others = [
|
||||||
cfg.telemetryPort
|
cfg.telemetryPort
|
||||||
cfg.producerPort
|
cfg.producerPort
|
||||||
|
cfg.storeProducerPort
|
||||||
otelCfg.collector.port
|
otelCfg.collector.port
|
||||||
]
|
]
|
||||||
++ lib.optional deployCfg.victoriametrics.enable vmCfg.port;
|
++ lib.optional deployCfg.victoriametrics.enable vmCfg.port;
|
||||||
|
|
@ -1119,6 +1204,15 @@ in
|
||||||
auth.authenticator = "oidc/${swarmTierName}";
|
auth.authenticator = "oidc/${swarmTierName}";
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
# The secret store's forwarder, which is a swarm-tier
|
||||||
|
# producer with a principal of its own — a receiver per
|
||||||
|
# audience, because an `oidc` authenticator checks one.
|
||||||
|
// {
|
||||||
|
"otlp/${storeProducerName}".protocols.http = {
|
||||||
|
endpoint = "127.0.0.1:${toString cfg.storeProducerPort}";
|
||||||
|
auth.authenticator = "oidc/${storeProducerName}";
|
||||||
|
};
|
||||||
|
}
|
||||||
# MERGED with the per-hive receivers, never assigned over
|
# MERGED with the per-hive receivers, never assigned over
|
||||||
# them. A plain assignment here would drop every hive's
|
# them. A plain assignment here would drop every hive's
|
||||||
# receiver and still render a valid config that starts
|
# receiver and still render a valid config that starts
|
||||||
|
|
@ -1303,7 +1397,13 @@ in
|
||||||
# same reasoning as its receiver above: `otlp/${swarmTierName}`
|
# same reasoning as its receiver above: `otlp/${swarmTierName}`
|
||||||
# always exists, so the authenticator it names must too, or an
|
# always exists, so the authenticator it names must too, or an
|
||||||
# extension-not-listed startup failure follows every deploy.
|
# extension-not-listed startup failure follows every deploy.
|
||||||
++ [ "oidc/${swarmTierName}" ]
|
++ [
|
||||||
|
"oidc/${swarmTierName}"
|
||||||
|
# Unconditional for the same reason, and one level sharper:
|
||||||
|
# the store runs in every swarm, so its forwarder's
|
||||||
|
# receiver is as permanent as the swarm tier's own.
|
||||||
|
"oidc/${storeProducerName}"
|
||||||
|
]
|
||||||
# The push side's authenticators. Same rule as above: one an
|
# The push side's authenticators. Same rule as above: one an
|
||||||
# exporter names but this list omits is INERT — the collector
|
# exporter names but this list omits is INERT — the collector
|
||||||
# starts clean and pushes unauthenticated.
|
# starts clean and pushes unauthenticated.
|
||||||
|
|
@ -1347,6 +1447,16 @@ in
|
||||||
"metrics/${swarmTierName}" = {
|
"metrics/${swarmTierName}" = {
|
||||||
receivers = [
|
receivers = [
|
||||||
"otlp/${swarmTierName}"
|
"otlp/${swarmTierName}"
|
||||||
|
# The store forwarder's receiver joins the swarm
|
||||||
|
# tier's pipeline rather than bringing one of its own:
|
||||||
|
# the stamp it needs is `resource/${swarmTierName}`'s
|
||||||
|
# exact stamp — the swarm's name and deliberately no
|
||||||
|
# `hive` — and a second pipeline would be that same
|
||||||
|
# processor under a second name to keep in step. What
|
||||||
|
# a producer is separated FOR is the credential it
|
||||||
|
# proves, and that is the receiver's job, not the
|
||||||
|
# pipeline's.
|
||||||
|
"otlp/${storeProducerName}"
|
||||||
]
|
]
|
||||||
++ lib.optional (cfg.scrapeTargets != { } || cfg.publishedScrapeTargets != { }) "prometheus";
|
++ lib.optional (cfg.scrapeTargets != { } || cfg.publishedScrapeTargets != { }) "prometheus";
|
||||||
processors = [ "resource/${swarmTierName}" ];
|
processors = [ "resource/${swarmTierName}" ];
|
||||||
|
|
@ -1386,7 +1496,14 @@ in
|
||||||
# `hive` value to put on them.
|
# `hive` value to put on them.
|
||||||
// lib.optionalAttrs collectLogs {
|
// lib.optionalAttrs collectLogs {
|
||||||
"logs/${swarmTierName}" = {
|
"logs/${swarmTierName}" = {
|
||||||
receivers = [ "journald" ];
|
# This host's own journal, and the store container's,
|
||||||
|
# which arrives over the receiver above rather than off
|
||||||
|
# a disk — the store's forwarder reads a journal no
|
||||||
|
# reader on another host can see.
|
||||||
|
receivers = [
|
||||||
|
"journald"
|
||||||
|
"otlp/${storeProducerName}"
|
||||||
|
];
|
||||||
processors = [ "resource/${swarmTierName}" ];
|
processors = [ "resource/${swarmTierName}" ];
|
||||||
exporters = logExporterNames;
|
exporters = logExporterNames;
|
||||||
};
|
};
|
||||||
|
|
@ -1450,6 +1567,18 @@ in
|
||||||
audience = config.services.hyperhive.swarm.controller.queueClientId;
|
audience = config.services.hyperhive.swarm.controller.queueClientId;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
# The secret store forwarder's own authenticator. Same shape
|
||||||
|
# again, and the audience is that principal's own client id
|
||||||
|
# — the self-referential form `swarm-authelia.nix` registers
|
||||||
|
# a hive client under and `swarm-controller` uses above,
|
||||||
|
# read from the option `glue-swarm-bao-otel-oidc-client.nix`
|
||||||
|
# registers so the two ends cannot spell it differently.
|
||||||
|
// {
|
||||||
|
"oidc/${storeProducerName}" = {
|
||||||
|
issuer_url = hyperhiveCfg.swarm.authelia.url;
|
||||||
|
audience = storeClientId;
|
||||||
|
};
|
||||||
|
}
|
||||||
# The push side. Opposite direction to every `oidc/*` above —
|
# The push side. Opposite direction to every `oidc/*` above —
|
||||||
# those VALIDATE a token arriving; these OBTAIN one to send.
|
# those VALIDATE a token arriving; these OBTAIN one to send.
|
||||||
# Hence `oauth2client` rather than `oidc`, and hence a client
|
# Hence `oauth2client` rather than `oidc`, and hence a client
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,13 @@ let
|
||||||
serviceClientIds = [
|
serviceClientIds = [
|
||||||
hyperhiveCfg.swarm.grafana.oidc.clientId
|
hyperhiveCfg.swarm.grafana.oidc.clientId
|
||||||
hyperhiveCfg.swarm.otel.clientId
|
hyperhiveCfg.swarm.otel.clientId
|
||||||
|
# The secret store's own journal forwarder. A third swarm service by this
|
||||||
|
# list's own rule — a client registered once for the whole swarm, whose
|
||||||
|
# secret one host mints and another reads — even though the principal
|
||||||
|
# behind it runs inside the store's container rather than beside it.
|
||||||
|
# Missing from here, authelia mints the value and nothing carries it, so
|
||||||
|
# the reader waits on a path that is never written.
|
||||||
|
hyperhiveCfg.swarm.bao.otel.clientId
|
||||||
];
|
];
|
||||||
|
|
||||||
# Where this unit keeps the appservice tokens it minted, and the whole reason
|
# Where this unit keeps the appservice tokens it minted, and the whole reason
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,10 @@ let
|
||||||
deploy.bao.enable = true;
|
deploy.bao.enable = true;
|
||||||
deploy.bao.extraListenAddresses = [ "10.0.0.1" ];
|
deploy.bao.extraListenAddresses = [ "10.0.0.1" ];
|
||||||
# Pinned, not incidental: the case counting these listeners is about the
|
# Pinned, not incidental: the case counting these listeners is about the
|
||||||
# declared addresses, and a collector on this host would add one of its own.
|
# declared API addresses. ⚠️ Since the store got its own collector, this no longer keeps the METRICS
|
||||||
|
# listener out of the count — that one is a function of the store, not of
|
||||||
|
# a collector's placement — so the case below subtracts it by name rather
|
||||||
|
# than by leaving the swarm tier off.
|
||||||
deploy.swarm-otel.enable = false;
|
deploy.swarm-otel.enable = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -42,18 +45,50 @@ let
|
||||||
swarm.otel.scrapeTargets.plain = "127.0.0.1:9999";
|
swarm.otel.scrapeTargets.plain = "127.0.0.1:9999";
|
||||||
};
|
};
|
||||||
|
|
||||||
baoNoCollector = hive {
|
# The SPLIT topology, and the reason it is not called `baoNoCollector` any
|
||||||
|
# more: a swarm always runs every swarm-level service somewhere, so this host
|
||||||
|
# having `deploy.swarm-otel.enable = false` means the collector is on ANOTHER
|
||||||
|
# host, never that the swarm has none. Both of those read the same locally,
|
||||||
|
# and only one of them is a real deployment — which is what the two gates
|
||||||
|
# this PR removed each mistook for "there is nothing to forward to".
|
||||||
|
baoOtelElsewhere = hive {
|
||||||
deploy.bao.enable = true;
|
deploy.bao.enable = true;
|
||||||
deploy.swarm-otel.enable = false;
|
deploy.swarm-otel.enable = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
# The same split, plus a HIVE collector on the store's host — the pairing the
|
||||||
|
# first gate read as permission to forward. It is a third collector with a
|
||||||
|
# different job, so it changes neither whether the store forwards nor where.
|
||||||
|
# `clientSecretFile` is what ../host-modules/otel.nix's identity assertion
|
||||||
|
# demands of any hive with the tier on.
|
||||||
|
baoWithHiveOtel = hive {
|
||||||
|
deploy.bao.enable = true;
|
||||||
|
otel.enable = true;
|
||||||
|
otel.clientSecretFile = "/var/lib/hive-otel-oidc/client.secret";
|
||||||
|
};
|
||||||
|
|
||||||
|
# The forwarder INSIDE the store's container, not the host's collector and
|
||||||
|
# not the swarm tier's — three collectors in this tree, and only this one can
|
||||||
|
# see the store's journal.
|
||||||
|
baoForwarder = machine: machine.containers.swarm-bao.config.services.opentelemetry-collector;
|
||||||
|
|
||||||
# The scrape list prometheus is handed, not the option a service declared:
|
# The scrape list prometheus is handed, not the option a service declared:
|
||||||
# the address, the path and the query are one string on the way in and three
|
# the address, the path and the query are one string on the way in and three
|
||||||
# fields on the way out, and only the second shape is what gets requested.
|
# fields on the way out, and only the second shape is what gets requested.
|
||||||
scrapeJob =
|
#
|
||||||
machine: job:
|
# ⚠️ Two of these now, aimed at two different containers, and the split is
|
||||||
lib.findFirst (c: c.job_name == job) null
|
# the point: the store's own job left the swarm collector for the
|
||||||
machine.containers.swarm-otel.config.services.opentelemetry-collector.settings.receivers.prometheus.config.scrape_configs;
|
# one inside its container, so a case that looked in the old place would
|
||||||
|
# find `null` and a case that only looked in the new one would not notice
|
||||||
|
# the store still being declared to both.
|
||||||
|
scrapeJobIn =
|
||||||
|
container: machine: job:
|
||||||
|
lib.findFirst (c: c.job_name == job) null (
|
||||||
|
machine.containers.${container}.config.services.opentelemetry-collector.settings.receivers.prometheus.config.scrape_configs
|
||||||
|
or [ ]
|
||||||
|
);
|
||||||
|
scrapeJob = scrapeJobIn "swarm-otel";
|
||||||
|
baoScrapeJob = scrapeJobIn "swarm-bao";
|
||||||
cases = [
|
cases = [
|
||||||
{
|
{
|
||||||
# Same gap one tier up, and it needs its own arm: this collector
|
# Same gap one tier up, and it needs its own arm: this collector
|
||||||
|
|
@ -80,9 +115,14 @@ let
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
# Control for the case above: these settings are rendered per deployment,
|
# Control for the case above: these settings are rendered per deployment,
|
||||||
# not constants a passing case could be indifferent to.
|
# not constants a passing case could be indifferent to. The metrics
|
||||||
|
# listener is excluded by name — it answers on a port of its own and is
|
||||||
|
# not one of the API addresses this case is counting.
|
||||||
name = "a declared extra address renders a second listener beside loopback";
|
name = "a declared extra address renders a second listener beside loopback";
|
||||||
ok = builtins.length (builtins.attrNames (baoSettings baoTwoAddresses).listener) == 2;
|
ok =
|
||||||
|
builtins.length (
|
||||||
|
builtins.filter (n: n != "metrics") (builtins.attrNames (baoSettings baoTwoAddresses).listener)
|
||||||
|
) == 2;
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
# Retention is what serves the endpoint at all, so the listener alone
|
# Retention is what serves the endpoint at all, so the listener alone
|
||||||
|
|
@ -97,13 +137,56 @@ let
|
||||||
{
|
{
|
||||||
# openbao serves no `/metrics` at all, so a scrape of the default path
|
# openbao serves no `/metrics` at all, so a scrape of the default path
|
||||||
# 404s: the store looks like a dead exporter, and every panel built on
|
# 404s: the store looks like a dead exporter, and every panel built on
|
||||||
# it renders empty rather than erroring.
|
# it renders empty rather than erroring. The query is the other half —
|
||||||
|
# `/v1/sys/metrics` answers JSON without it, which parses as no series
|
||||||
|
# rather than as an error.
|
||||||
name = "the store's scrape asks for the path openbao serves";
|
name = "the store's scrape asks for the path openbao serves";
|
||||||
ok =
|
ok =
|
||||||
let
|
let
|
||||||
j = scrapeJob baoWithCollector "bao";
|
j = baoScrapeJob baoWithCollector "bao";
|
||||||
in
|
in
|
||||||
(j.metrics_path or "") == "/v1/sys/metrics" && (j.params.format or [ ]) == [ "prometheus" ];
|
j != null
|
||||||
|
&& (j.metrics_path or "") == "/v1/sys/metrics"
|
||||||
|
&& (j.params.format or [ ]) == [ "prometheus" ];
|
||||||
|
}
|
||||||
|
{
|
||||||
|
# 🎯 This move's deliverable, and it needs BOTH halves or it is not a move:
|
||||||
|
# the job is in the store's own container, and it is no longer declared
|
||||||
|
# to the swarm collector. Only the second half can catch a "move" that
|
||||||
|
# left the original behind — two scrapers of one endpoint, double-counted
|
||||||
|
# and neither obviously wrong.
|
||||||
|
name = "the store's metrics are scraped by its own collector, not the swarm's";
|
||||||
|
ok = baoScrapeJob baoWithCollector "bao" != null && scrapeJob baoWithCollector "bao" == null;
|
||||||
|
}
|
||||||
|
{
|
||||||
|
# The scrape target is the loopback listener, not the API one: dialing
|
||||||
|
# the API listener would need a client certificate the collector has no
|
||||||
|
# way to present, and the fix for that would be relaxing
|
||||||
|
# `tls_require_and_verify_client_cert` — which is the trade this address
|
||||||
|
# exists to avoid. Pinned so a later edit has to argue with it.
|
||||||
|
name = "the store's collector scrapes loopback, never a wider address";
|
||||||
|
ok =
|
||||||
|
let
|
||||||
|
j = baoScrapeJob baoWithCollector "bao";
|
||||||
|
s = baoSettings baoWithCollector;
|
||||||
|
in
|
||||||
|
j.static_configs == [ { targets = [ "127.0.0.1:8202" ]; } ]
|
||||||
|
&& s.listener.metrics.address == "127.0.0.1:8202";
|
||||||
|
}
|
||||||
|
{
|
||||||
|
# A pipeline is typed: a prometheus receiver named in `logs` is rejected
|
||||||
|
# at startup, and metrics with no pipeline at all are collected and
|
||||||
|
# dropped. The exporter is pinned alongside because "no otel bypass"
|
||||||
|
# means both signals leave by the one hop.
|
||||||
|
name = "the store's collector has a metrics pipeline out the same hop as its logs";
|
||||||
|
ok =
|
||||||
|
let
|
||||||
|
s = (baoForwarder baoWithCollector).settings;
|
||||||
|
m = s.service.pipelines.metrics;
|
||||||
|
in
|
||||||
|
m.receivers == [ "prometheus" ]
|
||||||
|
&& m.exporters == [ "otlphttp" ]
|
||||||
|
&& m.exporters == s.service.pipelines.logs.exporters;
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
# Presence control for the case above: both fields are omitted rather
|
# Presence control for the case above: both fields are omitted rather
|
||||||
|
|
@ -117,14 +200,236 @@ let
|
||||||
j != null && !(j ? metrics_path) && !(j ? params);
|
j != null && !(j ? metrics_path) && !(j ? params);
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
# Absence arm. Unauthenticated by design, so it must not exist where
|
# 🩸 This case USED to be the absence arm — "a store with no collector
|
||||||
# nothing reads it.
|
# beside it serves no metrics" — and this inverts it deliberately
|
||||||
name = "a store with no collector beside it serves no metrics";
|
# rather than deleting it, because the condition it asserted is the bug.
|
||||||
|
#
|
||||||
|
# The old reasoning was sound for the old reader: the endpoint is
|
||||||
|
# unauthenticated, so it should not exist where nothing reads it, and
|
||||||
|
# the only reader was a swarm collector that had to be on this host.
|
||||||
|
# What that rendered on every OTHER host was a store whose metrics went
|
||||||
|
# nowhere, indistinguishably from a store nobody had asked to scrape.
|
||||||
|
#
|
||||||
|
# The reader now ships inside the container, so "no collector beside it"
|
||||||
|
# describes no host that exists. The endpoint is still not exposed to
|
||||||
|
# anything new: it is loopback in a netns whose only other occupants are
|
||||||
|
# the store and its collector.
|
||||||
|
name = "a store whose swarm collector is elsewhere still serves and scrapes its metrics";
|
||||||
ok =
|
ok =
|
||||||
let
|
let
|
||||||
s = baoSettings baoNoCollector;
|
s = baoSettings baoOtelElsewhere;
|
||||||
in
|
in
|
||||||
!(s.listener ? metrics) && !(s ? telemetry);
|
s.listener.metrics.address == "127.0.0.1:8202"
|
||||||
|
&& (s.telemetry.prometheus_retention_time or "0s") != "0s"
|
||||||
|
&& baoScrapeJob baoOtelElsewhere "bao" != null;
|
||||||
|
}
|
||||||
|
{
|
||||||
|
# The store's journal reaches a reader through a collector of its own,
|
||||||
|
# and every one of these fields is silent when wrong: the runtime journal
|
||||||
|
# is the receiver's own default and is empty here, an unlisted extension
|
||||||
|
# is inert so the cursor silently stops persisting, and a pipeline is
|
||||||
|
# free to name none of it.
|
||||||
|
name = "the store's container forwards its own journal";
|
||||||
|
ok =
|
||||||
|
let
|
||||||
|
s = (baoForwarder baoWithCollector).settings;
|
||||||
|
p = s.service.pipelines.logs;
|
||||||
|
in
|
||||||
|
s.receivers.journald.directory == "/var/log/journal"
|
||||||
|
&& s.receivers.journald.storage == "file_storage"
|
||||||
|
&&
|
||||||
|
s.service.extensions == [
|
||||||
|
"file_storage"
|
||||||
|
"oauth2client/swarm-bao"
|
||||||
|
]
|
||||||
|
&& p.receivers == [ "journald" ]
|
||||||
|
&& p.exporters == [ "otlphttp" ]
|
||||||
|
&& s ? exporters.otlphttp;
|
||||||
|
}
|
||||||
|
{
|
||||||
|
# The whole journal, which is what the shared collector's unit allowlist
|
||||||
|
# is not. A `units` list here would render and deploy perfectly while
|
||||||
|
# shipping only the units someone remembered to name — the failure this
|
||||||
|
# forwarder exists to end.
|
||||||
|
name = "the store's forwarder filters no units";
|
||||||
|
ok = !((baoForwarder baoWithCollector).settings.receivers.journald ? units);
|
||||||
|
}
|
||||||
|
{
|
||||||
|
# Both ends of the hop, because a mismatch between them is silent in both
|
||||||
|
# directions: the exporter retries into a 404 and the receiver never
|
||||||
|
# hears from it. Not "on one host" any more — the far end is the gateway
|
||||||
|
# vhost the swarm collector serves its name on, which is the whole point
|
||||||
|
# of addressing it by name rather than by a bridge address.
|
||||||
|
name = "the store's forwarder exports to the swarm collector's own route";
|
||||||
|
ok =
|
||||||
|
let
|
||||||
|
otel = baoWithCollector.services.hyperhive.swarm.otel;
|
||||||
|
vhost = baoWithCollector.services.nginx.virtualHosts.${otel.domain};
|
||||||
|
in
|
||||||
|
(baoForwarder baoWithCollector).settings.exporters.otlphttp.endpoint
|
||||||
|
== "https://${otel.domain}/${otel.storeProducerName}"
|
||||||
|
&& vhost.locations ? "/${otel.storeProducerName}/";
|
||||||
|
}
|
||||||
|
{
|
||||||
|
# The case the old hive-tier gate got wrong: this host runs the swarm's
|
||||||
|
# collector and no hive collector at all, and the forwarder still exists
|
||||||
|
# with an address that resolves. Gated on `otel.enable` it rendered
|
||||||
|
# nothing here, so the store's journal left no trace anywhere.
|
||||||
|
name = "a store on a host with no hive collector still forwards";
|
||||||
|
ok =
|
||||||
|
!baoWithCollector.services.hyperhive.otel.enable
|
||||||
|
&& (baoForwarder baoWithCollector).enable
|
||||||
|
&& (baoForwarder baoWithCollector).settings.exporters.otlphttp.endpoint != "";
|
||||||
|
}
|
||||||
|
{
|
||||||
|
# 🎯 The split topology, which is what both removed gates got wrong and
|
||||||
|
# neither earlier test could see: the swarm's collector runs on ANOTHER
|
||||||
|
# host, so nothing local says it exists — and it exists anyway, because
|
||||||
|
# every swarm-level service runs somewhere in the swarm. The forwarder
|
||||||
|
# has to render here and has to be addressed by the collector's swarm
|
||||||
|
# name, which is reachable from a host that serves no vhost for it.
|
||||||
|
#
|
||||||
|
# Both halves matter. Without `enable` this is the old bug; without the
|
||||||
|
# endpoint it is a forwarder that renders unconditionally and points at
|
||||||
|
# nothing. The `!(vhost ? domain)` conjunct is what makes the fixture
|
||||||
|
# genuinely remote rather than a co-located host in disguise — the
|
||||||
|
# premise the co-located fixtures cannot test.
|
||||||
|
name = "a store forwards by name from a host that does not run the swarm collector";
|
||||||
|
ok =
|
||||||
|
let
|
||||||
|
otel = baoOtelElsewhere.services.hyperhive.swarm.otel;
|
||||||
|
route = "https://${otel.domain}/${otel.storeProducerName}";
|
||||||
|
in
|
||||||
|
!(baoOtelElsewhere.services.nginx.virtualHosts ? ${otel.domain})
|
||||||
|
&& (baoForwarder baoOtelElsewhere).enable
|
||||||
|
&& (baoForwarder baoOtelElsewhere).settings.exporters.otlphttp.endpoint == route
|
||||||
|
# …and a hive collector standing beside the store neither supplies the
|
||||||
|
# reason to forward nor changes the addressee: same route, same tier.
|
||||||
|
&& (baoForwarder baoWithHiveOtel).enable
|
||||||
|
&& (baoForwarder baoWithHiveOtel).settings.exporters.otlphttp.endpoint == route;
|
||||||
|
}
|
||||||
|
{
|
||||||
|
# 🎯 The credential half of this move, and the arm that says the hop
|
||||||
|
# DELIVERS rather than merely resolves. The receiver at the far end
|
||||||
|
# takes an OIDC token and nothing else, so an exporter with no
|
||||||
|
# authenticator is a forwarder that retries into 401s while every other
|
||||||
|
# case here passes. Both halves: the extension has to exist AND the
|
||||||
|
# exporter has to name it.
|
||||||
|
name = "the store's forwarder presents a credential on the hop it exports over";
|
||||||
|
ok =
|
||||||
|
let
|
||||||
|
s = (baoForwarder baoWithCollector).settings;
|
||||||
|
auth = "oauth2client/swarm-bao";
|
||||||
|
in
|
||||||
|
s.exporters.otlphttp.auth.authenticator == auth
|
||||||
|
&& s.extensions ? ${auth}
|
||||||
|
&& lib.elem auth s.service.extensions;
|
||||||
|
}
|
||||||
|
{
|
||||||
|
# Its OWN client, never the swarm collector's and never the
|
||||||
|
# controller's: one identity per principal, and this one runs on a host
|
||||||
|
# neither of those two does. The audience is the same id because the
|
||||||
|
# receiver checks exactly that — a token minted without ASKING for it
|
||||||
|
# carries `aud: []` and is refused with a config that reads correctly at
|
||||||
|
# both ends.
|
||||||
|
name = "the forwarder authenticates as a principal of its own";
|
||||||
|
ok =
|
||||||
|
let
|
||||||
|
bao = baoWithCollector.services.hyperhive.swarm.bao;
|
||||||
|
e = (baoForwarder baoWithCollector).settings.extensions."oauth2client/swarm-bao";
|
||||||
|
in
|
||||||
|
e.client_id == bao.otel.clientId
|
||||||
|
&& e.endpoint_params.audience == bao.otel.clientId
|
||||||
|
&& bao.otel.clientId != baoWithCollector.services.hyperhive.swarm.otel.clientId
|
||||||
|
&& bao.otel.clientId != baoWithCollector.services.hyperhive.swarm.controller.queueClientId;
|
||||||
|
}
|
||||||
|
{
|
||||||
|
# A path systemd resolves at runtime, never a value and never a store
|
||||||
|
# path: the collector runs under `DynamicUser` and opens this file
|
||||||
|
# itself, so the credential arrives through `LoadCredential` and the
|
||||||
|
# config names only the directory systemd exports.
|
||||||
|
name = "the forwarder's client secret reaches it as a credential, not a value";
|
||||||
|
ok =
|
||||||
|
let
|
||||||
|
container = baoWithCollector.containers.swarm-bao.config;
|
||||||
|
e = (baoForwarder baoWithCollector).settings.extensions."oauth2client/swarm-bao";
|
||||||
|
unit = container.systemd.services.opentelemetry-collector.serviceConfig;
|
||||||
|
in
|
||||||
|
e.client_secret_file == "\${env:CREDENTIALS_DIRECTORY}/oidc-client-secret"
|
||||||
|
&&
|
||||||
|
unit.LoadCredential == [
|
||||||
|
"oidc-client-secret:/var/lib/swarm-bao-otel-oidc/swarm-bao-collector.secret"
|
||||||
|
];
|
||||||
|
}
|
||||||
|
{
|
||||||
|
# Same 403-not-a-miss property the grafana and matrix readers are pinned
|
||||||
|
# for: the reader's grant covers the `services` prefix, so a secret
|
||||||
|
# filed under the hive that happens to run the store would be refused
|
||||||
|
# rather than missing, however correct the path reads.
|
||||||
|
name = "the forwarder's secret is read from the prefix the publisher writes";
|
||||||
|
ok =
|
||||||
|
let
|
||||||
|
s = baoWithCollector.systemd.services.swarm-bao-forwarder-oidc.script;
|
||||||
|
in
|
||||||
|
lib.hasInfix "secret/swarm/services/swarm-bao-collector/oidc/client" s
|
||||||
|
&& !(lib.hasInfix "secret/swarm/hives/" s);
|
||||||
|
}
|
||||||
|
{
|
||||||
|
# The delivery unit is ordered AFTER the store's container, not before
|
||||||
|
# it: the store it reads lives in the container it delivers into, so a
|
||||||
|
# `before` edge is a wait on a process that cannot start until this
|
||||||
|
# finishes. Pinned because the unit it was copied from does the
|
||||||
|
# opposite, and copying that line too would deadlock every cold boot.
|
||||||
|
name = "the delivery unit waits for the store rather than blocking it";
|
||||||
|
ok =
|
||||||
|
let
|
||||||
|
u = baoWithCollector.systemd.services.swarm-bao-forwarder-oidc;
|
||||||
|
in
|
||||||
|
lib.elem "container@swarm-bao.service" u.after
|
||||||
|
&& !(lib.elem "container@swarm-bao.service" (u.before or [ ]))
|
||||||
|
&& lib.elem "swarm-bao-pki.service" u.requires;
|
||||||
|
}
|
||||||
|
{
|
||||||
|
# The far end of the credential. An authenticator checks ONE audience,
|
||||||
|
# so the store's forwarder gets a receiver of its own rather than a
|
||||||
|
# second sender into the route whose audience belongs to
|
||||||
|
# `swarm-controller` — and every one of these four is silent when
|
||||||
|
# wrong: an unlisted extension is inert, a receiver in no pipeline
|
||||||
|
# drops what it accepts, and a mismatched audience is a healthy 401.
|
||||||
|
name = "the swarm collector admits this principal on a receiver of its own";
|
||||||
|
ok =
|
||||||
|
let
|
||||||
|
otel = baoWithCollector.services.hyperhive.swarm.otel;
|
||||||
|
s = baoWithCollector.containers.swarm-otel.config.services.opentelemetry-collector.settings;
|
||||||
|
name = otel.storeProducerName;
|
||||||
|
in
|
||||||
|
s.receivers."otlp/${name}".protocols.http.auth.authenticator == "oidc/${name}"
|
||||||
|
&&
|
||||||
|
s.extensions."oidc/${name}".audience == baoWithCollector.services.hyperhive.swarm.bao.otel.clientId
|
||||||
|
&& lib.elem "oidc/${name}" s.service.extensions
|
||||||
|
&& lib.elem "otlp/${name}" s.service.pipelines."metrics/${otel.producerName}".receivers
|
||||||
|
&& lib.elem "otlp/${name}" s.service.pipelines."logs/${otel.producerName}".receivers;
|
||||||
|
}
|
||||||
|
{
|
||||||
|
# A client authelia has never heard of mints nothing, so the publisher
|
||||||
|
# has nothing to copy and the delivery unit reads an empty path
|
||||||
|
# forever. Registered where AUTHELIA runs, which is why it is a glue
|
||||||
|
# module and not a line in the store's own config — this fixture runs
|
||||||
|
# no store at all and still has to register it.
|
||||||
|
name = "the forwarder's client is registered wherever authelia runs";
|
||||||
|
ok =
|
||||||
|
let
|
||||||
|
autheliaOnly = hive { deploy.authelia.enable = true; };
|
||||||
|
clients = autheliaOnly.services.hyperhive.swarm.authelia.oidc.clients;
|
||||||
|
c = lib.findFirst (c: c.id == "swarm-bao-collector") null clients;
|
||||||
|
in
|
||||||
|
!autheliaOnly.services.hyperhive.deploy.bao.enable
|
||||||
|
&& c != null
|
||||||
|
&& c.audience == [ "swarm-bao-collector" ]
|
||||||
|
# ⚠️ What makes the token READABLE by the receiver at all: authelia's
|
||||||
|
# default is an opaque handle, and an `oidc` extension verifies
|
||||||
|
# offline against the provider's JWKS.
|
||||||
|
&& c.accessTokenSignedResponseAlg == "RS256";
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
in
|
in
|
||||||
|
|
|
||||||
|
|
@ -132,6 +132,17 @@ let
|
||||||
secretPublisherHere.systemd.services.swarm-secret-publish.script
|
secretPublisherHere.systemd.services.swarm-secret-publish.script
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
{
|
||||||
|
# The third swarm service on that list, and the one whose absence is
|
||||||
|
# hardest to see: the store's own forwarder holds a client authelia
|
||||||
|
# registers and mints for, so every layer above looks complete while
|
||||||
|
# the one hop that CARRIES the value skips it and the reader on the
|
||||||
|
# store's host waits forever on a path nothing writes.
|
||||||
|
name = "the publisher carries the store forwarder's secret too";
|
||||||
|
ok = lib.hasInfix "secret/swarm/services/swarm-bao-collector/oidc/client" (
|
||||||
|
secretPublisherHere.systemd.services.swarm-secret-publish.script
|
||||||
|
);
|
||||||
|
}
|
||||||
{
|
{
|
||||||
# The same hole the controller's case above names, open a second time: the
|
# 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.
|
# PKI script grew a third leaf and no case read it.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue