Say an apply's report even when the apply ends the link

A host that delivered its own successor stood aside before the report of
that apply was published, so it failed with 'context canceled' and the
release plan waited for a report that never came (novox/hq issue 264).
Reports are now published on a context the stand-aside does not cancel,
and the last apply's report is kept until the broker takes it and said
again on the next link, so a crash between apply and report is covered too.
This commit is contained in:
jochen
2026-10-06 00:30:15 +02:00
parent 07430240bb
commit f62ee0bc75
5 changed files with 447 additions and 11 deletions
+44 -2
View File
@@ -1068,7 +1068,7 @@ func runLink(ctx context.Context, opts options) error {
// Asked with the version this host is RUNNING, read from where it sits — not the link-time
// stamp, which every delivered host carries as "development build". Asked with the stamp,
// a delivered host never matched the newest delivered version, so it stood aside on every
// push for ever, and standing aside cancels the report, so the mesh never heard from it
// push for ever, and standing aside then cancelled the report, so the mesh never heard from it
// again (novox/hq 04-ISSUES/163).
switch next, waiting, err := upgrade.Successor(upgrade.VersionsDir(""), runningVersion()); {
case err != nil:
@@ -1117,7 +1117,7 @@ func runLink(ctx context.Context, opts options) error {
Password: mine.Membership.Password,
Transport: mine.Membership.Transport,
Signer: mine.Membership.Signer,
}, applier, say, opts.timeout, rousedBySignal(ctx), outbox)
}, applier, say, opts.timeout, rousedBySignal(ctx), outbox, unsaidBeside(opts.state))
// **Cleanly**, or the launcher counts standing aside as a crash and rolls the new host back
// before it has run once. The context this returns on was cancelled deliberately, so its error
// is not a fault to report.
@@ -1127,6 +1127,48 @@ func runLink(ctx context.Context, opts options) error {
return held
}
// unsaidFile keeps the last apply's report beside the node's state until the mesh has taken it
// (novox/hq issue 264), so a report lost when this host stood aside for its successor — or died
// between applying and publishing — is said by whichever host runs next, once it is linked.
type unsaidFile struct{ path string }
func unsaidBeside(statePath string) unsaidFile { return unsaidFile{path: store.UnsaidPath(statePath)} }
func (u unsaidFile) Keep(r link.Report) error {
// A report naming no declaration is one the mesh cannot match to what it sent, and keeping it
// would only re-say an older apply after a newer one was refused.
if r.Declared == "" {
return store.ClearUnsaid(u.path)
}
body, err := json.Marshal(r)
if err != nil {
return err
}
return store.SaveUnsaid(u.path, body)
}
func (u unsaidFile) Said(declared string) error {
kept, ok, err := u.Pending()
if err != nil || !ok || kept.Declared != declared {
return err
}
return store.ClearUnsaid(u.path)
}
func (u unsaidFile) Pending() (link.Report, bool, error) {
raw, err := store.ReadUnsaid(u.path)
if err != nil || raw == nil {
return link.Report{}, false, err
}
var r link.Report
if err := json.Unmarshal(raw, &r); err != nil {
// Unreadable is forgotten rather than kept for ever: it can never be said.
_ = store.ClearUnsaid(u.path)
return link.Report{}, false, fmt.Errorf("the kept report at %s is unreadable, and is dropped: %w", u.path, err)
}
return r, r.Declared != "", nil
}
// adoptionWatch remembers what the node last said about what it holds and its firewall, so a
// reconcile speaks unasked only when that changed.
type adoptionWatch struct {
+64
View File
@@ -0,0 +1,64 @@
package main
import (
"os"
"path/filepath"
"reflect"
"testing"
"github.com/novox/mesh-host/internal/link"
"github.com/novox/mesh-host/internal/store"
)
// The last apply's report, kept beside the state until the mesh takes it (novox/hq issue 264).
// What a host that died between applying and reporting kept is read back by the next one exactly —
// the digest and the outcome, never a new account of the machine.
func TestTheKeptReportSurvivesTheHost(t *testing.T) {
state := filepath.Join(t.TempDir(), "state.json")
made := link.Report{Declared: "sha256:d1", Applied: []string{"a"}, Failed: map[string]string{"b": "no room"},
Host: "v2"}
if err := unsaidBeside(state).Keep(made); err != nil {
t.Fatal(err)
}
kept, ok, err := unsaidBeside(state).Pending()
if err != nil || !ok {
t.Fatalf("nothing kept: %v", err)
}
if !reflect.DeepEqual(kept, made) {
t.Fatalf("kept %+v, made %+v", kept, made)
}
// Taken about another declaration, it stays; taken about this one, it goes.
if err := unsaidBeside(state).Said("sha256:other"); err != nil {
t.Fatal(err)
}
if _, ok, _ := unsaidBeside(state).Pending(); !ok {
t.Fatal("a report about another declaration cleared this one")
}
if err := unsaidBeside(state).Said("sha256:d1"); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(store.UnsaidPath(state)); !os.IsNotExist(err) {
t.Fatalf("the report was taken and is still kept: %v", err)
}
}
// A node that never applied anything has nothing to say again; and an apply refused before its
// declaration was read clears an older report, which would otherwise be re-said after it.
func TestNothingKeptWhenNothingWasApplied(t *testing.T) {
state := filepath.Join(t.TempDir(), "state.json")
if _, ok, err := unsaidBeside(state).Pending(); ok || err != nil {
t.Fatalf("a node that applied nothing has a report to say: %v %v", ok, err)
}
if err := unsaidBeside(state).Keep(link.Report{Declared: "sha256:d1"}); err != nil {
t.Fatal(err)
}
if err := unsaidBeside(state).Keep(link.Report{Refused: "forged"}); err != nil {
t.Fatal(err)
}
if _, ok, _ := unsaidBeside(state).Pending(); ok {
t.Fatal("an older report is kept after a later declaration was refused")
}
}