package asks import ( "encoding/json" "errors" "fmt" "reflect" "strings" ) // ConditionSubject is where a module says a condition to the operator: a by-caller accept of the operator-channel // seat, `mesh.seat.operator-channel.accept.condition.` (novox/hq ADR 0437 §9). The router reads the // verb at the fifth part and the caller at the sixth, so the publisher is one token, the module's own name; it is // refused when empty or when it holds a dot, a wildcard or whitespace. func ConditionSubject(publisher string) (string, error) { if publisher == "" { return "", errors.New("asks: the condition's publisher is empty") } if strings.ContainsAny(publisher, ".*> \t\r\n") || strings.ContainsFunc(publisher, func(r rune) bool { return r <= ' ' }) { return "", fmt.Errorf("asks: the condition's publisher %q is not one token: it holds a dot, a wildcard or whitespace", publisher) } return "mesh.seat." + Seat + ".accept.condition." + publisher, nil } // ConditionVersion is the version of the condition value this package writes and the only one it reads. const ConditionVersion = 1 // ErrUnknownConditionVersion is what DecodeCondition returns for a value whose version is missing or not // ConditionVersion. Test it with errors.Is. var ErrUnknownConditionVersion = errors.New("asks: unknown condition version") // The words a condition's explanation opens with (novox/hq ADR 0253 §1). const ( NothingToDo = "Nothing for you to do." NeedsYou = "Needs you: " ) // The severities and states of a condition. const ( SeverityUrgent = "urgent" SeverityWarning = "warning" StateRaised = "raised" StateCleared = "cleared" ) // Scope is what a condition is about, under the controller's names. type Scope struct { Scope string `json:"scope"` ID string `json:"id"` Machine string `json:"machine,omitempty"` } // Action is one answer a notification offers, under the controller's names. type Action struct { Label string `json:"label"` Verb string `json:"verb"` Machine string `json:"machine,omitempty"` Arguments map[string]string `json:"arguments,omitempty"` Level Level `json:"level,omitempty"` } // Condition is a condition a module says to the operator, under the names of the controller's conditions: its key, // severity, subject, state (raised or cleared), summary and the plain words of ADR 0253. A cleared condition needs // only its key, subject and state. The router alone chooses the channels. type Condition struct { V int `json:"v"` Key string `json:"key"` Severity string `json:"severity,omitempty"` Subject Scope `json:"subject"` State string `json:"state"` Summary string `json:"summary,omitempty"` Headline string `json:"headline,omitempty"` Needs string `json:"needs,omitempty"` Explanation string `json:"explanation,omitempty"` Resolved string `json:"resolved,omitempty"` Actions []Action `json:"actions,omitempty"` } func (c Condition) check() error { if c.Key == "" { return errors.New("asks: a condition has no key") } if c.Subject.Scope == "" || c.Subject.ID == "" { return errors.New("asks: a condition's subject needs its scope and id") } switch c.State { case StateCleared: return nil case StateRaised: default: return fmt.Errorf("asks: the condition's state %q is not raised or cleared", c.State) } switch c.Severity { case SeverityUrgent, SeverityWarning: default: return fmt.Errorf("asks: the condition's severity %q is not urgent or warning", c.Severity) } if c.Headline == "" { return errors.New("asks: a raised condition has no headline") } if n := len([]rune(c.Headline)); n > HeadlineLength { return fmt.Errorf("asks: the condition's headline is %d characters, more than %d", n, HeadlineLength) } verdict := NothingToDo if c.Needs != "" { verdict = NeedsYou + c.Needs } if !strings.HasPrefix(c.Explanation, verdict) { return fmt.Errorf("asks: the condition's explanation must open with %q", verdict) } return nil } // EncodeCondition is c as JSON at ConditionVersion; it refuses what ADR 0253 and ADR 0437 §9 refuse. func EncodeCondition(c Condition) ([]byte, error) { if err := c.check(); err != nil { return nil, err } c.V = ConditionVersion return json.Marshal(c) } // DecodeCondition reads a condition; it refuses an unknown version (ErrUnknownConditionVersion) and what // EncodeCondition refuses. func DecodeCondition(data []byte) (Condition, error) { var probe struct { V *int `json:"v"` } if err := json.Unmarshal(data, &probe); err != nil { return Condition{}, fmt.Errorf("asks: the condition is not JSON: %w", err) } if probe.V == nil { return Condition{}, fmt.Errorf("%w: no version in the value, this reader reads %d", ErrUnknownConditionVersion, ConditionVersion) } if *probe.V != ConditionVersion { return Condition{}, fmt.Errorf("%w: the value is version %d, this reader reads %d", ErrUnknownConditionVersion, *probe.V, ConditionVersion) } var c Condition if err := json.Unmarshal(data, &c); err != nil { return Condition{}, fmt.Errorf("asks: the condition does not fit its shape: %w", err) } if err := c.check(); err != nil { return Condition{}, err } return c, nil } // SameCondition says whether two conditions are the same value. func SameCondition(a, b Condition) bool { return reflect.DeepEqual(a, b) }