eleven / messenger Public

deploy: bootstrap pre-creates the collector state dirs — a missing ReadWritePaths bricks the unit (#1159)

First real-box run of lchat-hardening.service failed at namespace setup:
/data/hardening didn't exist, and on the fleet's systemd the unit's
ExecStartPre=+mkdir can't run either — the + prefix doesn't escape mount-
namespace construction, so the dir the namespace needs can never be created
by the unit itself. bootstrap (and --collectors-only) now creates both
collector state dirs as root before the timers are enabled.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell fbbaeb512ec1a3573c01bd2fed135ad6e1425b9d parent 16a031c
2 files changed, +27 −4
  • bootstrap.go +15 −3
  • bootstrap_test.go +12 −1
diff --git a/bootstrap.go b/bootstrap.go
index a349319..1b8628b 100644
--- a/bootstrap.go
+++ b/bootstrap.go
@@ -200,7 +200,19 @@ var collectorTimers = []string{"lchat-ssh-ledger.timer", "lchat-hardening.timer"
// appDir is the on-box install root (/opt/lchat): each unit's ExecStart points at
// <app-dir>/<script>, and the collectors upload the pages from <app-dir>/status-
// pages. unitDir is the systemd unit directory.
-func writeCollectorFiles(appDir, unitDir string) {
+func writeCollectorFiles(appDir, unitDir, dataDir string) {
+ // The collectors' state dirs must exist BEFORE their units first start:
+ // each unit lists its dir in ReadWritePaths, and on the fleet's systemd a
+ // missing ReadWritePaths target fails namespace setup for the WHOLE unit —
+ // including the `ExecStartPre=+mkdir` that was supposed to create it (the
+ // `+` doesn't escape mount-namespace construction there). Found on the
+ // hardening collector's first real-box run, 2026-07-19.
+ for _, d := range []string{filepath.Join(dataDir, "ssh-ledger"), filepath.Join(dataDir, "hardening")} {
+ if err := os.MkdirAll(d, 0o755); err != nil {
+ die("bootstrap: mkdir %s: %v", d, err)
+ }
+ }
+
// The scripts are executable — their units fire them via ExecStart.
writeFile(filepath.Join(appDir, "ssh-ledger-collect.sh"), scriptSSHLedger, 0o755)
writeFile(filepath.Join(appDir, "hardening-posture.sh"), scriptHardening, 0o755)
@@ -263,7 +275,7 @@ func runBootstrap(args []string) {
// install root, /opt/lchat) is where the collectors' units expect their
// scripts + status-pages dir.
if *collectorsOnly {
- writeCollectorFiles(optDir, *unitDir)
+ writeCollectorFiles(optDir, *unitDir, *dataDir)
if err := systemctl("daemon-reload"); err != nil {
die("bootstrap: %v", err)
}
@@ -311,7 +323,7 @@ func runBootstrap(args []string) {
// The status collectors ride along here so the single daemon-reload below
// registers their units too; --no-start still lays down the files (like every
// other unit), and the started path enables their timers.
- writeCollectorFiles(optDir, *unitDir)
+ writeCollectorFiles(optDir, *unitDir, *dataDir)
if err := ensureControlToken(); err != nil {
die("bootstrap: control token: %v", err)
diff --git a/bootstrap_test.go b/bootstrap_test.go
index 1f0a807..cc41ace 100644
--- a/bootstrap_test.go
+++ b/bootstrap_test.go
@@ -74,8 +74,19 @@ func TestEmbeddedCollectors(t *testing.T) {
func TestWriteCollectorFiles(t *testing.T) {
appDir := t.TempDir()
unitDir := t.TempDir()
+ dataDir := t.TempDir()
- writeCollectorFiles(appDir, unitDir)
+ writeCollectorFiles(appDir, unitDir, dataDir)
+
+ // The collectors' state dirs must exist before their units first start: a
+ // missing ReadWritePaths target fails the unit's namespace setup — even its
+ // `+`-prefixed ExecStartPre mkdir (the first-real-run failure, 2026-07-19).
+ for _, d := range []string{"ssh-ledger", "hardening"} {
+ info, err := os.Stat(filepath.Join(dataDir, d))
+ if err != nil || !info.IsDir() {
+ t.Errorf("collector state dir %s not created: %v", d, err)
+ }
+ }
// Scripts: present, executable, and byte-identical to the embedded source.
for _, tc := range []struct {