From 1a4dc160535bfb4451d518c0c580e1f8bcde3bd8 Mon Sep 17 00:00:00 2001 From: atlas Date: Mon, 5 Oct 2026 20:47:20 +0200 Subject: [PATCH] hive TLS: reload the trust bundle's unit on a switch, name the missing root when it's empty A switch that changed `hive-tls-ca` stopped and started it, and the stop propagated through `Requires=` to the store's container (sealed again under the shamir seal), every container binding the bundle, and every `-ca-bundle` unit with its consumers. `reloadIfChanged` with the same script as `ExecReload` rewrites the bundle in place and restarts none of them. `Requires=` stays, so a failed assembly still keeps them from starting. With `swarm-services-root.pem` missing, `hive-tls-ca` writes an empty bundle and every `-ca-bundle` unit fails. Its log line now names the root's path. docs/swarm/ca.md describes both. --- docs/swarm/ca.md | 17 +++++++++++++++-- nix/host-modules/hive-tls.nix | 13 ++++++++++++- nix/module-eval/hive-tls.nix | 26 ++++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 3 deletions(-) diff --git a/docs/swarm/ca.md b/docs/swarm/ca.md index 8cf0c637..96a5f564 100644 --- a/docs/swarm/ca.md +++ b/docs/swarm/ca.md @@ -77,8 +77,21 @@ issues it. `hive-tls-ca.service` writes `/trust-bundle.pem`, the file every consumer anchors on. It holds one certificate, a copy of `swarm-services-root.pem`: the store's `pki` root, which the gateway leaf and -the swarm-services leaf both chain to. It's the only root the swarm has. Until -the first fetch of that root lands, the bundle is empty. +the swarm-services leaf both chain to. It's the only root the swarm has. + +**Without the store's root, the bundle is empty and its readers go red.** +While `/swarm-services-root.pem` is +missing or empty, as on a first boot before the store answers, +`hive-tls-ca.service` writes an empty bundle and logs that file's path. Every +`-ca-bundle` unit then fails with ` contributed no certificate +to the bundle`, and the consumers that `Requires=` it don't start. +`swarm-services-cert.service` restarts `hive-tls-ca.service` when it writes a +new root. + +A deploy that changes `hive-tls-ca.service` reloads it: the same script +rewrites the bundle in place, and nothing that `Requires=` the unit restarts, +including the store's container. A `-ca-bundle` unit keeps the copy it +made when it started. ⚠️ **A consumer reads its trust when it starts.** An agent or service container that last started before the store's root reached the bundle diff --git a/nix/host-modules/hive-tls.nix b/nix/host-modules/hive-tls.nix index ef70e92d..6055615b 100644 --- a/nix/host-modules/hive-tls.nix +++ b/nix/host-modules/hive-tls.nix @@ -362,6 +362,17 @@ in # "$svcroot" ]` guard is what makes that state legal rather than fatal, # and `swarm-services-cert` restarts this unit once the root it wrote # is new — so the bundle converges instead of being a boot behind. + # + # ⚠️ A switch that changes this unit reloads it, running the same + # script, rather than stopping and starting it. A stop propagates to + # every unit that `Requires=` this one: the store's container, which + # comes back sealed under the `shamir` seal, every container that + # binds the bundle, and every `-ca-bundle` unit with its + # consumers. A reload rewrites the bundle in place and touches none of + # them, so a `-ca-bundle` unit keeps the copy it made at start + # until it restarts. + reloadIfChanged = true; + reload = config.systemd.services.hive-tls-ca.script; serviceConfig = { Type = "oneshot"; RemainAfterExit = true; @@ -387,7 +398,7 @@ in if [ -s "$svcroot" ]; then cat "$svcroot" > "$bundle" else - echo "no trust anchor on disk yet — $bundle is empty until the store's root is fetched" >&2 + echo "the store's root $svcroot is missing or empty, so $bundle is empty and every *-ca-bundle unit fails until swarm-services-cert fetches it" >&2 : > "$bundle" fi chmod 0644 "$bundle" diff --git a/nix/module-eval/hive-tls.nix b/nix/module-eval/hive-tls.nix index ec07ea8f..e14caf46 100644 --- a/nix/module-eval/hive-tls.nix +++ b/nix/module-eval/hive-tls.nix @@ -281,6 +281,32 @@ let && !(lib.hasInfix "anchors+=(" caUnit.script) && !(lib.hasInfix "ca.pem" caUnit.script); } + { + # A reload does not propagate through `Requires=`; a stop and start + # restarts the store's container, which comes back sealed. The control + # is that container still requiring the unit, so a failed assembly + # still keeps it from starting. + name = "a switch that changes the trust bundle's unit reloads it with the same script, so the store's container is not restarted"; + ok = + let + text = allLocal.systemd.units."hive-tls-ca.service".text; + store = units."container@swarm-bao"; + in + caUnit.reloadIfChanged + && caUnit.reload == caUnit.script + && lib.hasInfix "X-ReloadIfChanged=true" text + && lib.hasInfix "ExecReload=" text + && lib.elem "hive-tls-ca.service" store.requires + && lib.elem "hive-tls-ca.service" store.after; + } + { + # A missing root is the one case where the bundle is empty, and every + # `-ca-bundle` unit fails on it; the log names the file. + name = "an empty trust bundle is logged naming the store's root it is copied from"; + ok = lib.hasInfix ''echo "the store's root $svcroot is missing or empty'' ( + lib.last (lib.splitString ''if [ -s "$svcroot" ]; then'' caUnit.script) + ); + } { # The control is the all-local host, which defines none of them. name = "a definition of a removed swarm.ca option fails evaluation, naming the option and the store's root";