From 622b6b4f556da88f0f944f073a3cdeca8326ef03 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 10 Oct 2026 21:06:44 +0200 Subject: [PATCH 1/2] Add go/health: a health tool answers healthy, unhealthy or waits (hq ADR 0283, issue 386) A module waiting for the operator's secret had only 'not healthy' to say, so its first-node gate failed and the operator was told nothing to do. The answer gains a state and named waits, and Read refuses any wait it cannot check, so a malformed wait never excuses a fault. --- go/health/health.go | 170 +++++++++++++++++++++++++++++++++++++++ go/health/health_test.go | 118 +++++++++++++++++++++++++++ 2 files changed, 288 insertions(+) create mode 100644 go/health/health.go create mode 100644 go/health/health_test.go diff --git a/go/health/health.go b/go/health/health.go new file mode 100644 index 0000000..29a9fe6 --- /dev/null +++ b/go/health/health.go @@ -0,0 +1,170 @@ +// Package health is the answer a module's health tool gives, and how the node-engine reads it (novox/hq ADR 0240 +// rule 2, ADR 0283). +// +// A health tool answers one of three: +// +// - healthy: everything the module runs works; +// - unhealthy: something is wrong, said in Why; +// - waits: nothing is wrong but a part that cannot work until the operator gives something — one of the module's +// own secrets, or one of its settings. Each Wait names exactly one of them, the part, and what the operator +// gives, in words with no path, address or value. +// +// The answer keeps `healthy` beside `state`, so a node-engine older than ADR 0283 reads a wait as not healthy, as +// it did before. An answer without `state` is read from `healthy`. +// +// The module writes its answer with Healthy, Unhealthy or Waiting, and the node-engine reads it with Read. Read +// never trusts a shape it cannot check: a wait it cannot read makes the answer unhealthy, saying why. +package health + +import ( + "encoding/json" + "fmt" + "regexp" + "strings" +) + +// The three states an answer says. +const ( + StateHealthy = "healthy" + StateUnhealthy = "unhealthy" + StateWaits = "waits" +) + +// Answer is a health tool's answer. +type Answer struct { + // State is healthy, unhealthy or waits. + State string `json:"state"` + // Healthy is State == healthy, kept for a reader older than State. + Healthy bool `json:"healthy"` + // Why is the answer in one or more plain sentences. + Why string `json:"why"` + // Waits is what the module waits for, when State is waits; and, beside a fault, what it also waits for. + Waits []Wait `json:"waits,omitempty"` +} + +// Wait is one thing a module waits for the operator to give: exactly one of Secret and Setting. +type Wait struct { + // Part is what waits, in words: "the source games". + Part string `json:"part"` + // Secret is one of the module's own secrets, by name (a member of a secret family by its full name). + Secret string `json:"secret,omitempty"` + // Setting is one of the module's settings, by name. + Setting string `json:"setting,omitempty"` + // What is what the operator gives, in words: "the password of the source games". + What string `json:"what"` +} + +// Healthy is a healthy answer. +func Healthy(why string) Answer { + return Answer{State: StateHealthy, Healthy: true, Why: why} +} + +// Unhealthy is an unhealthy answer. Waits may be given beside the fault: they are said, and excuse nothing. +func Unhealthy(why string, waits ...Wait) Answer { + return Answer{State: StateUnhealthy, Why: why, Waits: waits} +} + +// Waiting is an answer that says nothing is wrong but what the operator has yet to give. With no wait it is +// unhealthy: a module that waits names what for. +func Waiting(why string, waits ...Wait) Answer { + if len(waits) == 0 { + return Unhealthy(orSaid(why, "it says it waits") + " (and names nothing it waits for)") + } + return Answer{State: StateWaits, Why: why, Waits: waits} +} + +// name is a secret's or a setting's name: what a manifest may declare. +var name = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`) + +// looksLikeAddress finds an IPv4 address, a URL, or a path in words meant for the operator. +var looksLikeAddress = regexp.MustCompile(`\b[0-9]{1,3}(\.[0-9]{1,3}){3}\b|://|(^|\s)/[^\s]`) + +// Check says why a wait cannot be read, or nil. +func (w Wait) Check() error { + switch { + case w.Secret != "" && w.Setting != "": + return fmt.Errorf("a wait names both the secret %q and the setting %q; it names one", w.Secret, w.Setting) + case w.Secret == "" && w.Setting == "": + return fmt.Errorf("a wait names neither a secret nor a setting") + case w.Secret != "" && !name.MatchString(w.Secret): + return fmt.Errorf("a wait names the secret %q, which is not a name", w.Secret) + case w.Setting != "" && !name.MatchString(w.Setting): + return fmt.Errorf("a wait names the setting %q, which is not a name", w.Setting) + case strings.TrimSpace(w.What) == "": + return fmt.Errorf("a wait for %s does not say what the operator gives", w.Names()) + case strings.TrimSpace(w.Part) == "": + return fmt.Errorf("a wait for %s does not say which part waits", w.Names()) + } + for _, words := range []string{w.What, w.Part} { + if strings.ContainsAny(words, "\n\r") { + return fmt.Errorf("a wait for %s says more than one line", w.Names()) + } + if looksLikeAddress.MatchString(words) { + return fmt.Errorf("a wait for %s says a path or an address, which the operator's words never carry", w.Names()) + } + } + return nil +} + +// Names is what the wait names, as "the secret x" or "the setting y". +func (w Wait) Names() string { + if w.Secret != "" { + return "the secret " + w.Secret + } + if w.Setting != "" { + return "the setting " + w.Setting + } + return "nothing" +} + +// Read reads a health tool's result. An answer it cannot read at all is an error; an answer whose wait it cannot +// read is unhealthy, saying why, so a malformed wait never excuses anything. +func Read(result []byte) (Answer, error) { + var a Answer + if err := json.Unmarshal(result, &a); err != nil { + return Answer{}, fmt.Errorf("it answered something other than healthy or not: %w", err) + } + switch a.State { + case "": + if a.Healthy { + a.State = StateHealthy + } else { + a.State = StateUnhealthy + } + case StateHealthy: + // A healthy module waits for nothing: a wait beside healthy is a contradiction, and is dropped. + a.Healthy, a.Waits = true, nil + case StateUnhealthy: + a.Healthy = false + case StateWaits: + a.Healthy = false + if len(a.Waits) == 0 { + return Unhealthy(orSaid(a.Why, "it says it waits") + " (and names nothing it waits for)"), nil + } + for _, w := range a.Waits { + if err := w.Check(); err != nil { + return Unhealthy(orSaid(a.Why, "it says it waits") + " (" + err.Error() + ")"), nil + } + } + default: + return Unhealthy(fmt.Sprintf("it answered the state %q, which is none of healthy, unhealthy or waits", a.State)), nil + } + if a.State != StateWaits { + // Waits beside a fault are said and excuse nothing; one that cannot be read is dropped, not believed. + var kept []Wait + for _, w := range a.Waits { + if w.Check() == nil { + kept = append(kept, w) + } + } + a.Waits = kept + } + return a, nil +} + +func orSaid(s, otherwise string) string { + if strings.TrimSpace(s) == "" { + return otherwise + } + return s +} diff --git a/go/health/health_test.go b/go/health/health_test.go new file mode 100644 index 0000000..8bcc0ef --- /dev/null +++ b/go/health/health_test.go @@ -0,0 +1,118 @@ +package health + +import ( + "encoding/json" + "strings" + "testing" +) + +func roundTrip(t *testing.T, a Answer) Answer { + t.Helper() + b, err := json.Marshal(a) + if err != nil { + t.Fatal(err) + } + got, err := Read(b) + if err != nil { + t.Fatal(err) + } + return got +} + +var password = Wait{Part: "the source games", Secret: "smb-password-games", What: "the password of the source games"} + +func TestEachConstructorSaysItsStateAndKeepsHealthyForAnOlderReader(t *testing.T) { + for _, c := range []struct { + a Answer + state string + healthy bool + }{ + {Healthy("all armed"), StateHealthy, true}, + {Unhealthy("the share is unreachable"), StateUnhealthy, false}, + {Waiting("the source games waits for its password", password), StateWaits, false}, + } { + b, _ := json.Marshal(c.a) + var old struct { + Healthy bool `json:"healthy"` + Why string `json:"why"` + } + if err := json.Unmarshal(b, &old); err != nil || old.Healthy != c.healthy || old.Why == "" { + t.Fatalf("an older reader of %s reads %+v (%v)", b, old, err) + } + if got := roundTrip(t, c.a); got.State != c.state || got.Healthy != c.healthy { + t.Fatalf("%s read as %+v", b, got) + } + } +} + +func TestAWaitIsReadWithWhatItNames(t *testing.T) { + got := roundTrip(t, Waiting("waits", password, Wait{Part: "the source games", Setting: "smb-users", What: "the username of the source games"})) + if got.State != StateWaits || len(got.Waits) != 2 || got.Waits[0].Secret != "smb-password-games" || got.Waits[1].Setting != "smb-users" { + t.Fatalf("read as %+v", got) + } +} + +func TestAnAnswerWithoutStateIsReadFromHealthy(t *testing.T) { + for raw, want := range map[string]string{`{"healthy":true,"why":"ok"}`: StateHealthy, `{"healthy":false,"why":"down"}`: StateUnhealthy} { + got, err := Read([]byte(raw)) + if err != nil || got.State != want { + t.Fatalf("%s read as %+v, %v", raw, got, err) + } + } +} + +func TestAWaitThatCannotBeReadIsUnhealthyAndSaysWhy(t *testing.T) { + for name, c := range map[string]struct { + w []Wait + says string + }{ + "no wait": {nil, "names nothing"}, + "neither": {[]Wait{{Part: "p", What: "w"}}, "neither a secret nor a setting"}, + "both": {[]Wait{{Part: "p", Secret: "a", Setting: "b", What: "w"}}, "both"}, + "not a name": {[]Wait{{Part: "p", Secret: "Smb Password", What: "w"}}, "not a name"}, + "no what": {[]Wait{{Part: "p", Secret: "a"}}, "does not say what"}, + "no part": {[]Wait{{Secret: "a", What: "w"}}, "which part"}, + "two lines": {[]Wait{{Part: "p", Secret: "a", What: "the password\nhunter2"}}, "more than one line"}, + "an address": {[]Wait{{Part: "p", Secret: "a", What: "the password of 192.0.2.76"}}, "path or an address"}, + "a url": {[]Wait{{Part: "p", Secret: "a", What: "the password of smb://host/share"}}, "path or an address"}, + "a path": {[]Wait{{Part: "the source at /mnt/games", Secret: "a", What: "the password"}}, "path or an address"}, + } { + b, _ := json.Marshal(Answer{State: StateWaits, Why: "it waits", Waits: c.w}) + got, err := Read(b) + if err != nil { + t.Fatalf("%s: %v", name, err) + } + if got.State != StateUnhealthy || got.Healthy || !strings.Contains(got.Why, c.says) { + t.Fatalf("%s: read as %+v, want unhealthy saying %q", name, got, c.says) + } + } +} + +func TestAnUnknownStateIsUnhealthy(t *testing.T) { + got, err := Read([]byte(`{"state":"fine","healthy":true}`)) + if err != nil || got.State != StateUnhealthy || got.Healthy { + t.Fatalf("read as %+v, %v", got, err) + } +} + +func TestWaitsBesideAFaultAreSaidAndExcuseNothing(t *testing.T) { + got := roundTrip(t, Unhealthy("the share is unreachable", password, Wait{Part: "p", What: "w"})) + if got.State != StateUnhealthy || len(got.Waits) != 1 { + t.Fatalf("read as %+v: the readable wait is kept, the other dropped, the state stays unhealthy", got) + } + if h := roundTrip(t, Answer{State: StateHealthy, Healthy: true, Why: "ok", Waits: []Wait{password}}); len(h.Waits) != 0 { + t.Fatalf("a healthy answer kept its waits: %+v", h) + } +} + +func TestWaitingWithNothingIsUnhealthy(t *testing.T) { + if a := Waiting("it waits"); a.State != StateUnhealthy { + t.Fatalf("Waiting with no wait is %+v", a) + } +} + +func TestNotAnAnswerIsAnError(t *testing.T) { + if _, err := Read([]byte(`"yes"`)); err == nil { + t.Fatal("a string read as an answer") + } +} -- 2.54.0 From 33917f91ac3cc213b879b32f3c70e2aa70395f98 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 10 Oct 2026 21:10:01 +0200 Subject: [PATCH 2/2] Refuse every form of path or address in a wait's words, a contradicting healthy answer, and a malformed wait at Waiting From the review of mesh-sdk#18: quoted and bracketed paths, home and relative paths, IPv6, host and port, UNC and drive paths passed the check. --- go/health/health.go | 27 ++++++++++++++++++++++++--- go/health/health_test.go | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/go/health/health.go b/go/health/health.go index 29a9fe6..b9ab26f 100644 --- a/go/health/health.go +++ b/go/health/health.go @@ -70,14 +70,31 @@ func Waiting(why string, waits ...Wait) Answer { if len(waits) == 0 { return Unhealthy(orSaid(why, "it says it waits") + " (and names nothing it waits for)") } + for _, w := range waits { + if err := w.Check(); err != nil { + return Unhealthy(orSaid(why, "it says it waits") + " (" + err.Error() + ")") + } + } return Answer{State: StateWaits, Why: why, Waits: waits} } // name is a secret's or a setting's name: what a manifest may declare. var name = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`) -// looksLikeAddress finds an IPv4 address, a URL, or a path in words meant for the operator. -var looksLikeAddress = regexp.MustCompile(`\b[0-9]{1,3}(\.[0-9]{1,3}){3}\b|://|(^|\s)/[^\s]`) +// looksLikeAddress finds what the operator's words never carry: a URL, an IPv4 or IPv6 address, a host and port, a +// path (absolute, from a home or relative, on its own or after a bracket or a quote), a UNC path or a drive path. +// It cannot find a value typed in plain words ("the password hunter2"): a module never puts one there, and the +// words are its own, not the operator's. +var looksLikeAddress = regexp.MustCompile(strings.Join([]string{ + `://`, + `\b[0-9]{1,3}(\.[0-9]{1,3}){3}\b`, + `[0-9A-Fa-f]{0,4}::[0-9A-Fa-f]{0,4}`, + `\b[0-9A-Fa-f]{1,4}(:[0-9A-Fa-f]{1,4}){3,}\b`, + `\b[A-Za-z0-9.-]+:[0-9]{1,5}\b`, + `(^|[\s(\[{"'\x60])[~.]{0,2}/`, + `\\\\`, + `\b[A-Za-z]:\\`, +}, "|")) // Check says why a wait cannot be read, or nil. func (w Wait) Check() error { @@ -132,8 +149,12 @@ func Read(result []byte) (Answer, error) { a.State = StateUnhealthy } case StateHealthy: + if !a.Healthy { + return Unhealthy(fmt.Sprintf("it answered the state healthy and healthy false, which contradict each other (%s)", + orSaid(a.Why, "and said nothing more"))), nil + } // A healthy module waits for nothing: a wait beside healthy is a contradiction, and is dropped. - a.Healthy, a.Waits = true, nil + a.Waits = nil case StateUnhealthy: a.Healthy = false case StateWaits: diff --git a/go/health/health_test.go b/go/health/health_test.go index 8bcc0ef..b6a3531 100644 --- a/go/health/health_test.go +++ b/go/health/health_test.go @@ -116,3 +116,35 @@ func TestNotAnAnswerIsAnError(t *testing.T) { t.Fatal("a string read as an answer") } } + +func TestEveryFormOfPathOrAddressIsRefused(t *testing.T) { + for _, words := range []string{ + "the source (/mnt/games)", `the source "/mnt/games"`, "the folder ~/games", "the folder ./games", + "2001:db8::1", "fe80::1%eth0", "2001:0db8:0000:0000:0000:0000:0000:0001", "nas.lan:445", "192.0.2.7:445", + `\\nas\games`, `C:\Users\x`, "smb://nas/games", "198.51.100.4", + } { + w := Wait{Part: "the source games", Secret: "smb-password-games", What: "the password of " + words} + if err := w.Check(); err == nil { + t.Errorf("%q passed as words for the operator", words) + } + } + for _, words := range []string{"the password of the source games", "the username of the source games-2", "the API key, issued by the vendor"} { + w := Wait{Part: "the source games", Secret: "smb-password-games", What: words} + if err := w.Check(); err != nil { + t.Errorf("%q refused: %v", words, err) + } + } +} + +func TestHealthyStateWithHealthyFalseIsUnhealthy(t *testing.T) { + got, err := Read([]byte(`{"state":"healthy","healthy":false,"why":"x"}`)) + if err != nil || got.State != StateUnhealthy || !strings.Contains(got.Why, "contradict") { + t.Fatalf("read as %+v, %v", got, err) + } +} + +func TestWaitingChecksItsWaits(t *testing.T) { + if a := Waiting("it waits", Wait{Part: "p", What: "w"}); a.State != StateUnhealthy || !strings.Contains(a.Why, "neither") { + t.Fatalf("Waiting with a malformed wait is %+v", a) + } +} -- 2.54.0