diff --git a/cmd/mesh-host/main.go b/cmd/mesh-host/main.go index 0c1c370..c246304 100644 --- a/cmd/mesh-host/main.go +++ b/cmd/mesh-host/main.go @@ -818,10 +818,7 @@ func enrol(ctx context.Context, opts options) error { // control plane cannot decide what a node should run without it, so it travels with the // request instead of being asked for in a second round trip. detected := profile.Detect(ctx, profile.Default(nil), opts.timeout) - reported := map[string]any{} - if raw, err := json.Marshal(detected); err == nil { - _ = json.Unmarshal(raw, &reported) - } + reported := profileAsReported(detected) // Signed with the identity just generated, so the mesh can tell this machine from anyone else // who knows its public key (novox/hq issue 083). @@ -1418,7 +1415,8 @@ func applyAndKeep(ctx context.Context, opts options, raw []byte, signed *store.D sched.Sync(declared, held) } - report := link.Report{Carried: carriedPorts(updated), Declared: digestOf(raw), Host: runningVersion()} + report := link.Report{Carried: carriedPorts(updated), Declared: digestOf(raw), Host: runningVersion(), + Profile: profileAsReported(profile.Detect(ctx, profile.Default(nil), opts.timeout))} // Which of this machine's links face outside, for the filter the mesh writes around them // (novox/hq ADR 0140). Reported whatever the node's mode: a converged node's filter needs it, // and an adopted one becomes converged without a further round trip. A machine that cannot read @@ -1630,3 +1628,13 @@ func refuseOlder(kept store.Declared, keptErr error, sequence int64) error { } return nil } + +// profileAsReported is the profile as the mesh reads it — the same bytes enrolment sends, so a +// report's profile and an enrolment's are one shape on the controller's side (novox/hq ADR 0161). +func profileAsReported(detected profile.Profile) map[string]any { + reported := map[string]any{} + if raw, err := json.Marshal(detected); err == nil { + _ = json.Unmarshal(raw, &reported) + } + return reported +} diff --git a/internal/link/messages.go b/internal/link/messages.go index ca9d0ce..1c1520d 100644 --- a/internal/link/messages.go +++ b/internal/link/messages.go @@ -110,6 +110,11 @@ type Report struct { // and the mesh's up in its place, and where the found configuration's original was kept. Tunnel *CarriedTunnel `json:"tunnel,omitempty"` + // Profile is what this machine can do, detected again by the apply that reports (novox/hq + // ADR 0161) — the same shape enrolment sends — so a capability gained or lost since enrolment, + // a network manager switched, reaches the mesh at the next push rather than never. + Profile map[string]any `json:"profile,omitempty"` + // Host is the version of the host that produced this report (novox/hq ADR 0141). // // Without it nothing can say a machine is behind, so "every machine current with its source" diff --git a/internal/profile/detectors.go b/internal/profile/detectors.go index d19114c..6798763 100644 --- a/internal/profile/detectors.go +++ b/internal/profile/detectors.go @@ -20,6 +20,14 @@ const ( // state: whether one IS running. Assignment needs the first. CapSeat = "seat" CapPrivileged = "privileged" + + // The network manager this machine runs, one capability per dialect (novox/hq ADR 0161): the + // uplink seat's holder declares its own, so the holder for a manager the machine does not run + // is refused the way any missing capability is, naming it. Active, not installed — a machine + // may have two of these on disk and runs one. + CapUplinkNetworkManager = "uplink-networkmanager" + CapUplinkSystemdNetworkd = "uplink-systemd-networkd" + CapUplinkDhcpcd = "uplink-dhcpcd" ) // commandCapability is the shape most detectors take: run something, and treat a working @@ -202,6 +210,21 @@ func Default(runner Runner) []Detector { why: "asks the kernel for interfaces — needs the module, not just the tool", runner: runner, }, + commandCapability{ + name: CapUplinkNetworkManager, command: "systemctl", args: []string{"is-active", "NetworkManager.service"}, + why: "asks the init whether NetworkManager is running — the dialect the uplink seat's holder must speak", + runner: runner, + }, + commandCapability{ + name: CapUplinkSystemdNetworkd, command: "systemctl", args: []string{"is-active", "systemd-networkd.service"}, + why: "asks the init whether systemd-networkd is running — the dialect the uplink seat's holder must speak", + runner: runner, + }, + commandCapability{ + name: CapUplinkDhcpcd, command: "systemctl", args: []string{"is-active", "dhcpcd.service"}, + why: "asks the init whether dhcpcd is running — the dialect the uplink seat's holder must speak", + runner: runner, + }, } } diff --git a/internal/profile/uplink_test.go b/internal/profile/uplink_test.go new file mode 100644 index 0000000..0c015d2 --- /dev/null +++ b/internal/profile/uplink_test.go @@ -0,0 +1,39 @@ +package profile + +import ( + "context" + "errors" + "testing" +) + +// The uplink seat's holder must be the dialect the machine runs (novox/hq ADR 0161): the profile +// names the network manager found active, one capability per manager, and nothing for one that is +// merely installed. +func TestTheProfileNamesTheNetworkManagerThatIsRunning(t *testing.T) { + runner := func(_ context.Context, name string, args ...string) (string, error) { + if name == "systemctl" && len(args) == 2 && args[0] == "is-active" { + if args[1] == "NetworkManager.service" { + return "active\n", nil + } + return "inactive\n", errors.New("exit status 3") + } + return "", errors.New("not here") + } + var have []Detector + for _, d := range Default(Runner(runner)) { + switch d.Name() { + case CapUplinkNetworkManager, CapUplinkSystemdNetworkd, CapUplinkDhcpcd: + have = append(have, d) + } + } + if len(have) != 3 { + t.Fatalf("expected a detector per manager, found %d", len(have)) + } + for _, d := range have { + v := d.Detect(context.Background()) + want := d.Name() == CapUplinkNetworkManager + if v.Present != want { + t.Errorf("%s: present=%v, want %v (%s)", d.Name(), v.Present, want, v.Detail) + } + } +}