Files
mesh-controller/cmd/mesh-control/status.go
T
jschoubben 0be227bd7e status: one machine that cannot be worked out no longer takes the answer from the rest
`status --json` emitted no JSON at all when a single node was unresolvable. A blocked
node is not on the private network, and a mesh whose hub is that node has no hub — which
came back through the reading as a refusal, so `status` printed nothing and `status
--json` put multi-line prose on stderr and not one byte on stdout. A machine-readable
interface that stops being machine-readable exactly when something is wrong is one nobody
can build an alarm on.

Why a machine cannot be worked out is read as data now, per machine, through the same
whoResolves the private network is built from — so this and the network agree about who
could not be resolved rather than deciding it twice. The private network failing to
compute is kept as a note beside it instead of ending the read: it is almost always a
consequence of those same refusals, and every question that does not depend on it is
still answered.

It reaches all three ways of saying it, from the one reading: the text form leads with it
because a machine here is in none of the answers below, the JSON carries `unresolved`
(always a list, never null) and `network`, and the page has a section of its own.

That also closes a silent success. A machine that resolves to nothing has nothing
computed for it, so there is nothing to compare it against and nothing it can be behind —
it appeared in no answer at all, and `status` reported a mesh where nothing could be sent
anywhere as "all doing what they were told".

statusAsJSON takes the whole reading now rather than a growing argument list, which is
what let an answer be added to the text form and forgotten here. The two are one
function's output in two shapes and must not be able to differ about what was asked.

Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
2026-09-10 21:12:06 +02:00

269 lines
9.4 KiB
Go

package main
import (
"context"
"flag"
"fmt"
"sort"
"strings"
"time"
"github.com/novox/mesh-control/internal/inventory"
"github.com/novox/mesh-control/internal/overlay"
)
// is anything broken, is anything not answering, is anything out of date.
//
// Split out of main.go, which had reached 2,769 lines because appending was always the
// cheapest next step. That is how novox/hq ADR 0001 records `hal/sdk` reaching 34,636:
// nothing in it was wrong, and no one edit was the one that should have been a new file.
// short is a commit as a person refers to it.
func short(commit string) string {
if len(commit) > 8 {
return commit[:8]
}
return commit
}
// statusCommand answers "did my change go out?".
//
// novox/hq ADR 0010 names losing that question as the real risk of replacing a pipeline with a
// comparison: it is answerable today by opening a pipeline, and something has to replace that or
// this is worse to live with whatever its other properties.
//
// The answer is not "a job succeeded". It is which modules the mesh has not built from what their
// source now has, and which machines are running the old one.
func statusCommand(ctx context.Context, args []string) error {
set := flag.NewFlagSet("status", flag.ContinueOnError)
asJSON := set.Bool("json", false, "the same answers, for something other than a person")
if _, err := parseAround(set, args); err != nil {
return err
}
open, err := openStores(ctx)
if err != nil {
return err
}
defer open.Close()
asked, err := theThreeQuestions(ctx, open)
if err != nil {
return err
}
wrong, nodes, quiet := asked.wrong, asked.nodes, asked.quiet
behind, sources := asked.behind, asked.sources
if *asJSON {
body, err := statusAsJSON(asked)
if err != nil {
return err
}
fmt.Println(string(body))
return nil
}
if len(asked.refused) > 0 {
// First, above everything else. A machine that cannot be worked out is not running an old
// declaration — it has no declaration, and nothing below this line is about it.
var names []string
for name := range asked.refused {
names = append(names, name)
}
sort.Strings(names)
fmt.Printf("%d machine(s) cannot be worked out at all, so nothing can be sent to them:\n\n",
len(names))
for _, name := range names {
fmt.Printf(" %s\n", name)
for _, line := range strings.Split(strings.TrimRight(asked.refused[name], "\n"), "\n") {
fmt.Printf(" %s\n", strings.TrimSpace(line))
}
}
fmt.Println()
}
if asked.network != "" {
fmt.Printf("the private network could not be computed:\n %s\n\n",
strings.ReplaceAll(strings.TrimRight(asked.network, "\n"), "\n", "\n "))
}
if len(wrong) > 0 {
fmt.Printf("%d machine(s) are not doing what they were told:\n\n", len(wrong))
for _, d := range wrong {
fmt.Printf(" %-18s %-9s %s\n", d.Node, d.Outcome, d.At.Local().Format("2006-01-02 15:04"))
if d.Refused != "" {
// The host's own words. It says exactly what it could not accept, and nothing
// written here would say it better.
fmt.Printf(" %-18s %s\n", "", firstLine(d.Refused))
}
for _, f := range d.Failed {
fmt.Printf(" %-18s %s: %s\n", "", f.ID, firstLine(f.Error))
}
}
fmt.Println()
}
if len(quiet) > 0 {
var said []string
for _, n := range quiet {
said = append(said, n.Name+" ("+heardFrom(n)+")")
}
fmt.Printf("%d machine(s) not heard from lately:\n %s\n\n",
len(quiet), strings.Join(said, "\n "))
}
if len(behind) > 0 {
var names []string
for m := range behind {
names = append(names, m)
}
sort.Strings(names)
fmt.Printf("%d module(s) behind their source:\n\n", len(behind))
for _, m := range names {
from := sources[m]
fmt.Printf(" %-18s holds %s, source has %s\n", m, short(from.BuiltFrom), short(from.Head))
if on := behind[m]; len(on) > 0 {
// The part somebody actually wants. A module being out of date is a fact about
// the catalogue; machines running the old one is the thing with consequences.
fmt.Printf(" %-18s running on %s\n", "", strings.Join(on, ", "))
} else {
fmt.Printf(" %-18s assigned to nothing\n", "")
}
}
// The remedy, beside the problem. A status that says what is wrong and not what to do
// about it makes somebody go and find the command, and the command is the whole point of
// having noticed.
fmt.Printf("\n `build --behind` builds them; `push --behind` sends them on\n")
fmt.Println()
}
if len(asked.waiting) > 0 {
// The other half of "is anything out of date": a module behind its source says the
// catalogue is old, and this says a machine is — and only this one has somebody's change
// waiting inside it.
var told, never []string
for _, m := range asked.waiting {
if m.Never {
never = append(never, m.Node)
continue
}
told = append(told, m.Node)
}
if len(told) > 0 {
fmt.Printf("%d machine(s) are not running what the mesh would send them:\n %s\n",
len(told), strings.Join(told, ", "))
}
if len(never) > 0 {
// Never told is not out of date. The remedy is the same push and the situation is
// not the same at all: nobody has ever asked this machine to be anything.
fmt.Printf("%d machine(s) have never been sent anything:\n %s\n",
len(never), strings.Join(never, ", "))
}
fmt.Printf("\n `push --behind` sends them\n\n")
}
if len(wrong) == 0 && len(quiet) == 0 && len(behind) == 0 && len(asked.waiting) == 0 &&
len(asked.refused) == 0 && asked.network == "" {
// Said plainly. "Nothing to report" and "nothing was checked" must never look the same,
// and getting here means every question was asked and answered.
fmt.Printf("%d machine(s), all doing what they were told, all heard from, running what "+
"the mesh would send them, and every module current with its source\n", len(nodes))
}
return nil
}
// firstLine is as much of a failure as belongs in a list.
func firstLine(s string) string {
if cut := strings.IndexByte(s, '\n'); cut >= 0 {
return strings.TrimSpace(s[:cut])
}
return strings.TrimSpace(s)
}
// builds keeps what a builder said, for the serving control plane.
//
// A type of its own rather than a method on the enrolment, because they are unrelated things
// arriving on one queue and an implementation of one should not have to say anything about the
// other.
type builds struct{ inv *inventory.Inventory }
// theThreeQuestions reads what anything answering "is the mesh alright" needs.
//
// **One reading, for every way of saying it** (novox/hq 03-DESIGN/01-to-be/11-a-board.md). There
// are three now — a person's status, its JSON, and a page — and three implementations of "which
// machine is not doing what it was told" would be three chances to disagree about it.
//
// The order is the design and not a convenience: is anything broken, is anything not answering, is
// anything out of date. The first has consequences now, the second may, the third is a plan for
// later — and anything that led with the third would bury the first.
func theThreeQuestions(ctx context.Context, open *stores) (answers, error) {
inv := open.inventory
var out answers
var err error
out.wrong, err = inv.NotDoingWhatTheyWereTold(ctx)
if err != nil {
return answers{}, err
}
out.nodes, err = inv.Nodes(ctx)
if err != nil {
return answers{}, err
}
for _, n := range out.nodes {
// Never heard from, or not lately. Different from failing: a machine that says nothing
// may be new, switched off, or unreachable, and none of those is a machine that tried
// and could not.
if n.LastSeen.IsZero() || time.Since(n.LastSeen) > time.Hour {
out.quiet = append(out.quiet, n)
}
}
out.behind, err = inv.Behind(ctx)
if err != nil {
return answers{}, err
}
// And why any machine cannot be worked out at all, which is neither of the first two questions
// and is asked before them both in practice: a machine nothing can be computed for is not
// broken, not quiet and not behind, and every other answer here would call it well.
//
// Read through whoResolves, which is what the private network is built from, so this and the
// network agree about who could not be resolved rather than deciding it twice.
_, out.refused, err = whoResolves(ctx, open, overlay.Addressing)
if err != nil {
return answers{}, err
}
// And which machines are not running what the mesh would send them. The same question as a
// module being behind its source, one level down: that one says the catalogue is out of date,
// this one says a machine is — and only the second has anybody's change waiting in it.
//
// **One machine that cannot be resolved must not take the answer away from every other**
// (novox/hq 04-ISSUES/017's sibling). This reaches the private network, and a mesh whose hub
// is the blocked machine has no hub — which used to come back here as a refusal, so `status`
// said nothing at all and `status --json` emitted prose to stderr and no JSON anywhere. The
// reason is kept and reported as data; every question that does not depend on it is still
// answered.
would, err := wouldSend(ctx, open, out.nodes)
if err != nil {
out.network = err.Error()
would = map[string]string{}
}
out.waiting, err = inv.Waiting(ctx, would)
if err != nil {
return answers{}, err
}
out.reported, err = inv.LastReports(ctx)
if err != nil {
return answers{}, err
}
out.sources = map[string]inventory.Source{}
for module := range out.behind {
from, err := inv.SourceOf(ctx, module)
if err != nil {
return answers{}, err
}
out.sources[module] = from
}
return out, nil
}