status --json, so a board has something to read

A board reads through interfaces and holds nothing. Everything it needs
is already answered — as text, for people, which is not something a page
can read.

`--json` rather than a serving API, because nothing needs one yet:
whatever serves a board runs the command, and the constraint holds either
way — the board never touches a context's store. An API is the larger
thing and should wait until something asks for it.

Both forms are gathered from the same reads before either says anything,
so they answer the same questions rather than being two implementations
that can drift. That was not true of the first version: the JSON printed
after the text, because the branch was too late.

Four properties, each asserted and each confirmed to fail when removed:

- refused and failed stay distinct all the way out. They are fixed in
  different places, so one word for both sends half a page's readers to
  the wrong one — and how much DID apply is carried, since "three of
  eight" and "none of eight" are different machines
- a machine that never spoke carries no time at all, rather than a zero
  one that any page would format as a date in 1970
- nothing is null. A page distinguishing "no machines are wrong" from
  "this field is missing" has to handle both, and null is the one that
  gets forgotten
- no field is named like a secret. Everything here comes from records
  that hold no readable one, but a shape a page is built against is
  exactly where one would eventually be added for convenience
This commit is contained in:
2026-08-30 20:22:04 +02:00
parent 79d6ade4c8
commit 0262873254
7 changed files with 472 additions and 31 deletions
+90 -28
View File
@@ -102,7 +102,7 @@ func run() error {
case "push":
return pushCommand(ctx, args[1:])
case "status":
return statusCommand(ctx)
return statusCommand(ctx, args[1:])
case "version":
fmt.Println(version)
return nil
@@ -133,7 +133,7 @@ func usage() {
module list what modules this mesh knows about
module moved <name> <commit> the source has a newer commit than the mesh built
module forget <name> remove one, unless a node is running it
status what the mesh is behind on, and which nodes
status [--json] what is wrong, what is quiet, and what is out of date
assign <node> <module> put a module on a node
unassign <node> <module> take it off
settings set <module> <file> what a module's config should say, for the whole mesh
@@ -1561,7 +1561,13 @@ func short(commit string) string {
//
// 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) error {
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
}
inv, err := openInventory(ctx)
if err != nil {
return err
@@ -1571,10 +1577,48 @@ func statusCommand(ctx context.Context) error {
// Three questions, in the order somebody asks them: is anything broken, is anything not
// answering, is anything out of date. The first has consequences now, the second may, and
// the third is a plan for later — and a status that led with the third would bury the first.
//
// All three are gathered before anything is said, so the two ways of saying it answer the
// same questions from the same reads rather than being two implementations.
wrong, err := inv.NotDoingWhatTheyWereTold(ctx)
if err != nil {
return err
}
nodes, err := inv.Nodes(ctx)
if err != nil {
return err
}
var quiet []inventory.Node
for _, n := range 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 {
quiet = append(quiet, n)
}
}
behind, err := inv.Behind(ctx)
if err != nil {
return err
}
sources := map[string]inventory.Source{}
for module := range behind {
from, err := inv.SourceOf(ctx, module)
if err != nil {
return err
}
sources[module] = from
}
if *asJSON {
body, err := statusAsJSON(wrong, nodes, quiet, behind, sources)
if err != nil {
return err
}
fmt.Println(string(body))
return nil
}
if len(wrong) > 0 {
fmt.Printf("%d machine(s) are not doing what they were told:\n\n", len(wrong))
for _, d := range wrong {
@@ -1591,28 +1635,15 @@ func statusCommand(ctx context.Context) error {
fmt.Println()
}
nodes, err := inv.Nodes(ctx)
if err != nil {
return err
}
var quiet []string
for _, n := range 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 {
quiet = append(quiet, n.Name+" ("+heardFrom(n)+")")
}
}
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(quiet, "\n "))
len(quiet), strings.Join(said, "\n "))
}
behind, err := inv.Behind(ctx)
if err != nil {
return err
}
if len(behind) > 0 {
var names []string
for m := range behind {
@@ -1622,10 +1653,7 @@ func statusCommand(ctx context.Context) error {
fmt.Printf("%d module(s) behind their source:\n\n", len(behind))
for _, m := range names {
from, err := inv.SourceOf(ctx, m)
if err != nil {
return err
}
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
@@ -2005,10 +2033,21 @@ func (b builds) Built(ctx context.Context, result link.BuildResult) error {
// handed — which in practice meant the broker's own administrative one. A program documented as
// holding its own credential and given somebody else's is worse than one with no story at all.
func builderCommand(ctx context.Context, args []string) error {
if len(args) != 2 || args[0] != "issue" {
return errors.New("builder issue <name>")
set := flag.NewFlagSet("builder issue", flag.ContinueOnError)
// Which machine will use it. Given, the credential is delivered by the mesh rather than
// printed for somebody to carry — which is the difference between the builder being a module
// and being a program somebody configures.
forNode := set.String("node", "",
"the machine that will run it, so the mesh delivers the credential instead of printing it")
module := set.String("module", "builder", "the module on that machine that will read it")
positionals, err := parseAround(set, args)
if err != nil {
return err
}
name := args[1]
if len(positionals) != 2 || positionals[0] != "issue" {
return errors.New("builder issue <name> [--node <machine>]")
}
name := positionals[1]
management, err := broker.ManagementFromEnvironment()
if err != nil {
@@ -2029,6 +2068,29 @@ func builderCommand(ctx context.Context, args []string) error {
fmt.Printf("broker account %s created, scoped to the %s queue and the %s exchange\n\n",
name, link.BuildQueue, link.Exchange)
if *forNode != "" {
known, err := broker.FromEnvironment()
if err != nil {
return fmt.Errorf("cannot deliver a credential without knowing where the broker is: %w", err)
}
inv, err := openInventory(ctx)
if err != nil {
return err
}
defer inv.Close()
url := fmt.Sprintf("amqps://%s:%s@%s/", name, password, known.Address)
if err := inv.AcceptSecretForModule(ctx, *forNode, *module, "broker", url); err != nil {
return err
}
// Not printed. It is sealed to that machine and the mesh cannot read it back, which is
// the whole point — printing it here would put the one copy that matters on a terminal.
fmt.Printf(" sealed to %s, for the %s module. It arrives with the next push.\n",
*forNode, *module)
fmt.Printf(" run `push %s` to send it\n", *forNode)
return nil
}
// The whole line only when the address is known. A URL with a placeholder where the host
// should be is a URL somebody pastes and then debugs, and the placeholder is the last thing
// they look at.