diff --git a/cmd/mesh-host/main.go b/cmd/mesh-host/main.go index 183755d..a1ae9a2 100644 --- a/cmd/mesh-host/main.go +++ b/cmd/mesh-host/main.go @@ -1246,6 +1246,21 @@ func applyDeclared(ctx context.Context, opts options, raw []byte, sched *apply.S return applyAndKeep(ctx, opts, raw, nil, sched, say) } +// announceOr is what the apply writes its detail with, given what the caller has to say things with. +// +// **Never nil.** This argument was nil on the serving path, and nil is silence: everything the apply +// says — a file held, a container replaced, the found firewall retired — was visible when a person ran +// the one-shot command and discarded in the way the host actually runs (novox/hq 04-ISSUES/143). +// +// Named rather than written inline at the call site so both paths reach the apply the same way, and so +// a reader asking "where does the apply's output go" finds one answer. +func announceOr(say link.Announce) func(string) { + if say == nil { + return func(string) {} + } + return say +} + // applying serialises applies within this process. // // **Two things apply here: the link and the reconcile loop**, and each reads the node's state, @@ -1308,8 +1323,18 @@ func applyAndKeep(ctx context.Context, opts options, raw []byte, signed *store.D // Declared, not carried. A declaration from the mesh removes only what the mesh previously // declared — never what this machine raised for itself from its bundle (04-ISSUES/010). + // **What the apply says goes to the console, which is the journal when this runs as a service.** + // + // It was nil, and nil is silence. The one-shot path has always passed a real one, so every detail + // the apply produces — a file held, a container replaced, the found firewall retired — was visible + // when a person ran it by hand and discarded in the way the host actually runs. Measured: after a + // machine was converged and its found firewall was not retired, what the host decided was + // unrecoverable, because it had said it to nobody (novox/hq 04-ISSUES/143). + // + // `say` already reaches stdout, and the launcher's unit sends that to the journal, so this needs + // no new mechanism — only for the argument to be passed. outcome, updated, applyErr := apply.ApplyKeeping(ctx, built, declared, known, store.OriginDeclared, - apply.ExecRunner, nil, sealOpener(opts.state), apply.KeepIn(filepath.Dir(opts.state))) + apply.ExecRunner, announceOr(say), sealOpener(opts.state), apply.KeepIn(filepath.Dir(opts.state))) // The mode the mesh said, recorded whichever way the apply went: the declaration is kept // either way, and the node is held to it from the next reconcile (novox/hq ADR 0100). diff --git a/cmd/mesh-host/main_test.go b/cmd/mesh-host/main_test.go index d5a27d1..ae91b75 100644 --- a/cmd/mesh-host/main_test.go +++ b/cmd/mesh-host/main_test.go @@ -568,3 +568,28 @@ func TestAConvergedMachineSaysItsOutwardLinksUnasked(t *testing.T) { t.Fatal("a refused report is offered as news about the machine") } } + +// **A host running as a service says what its apply did.** +// +// The serving path passed nil where the apply writes its detail, and nil is silence. The one-shot path +// has always passed a real function, so everything the apply says was visible when a person ran it by +// hand and discarded in the way the host actually runs. Measured before this was written: a machine was +// converged, its found firewall was not retired, and what the host decided was unrecoverable because it +// had been said to nobody (novox/hq 04-ISSUES/143). +// +// This asserts only that the apply's log is never nil and that a line reaches what the caller gave. +// **It cannot catch the fault it was written for** — a call site passing nil directly — because that is +// wiring, and wiring is only proved by running the thing. That proof is a deployed host whose journal +// carries the apply's detail, which is how this fix was verified. +func TestTheApplysLogIsNeverNil(t *testing.T) { + if announceOr(nil) == nil { + t.Fatal("a host with nowhere to say things got a nil log, which the apply will call") + } + announceOr(nil)("this goes nowhere and must not panic") + + var said []string + announceOr(func(line string) { said = append(said, line) })(" disabled ufw") + if len(said) != 1 || !strings.Contains(said[0], "disabled ufw") { + t.Fatalf("the apply's detail did not reach the caller's announce: %v", said) + } +}