diff --git a/cmd/mesh-control/main.go b/cmd/mesh-control/main.go index eb2c745..6d5f7bf 100644 --- a/cmd/mesh-control/main.go +++ b/cmd/mesh-control/main.go @@ -129,6 +129,7 @@ func usage() { migrate bring each context's schema up to date node add create a node record node list the nodes this mesh knows about + node show what one machine reported it can do, and why token issue --node a one-time right to join, for an existing record token issue --new create the record and issue for it identity show this control plane's signing key @@ -267,7 +268,7 @@ func openInventory(ctx context.Context) (*inventory.Inventory, error) { func nodeCommand(ctx context.Context, args []string) error { if len(args) == 0 { - return errors.New("node add , or node list") + return errors.New("node add , node list, or node show ") } inv, err := openInventory(ctx) if err != nil { @@ -276,6 +277,11 @@ func nodeCommand(ctx context.Context, args []string) error { defer inv.Close() switch args[0] { + case "show": + if len(args) != 2 { + return errors.New("node show ") + } + return showNode(ctx, inv, args[1]) case "add": if len(args) != 2 { return errors.New("node add ") @@ -2560,3 +2566,56 @@ func theThreeQuestions(ctx context.Context, inv *inventory.Inventory) (answers, } return out, nil } + +// showNode says what one machine reported about itself, in its own words. +// +// **A capability is detected and never assumed** (novox/hq ADR 0009), so the only account of what +// a machine can do is the one it gave — and its detail is half of that account. The mesh was +// keeping the yes and discarding the reason, which makes *this machine has no seat* an answer with +// nowhere to go: a person told a machine lacks something wants to know what the detector saw. +// +// It is also where "what should it be configured as" is read. The same line that gates an +// assignment carries `card1-DP-1`, and a person composing settings for that machine needs it. +func showNode(ctx context.Context, inv *inventory.Inventory, name string) error { + node, err := inv.NodeByName(ctx, name) + if err != nil { + return err + } + fmt.Printf("%s\n", node.Name) + fmt.Printf(" last heard from %s\n", heardFrom(node)) + + held, err := inv.Profile(ctx, name) + if err != nil { + return err + } + if held == nil { + // Never reported is not the same as reported nothing, and the remedy differs: one is a + // machine that has not run the host yet, the other is a machine that ran it and can do + // nothing. + fmt.Printf("\n this machine has never said what it can do, so everything requiring a\n" + + " capability is refused here — run the host on it\n") + return nil + } + if len(held) == 0 { + fmt.Printf("\n it reported no capabilities at all\n") + return nil + } + + fmt.Printf("\n what it can do, as it reported:\n") + for _, c := range held { + mark := "no " + if c.Present { + mark = "yes" + } + fmt.Printf(" %s %-20s %s\n", mark, c.Name, c.Detail) + } + + assigned, err := inv.Assigned(ctx, name) + if err != nil { + return err + } + if len(assigned) > 0 { + fmt.Printf("\n assigned: %s\n", strings.Join(assigned, ", ")) + } + return nil +} diff --git a/internal/inventory/catalogue.go b/internal/inventory/catalogue.go index 476cb01..d12307f 100644 --- a/internal/inventory/catalogue.go +++ b/internal/inventory/catalogue.go @@ -331,16 +331,11 @@ func (i *Inventory) ProfileOf(ctx context.Context, nodeName string) (map[string] // machine", which is wrong but visible. Better than assuming it can do everything. return out, nil } - var reported struct { - Capabilities []struct { - Name string `json:"name"` - Present bool `json:"present"` - } `json:"capabilities"` - } - if err := json.Unmarshal(raw, &reported); err != nil { + held, err := profileFrom(raw) + if err != nil { return nil, err } - for _, c := range reported.Capabilities { + for _, c := range held { if c.Present { out[c.Name] = true } @@ -348,6 +343,57 @@ func (i *Inventory) ProfileOf(ctx context.Context, nodeName string) (map[string] return out, nil } +// Capability is one named fact a machine reported about itself. +// +// **Detected and never assumed** (novox/hq ADR 0009). The detail is the part that was being +// thrown away: a capability's presence gates an assignment and *its detail can also carry a +// value* — `seat: card1-DP-1`, an architecture, an amount of memory. So *can this run here* and +// *what should it be configured as* are the same fact read two ways, and keeping only the first +// read makes the second unanswerable. +// +// It is also what a refusal should quote. "This machine has no seat" is the right answer and +// "no graphics devices are present" is the reason, and only the machine knows the reason. +type Capability struct { + Name string `json:"name"` + Present bool `json:"present"` + // Detail is what the detector observed, in its own words — including when it found nothing, + // which is the case a person most needs explaining. + Detail string `json:"detail,omitempty"` +} + +// Profile is everything a machine last reported about what it can do. +// +// The same read ProfileOf uses, unreduced. Two functions parsing one column would be two things +// to keep agreeing about what a profile is. +func (i *Inventory) Profile(ctx context.Context, nodeName string) ([]Capability, error) { + var raw []byte + err := i.store.Pool().QueryRow(ctx, + `select profile from node where name = $1`, nodeName).Scan(&raw) + if errors.Is(err, pgx.ErrNoRows) { + return nil, fmt.Errorf("%w: %s", ErrNoSuchNode, nodeName) + } + if err != nil { + return nil, err + } + return profileFrom(raw) +} + +func profileFrom(raw []byte) ([]Capability, error) { + if len(raw) == 0 { + // A machine that has never reported. Not an error and not an empty machine: nil says + // "nothing is known", where an empty slice would say "it reported having nothing", and + // those send a person to different places. + return nil, nil + } + var reported struct { + Capabilities []Capability `json:"capabilities"` + } + if err := json.Unmarshal(raw, &reported); err != nil { + return nil, err + } + return reported.Capabilities, nil +} + // SetSettings records what somebody wants a module's configuration to say. // // An empty node name means the whole mesh. Replacing rather than merging what is already there: diff --git a/internal/inventory/catalogue_test.go b/internal/inventory/catalogue_test.go index b054993..300f081 100644 --- a/internal/inventory/catalogue_test.go +++ b/internal/inventory/catalogue_test.go @@ -517,3 +517,90 @@ func TestACatalogueEntryKnowsWhetherItIsBehind(t *testing.T) { t.Fatal("a module whose source moved reported itself current") } } + +// A capability's detail is half of what the machine said, and it was being thrown away. +// +// novox/hq ADR 0009: a capability's presence gates an assignment and its detail carries a value — +// `seat: card1-DP-1`. So *can this run here* and *what should it be configured as* are one fact +// read two ways, and keeping only the first read makes the second unanswerable. +func TestWhatAMachineSaidAboutItselfIsKeptWhole(t *testing.T) { + inv := ForTest(t) + ctx := t.Context() + node, err := inv.AddNode(ctx, "workstation") + if err != nil { + t.Fatal(err) + } + if err := inv.RecordProfile(ctx, node.ID, map[string]any{"capabilities": []map[string]any{ + {"name": "seat", "present": true, "detail": "card1-DP-1, card1-HDMI-A-1"}, + {"name": "container-runtime", "present": false, "detail": "docker is not installed"}, + }}); err != nil { + t.Fatal(err) + } + + held, err := inv.Profile(ctx, "workstation") + if err != nil { + t.Fatal(err) + } + if len(held) != 2 { + t.Fatalf("the machine reported two things and the mesh kept %d", len(held)) + } + found := map[string]Capability{} + for _, c := range held { + found[c.Name] = c + } + if found["seat"].Detail != "card1-DP-1, card1-HDMI-A-1" { + t.Fatalf("the value the machine reported was discarded: %+v", found["seat"]) + } + // And the absent one keeps its reason, which is the case a person most needs explaining: + // "this machine has no container runtime" is the answer and "docker is not installed" is why. + if found["container-runtime"].Present { + t.Fatal("something the machine said it does not have was recorded as present") + } + if found["container-runtime"].Detail != "docker is not installed" { + t.Fatalf("the reason a capability is absent was discarded: %+v", found["container-runtime"]) + } + + // The gating read is unchanged, and takes only what is present. + gates, err := inv.ProfileOf(ctx, "workstation") + if err != nil { + t.Fatal(err) + } + if !gates["seat"] || gates["container-runtime"] { + t.Fatalf("the gating read disagrees with what the machine said: %+v", gates) + } +} + +// Never reported is not the same as reported nothing: one machine has not run the host, the other +// ran it and can do nothing, and the remedies are different. +func TestNeverReportedAndReportedNothingAreDifferentProfiles(t *testing.T) { + inv := ForTest(t) + ctx := t.Context() + silent, err := inv.AddNode(ctx, "silent") + if err != nil { + t.Fatal(err) + } + empty, err := inv.AddNode(ctx, "empty") + if err != nil { + t.Fatal(err) + } + if err := inv.RecordProfile(ctx, empty.ID, + map[string]any{"capabilities": []map[string]any{}}); err != nil { + t.Fatal(err) + } + _ = silent + + never, err := inv.Profile(ctx, "silent") + if err != nil { + t.Fatal(err) + } + if never != nil { + t.Fatalf("a machine that never reported looks like one that reported nothing: %+v", never) + } + nothing, err := inv.Profile(ctx, "empty") + if err != nil { + t.Fatal(err) + } + if nothing == nil { + t.Fatal("a machine that reported nothing looks like one that never reported") + } +}