Merge pull request 'go/health: a health tool answers healthy, unhealthy or waits (issue 386, ADR 0283)' (#18) from feat/386-a-wait-for-the-operator into main
This commit was merged in pull request #18.
This commit is contained in:
@@ -0,0 +1,191 @@
|
||||
// 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)")
|
||||
}
|
||||
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 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 {
|
||||
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:
|
||||
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.Waits = 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
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user