Watch
0
0
Fork
You've already forked hyperhive
0

swarm-otel: let the collector write its socket dir, and probe every route through nginx

This commit is contained in:
atlas 2026-10-24 21:12:35 +02:00 • committed by mara
commit 41bea3c6d9
3 changed files with 104 additions and 6 deletions

View file

@ -337,8 +337,13 @@ the swarm tier's and one per service forwarder) listens on a unix socket in
open, and the gateway forwards to them. The gateway's routes and the collector's open, and the gateway forwards to them. The gateway's routes and the collector's
receivers come from one list, so they name the same sockets. The receivers come from one list, so they name the same sockets. The
`swarm-otel-sockets` unit connects to each socket as the gateway's user after `swarm-otel-sockets` unit connects to each socket as the gateway's user after
the container starts and fails if one refuses for 30 seconds, so a collector the container starts, then requests every route through the gateway vhost. It
that's up but not listening shows as a failed unit. fails if a socket refuses for 30 seconds, or if a route answers `000`, `502` or
`504` for 30 seconds, so a collector that's up but not listening, or an nginx
that doesn't route to it, shows as a failed unit. Any other status passes, the
collector's 4xx included. A switch leaves a window of about 20 seconds in which
nginx and the container are out of step: the container stops before nginx
restarts and starts about 18 seconds after it. Senders retry through that window.
Every hive's own collector reaches this one by its gateway name, Every hive's own collector reaches this one by its gateway name,
`swarm.otel.domain` (default `otel.<swarm domain>`) — the same `swarm.otel.domain` (default `otel.<swarm domain>`) — the same

View file

@ -697,12 +697,17 @@ in
# A collector that is up but not listening on a socket the gateway names # A collector that is up but not listening on a socket the gateway names
# answers every producer with a 502 and fails no unit. This one fails when # answers every producer with a 502 and fails no unit. This one fails when
# any receiver socket refuses a connection for 30s, so the switch reports # any receiver socket refuses a connection for 30s, or when the running
# it. It runs as the gateway's user: a socket nginx cannot open fails too. # nginx cannot reach one, so the switch reports it. It runs as the
# Any HTTP status counts as accepted; only a failed connect is a failure. # gateway's user: a socket nginx cannot open fails too. On the socket any
# HTTP status counts as accepted. Through nginx 000, 502 and 504 fail and
# every other status passes, a collector's 4xx included.
systemd.services.swarm-otel-sockets = { systemd.services.swarm-otel-sockets = {
description = "wait for every swarm collector receiver socket to accept a connection"; description = "wait for every swarm collector receiver socket to accept a connection";
after = [ "container@${cfg.machine}.service" ]; after = [
"container@${cfg.machine}.service"
"nginx.service"
];
wants = [ "container@${cfg.machine}.service" ]; wants = [ "container@${cfg.machine}.service" ];
# Restarted with the container, so every start of the collector is # Restarted with the container, so every start of the collector is
# checked and not only the first one after a boot. # checked and not only the first one after a boot.
@ -727,6 +732,19 @@ in
echo "no connection to $sock after 30s" >&2 echo "no connection to $sock after 30s" >&2
exit 1 exit 1
done done
# The running nginx, not its config. `--resolve` pins the vhost to
# this host's gateway.
for route in ${lib.escapeShellArgs (lib.attrNames receiverSockets)}; do
for _ in $(seq 30); do
code=$(curl -sk --max-time 5 --resolve ${cfg.domain}:443:127.0.0.1 -o /dev/null -w '%{http_code}' \
"https://${cfg.domain}/$route/") || code=000
case $code in 000 | 502 | 504) ;; *) continue 2 ;; esac
sleep 1
done
echo "https://${cfg.domain}/$route/ answered $code after 30s" >&2
exit 1
done
''; '';
}; };
@ -1666,6 +1684,10 @@ in
# `0007` makes each socket `0770`: group nginx can connect. # `0007` makes each socket `0770`: group nginx can connect.
SupplementaryGroups = [ socketGroup ]; SupplementaryGroups = [ socketGroup ];
UMask = "0007"; UMask = "0007";
# `DynamicUser` forces `ProtectSystem=strict` over upstream's
# `full`, so `/run` is read-only in the unit and the socket
# directory's writable bind mount does not reach it.
ReadWritePaths = [ socketDir ];
# A socket left by a killed process would fail the next `bind`. # A socket left by a killed process would fail the next `bind`.
ExecStartPre = [ "${pkgs.coreutils}/bin/rm -f ${lib.escapeShellArgs allSockets}" ]; ExecStartPre = [ "${pkgs.coreutils}/bin/rm -f ${lib.escapeShellArgs allSockets}" ];
}; };

View file

@ -920,6 +920,77 @@ let
&& lib.elem "container@swarm-otel.service" u.partOf && lib.elem "container@swarm-otel.service" u.partOf
&& lib.elem "multi-user.target" u.wantedBy; && lib.elem "multi-user.target" u.wantedBy;
} }
{
# `DynamicUser` forces `ProtectSystem=strict`, which leaves `/run`
# read-only unless the path is in `ReadWritePaths`. The controls: the
# unit really is DynamicUser, and a copy without the path, or with some
# other path, is read-only.
name = "the collector unit can write the directory its receiver sockets are created in";
ok =
let
host = serviceContainersWithCi;
sc = host.containers.swarm-otel.config.systemd.services.opentelemetry-collector.serviceConfig;
s = host.containers.swarm-otel.config.services.opentelemetry-collector.settings;
socks = map (r: r.protocols.http.endpoint) (
lib.filter (r: (r.protocols.http.transport or null) == "unix") (lib.attrValues s.receivers)
);
writes =
c: sock:
let
strict = (c.DynamicUser or false) || (c.ProtectSystem or "no") == "strict";
dir = dirOf sock;
under = p: p == dir || lib.hasPrefix "${p}/" dir;
in
!strict || lib.any under (c.ReadWritePaths or [ ]);
in
socks != [ ]
&& sc.DynamicUser
&& lib.all (writes sc) socks
&& !(lib.any (writes (sc // { ReadWritePaths = [ ]; })) socks)
&& !(lib.any (writes (sc // { ReadWritePaths = [ "/var/lib/opentelemetry-collector" ]; })) socks);
}
{
# The probe loop names each gateway route, and the routes are read from
# the vhost, not from the receivers the probe is built from. A script
# missing one route, or treating 502 as a pass, is rejected.
name = "the readiness unit probes every collector route through the gateway";
ok =
let
host = serviceContainersWithCi;
otel = host.services.hyperhive.swarm.otel;
locs = host.services.nginx.virtualHosts.${otel.domain}.locations;
routes = lib.sort lib.lessThan (
map (l: lib.removeSuffix "/" (lib.removePrefix "/" l)) (
lib.filter (l: (locs.${l}.proxyPass or null) != null) (lib.attrNames locs)
)
);
lines = script: map (lib.removePrefix " ") (lib.splitString "\n" script);
probed =
script:
let
loop = lib.findFirst (l: lib.hasPrefix "for route in " l) "" (lines script);
in
lib.sort lib.lessThan (
lib.filter (w: w != "") (
lib.splitString " " (lib.removeSuffix "; do" (lib.removePrefix "for route in " loop))
)
);
verdict =
script:
probed script == routes
&& lib.hasInfix "--resolve ${otel.domain}:443:127.0.0.1" script
&& lib.hasInfix "https://${otel.domain}/$route/" script
&& lib.hasInfix "case $code in 000 | 502 | 504) ;;" script;
script = host.systemd.services.swarm-otel-sockets.script;
in
lib.length routes > 2
&& lib.elem "h1" routes
&& lib.elem "swarm-grafana" routes
&& verdict script
&& !verdict (lib.replaceStrings [ " swarm-grafana" ] [ "" ] script)
&& !verdict (lib.replaceStrings [ "000 | 502 | 504" ] [ "000 | 504" ] script)
&& !verdict (lib.replaceStrings [ "--resolve" ] [ "--noresolve" ] script);
}
{ {
# The DynamicUser layout keeps the history under /var/lib/private; the # The DynamicUser layout keeps the history under /var/lib/private; the
# static user's StateDirectory is the plain path. The move runs as its # static user's StateDirectory is the plain path. The move runs as its