Files
mesh-controller/cmd/mesh-controller/plan_failed.go
T

228 lines
7.5 KiB
Go

package main
import (
"context"
"errors"
"fmt"
"regexp"
"sort"
"strings"
"time"
"github.com/novox/mesh-controller/internal/conditions"
"github.com/novox/mesh-controller/internal/inventory"
)
// failedModule is one module of a failed plan that failed to build, where it runs and what it said.
type failedModule struct {
module string
machines []string
why string
}
// failedPlanFacts is one failed delivery plan asked of the operator (S23, novox/hq ADR 0449 §1).
type failedPlanFacts struct {
id, repository, commit string
modules []string
failed []failedModule
// note is where the plan stopped when no module failed to build (a rollout stopped at its first machine).
note string
// since is the plan's last change: when it failed.
since time.Time
// refusal is why `plans retry` would not run it, empty when it would.
refusal string
// passed is set when a newer plan of its repository and branch is done and the retry is refused for that
// (novox/hq ADR 0451): the condition asks nothing.
passed bool
}
// machines are the machines the failed modules run on, sorted, each once.
func (p failedPlanFacts) machines() []string {
seen := map[string]bool{}
var out []string
for _, m := range p.failed {
for _, n := range m.machines {
if !seen[n] {
seen[n] = true
out = append(out, n)
}
}
}
sort.Strings(out)
return out
}
// failureSaid is what failed, in the mesh's own words, for the condition's evidence.
func (p failedPlanFacts) failureSaid() string {
var parts []string
for _, m := range p.failed {
line := m.module
if len(m.machines) > 0 {
line += " on " + strings.Join(m.machines, ", ")
}
if m.why != "" {
line += ": " + oneLine(m.why)
}
parts = append(parts, line)
}
if len(parts) == 0 {
return "stopped: " + oneLine(p.note)
}
return strings.Join(parts, "; ")
}
// refusalSaid is why a retry would not run, for the evidence.
func (p failedPlanFacts) refusalSaid() string {
if p.refusal == "" {
return ""
}
return "; a retry would not run: " + oneLine(p.refusal)
}
// retryAction is the one answer a failed plan offers: the controller's `plans` verb with `retry` naming the plan,
// at the level approve. Only that argument: `plans` refuses a retry that carries others (seatverbs).
func retryAction(plan string) conditions.Action {
return conditions.Action{Label: "Retry", Verb: "mesh-controller.plans", Level: conditions.LevelApprove,
Arguments: map[string]string{"retry": plan}}
}
var (
planIDWord = regexp.MustCompile(`\bplan-[0-9]+\b`)
walkWd = regexp.MustCompile(`\bwalks\b`)
walkOne = regexp.MustCompile(`\bwalk\b`)
)
// plainRefusal is a retry's refusal in words the operator reads: the plans named as "the plan", a walk said as a
// delivery. Whatever still breaks the plain rule (a hash, a dotted name) is left to the condition's evidence, and the
// words say only that a retry would not run.
func plainRefusal(refusal string) string {
return plainRefusalOr(refusal, "a retry would not run now; the details say why")
}
// plainRefusalOr is plainRefusal with the sentence to say when the refusal's own words cannot be said plainly.
func plainRefusalOr(refusal, fallback string) string {
r := planIDWord.ReplaceAllString(refusal, "the plan")
r = walkWd.ReplaceAllString(r, "deliveries")
r = walkOne.ReplaceAllString(r, "delivery")
r = strings.TrimSpace(r)
if _, ok := conditions.Plain(r); !ok {
return fallback
}
return r
}
// planFailedWords are the plain words of a failed plan: which modules failed and where, what they said when that
// is plain, and Retry when a retry would run, else the refusal's words.
func planFailedWords(p failedPlanFacts) (headline, explanation, needs string, actions []conditions.Action, resolved string) {
name := deliveryName(p.modules, p.repository)
headline = conditions.Capital(name) + " failed"
what := deliveryWhat(p.modules, p.repository)
var parts []string
for i, m := range p.failed {
if i == 2 {
parts = append(parts, fmt.Sprintf("%d more", len(p.failed)-2))
break
}
line := m.module
if len(m.machines) > 0 {
line += " for " + readableList(m.machines)
}
if w := strings.TrimRight(cutWords(oneLine(m.why), 110), ".;:, "); w != "" {
if _, ok := conditions.Plain(w); ok {
line += " (" + w + ")"
}
}
parts = append(parts, line)
}
if len(parts) > 0 {
explanation = fmt.Sprintf("The delivery of %s failed: %s did not build.", what, readableList(parts))
} else {
explanation = fmt.Sprintf("The delivery of %s stopped before it finished.", what)
}
switch {
case p.passed:
explanation += " A newer delivery has passed it, so a retry would send older code. Nothing is needed from you."
case p.refusal == "":
explanation += " Nothing is tried again until you say so."
needs = "try the failed builds again " + FromMeshMCPServer
actions = []conditions.Action{retryAction(p.id)}
default:
explanation += " " + conditions.Capital(plainRefusal(p.refusal)) + "."
needs = "look at why it failed; a retry would not run now."
}
resolved = conditions.Capital(name) + " is no longer failed"
return
}
// cutWords is s cut to at most n bytes at a word.
func cutWords(s string, n int) string {
if len(s) <= n {
return s
}
t := s[:n]
if i := strings.LastIndexByte(t, ' '); i > n/2 {
t = t[:i]
}
return strings.TrimRight(t, " ,;:") + "…"
}
// gatherFailedPlans is every delivery plan that failed within planFailedWithin and is asked of the operator
// (S23, novox/hq ADR 0449 §1): a failed plan that carries a delivery, is no rollout of held updates and was not
// stopped by a person. Each carries why `plans retry` would not run it, when it would not.
func gatherFailedPlans(ctx context.Context, inv *inventory.Inventory, now time.Time) ([]failedPlanFacts, error) {
plans, err := inv.PlansSince(ctx, now.Add(-planFailedWithin))
if err != nil {
return nil, fmt.Errorf("the plans cannot be read: %w", err)
}
var failed []inventory.Plan
for _, p := range plans {
if failedAndAsked(p) {
failed = append(failed, p)
}
}
if len(failed) == 0 {
return nil, nil
}
entries, err := inv.Catalogued(ctx)
if err != nil {
return nil, fmt.Errorf("the catalogue cannot be read: %w", err)
}
onPath := ownPathOf(entries)
runsOn := map[string][]string{}
for _, e := range entries {
runsOn[e.Manifest.Module] = e.On
}
var out []failedPlanFacts
for _, p := range failed {
out = append(out, failedFactsOf(p, plans, onPath, runsOn))
}
return out, nil
}
// failedAndAsked says a plan is one S23 asks about: failed, a delivery's, not a rollout of held updates, not
// stopped by a person.
func failedAndAsked(p inventory.Plan) bool {
return p.State == inventory.PlanFailed && p.Delivery != nil && p.Release == nil && p.Delivery.Stopped == ""
}
// failedFactsOf is what S23 says of one failed plan, given every plan, the controller's own path and where each
// module runs.
func failedFactsOf(p inventory.Plan, plans []inventory.Plan, onPath map[string]string,
runsOn map[string][]string) failedPlanFacts {
f := failedPlanFacts{id: p.ID, repository: p.Repository, commit: p.Commit, modules: planModules(p),
note: p.Note, since: p.Updated}
for _, m := range f.modules {
if s := p.Modules[m]; s != nil && s.State == "failed" {
machines := append([]string(nil), runsOn[m]...)
sort.Strings(machines)
f.failed = append(f.failed, failedModule{module: m, machines: machines, why: s.Why})
}
}
if err := retryRefusal(p, plans, onPath); err != nil {
f.refusal = err.Error()
var passed passedBy
f.passed = errors.As(err, &passed)
}
return f
}