diff --git a/internal/profile/detectors.go b/internal/profile/detectors.go index f20d4f6..d19114c 100644 --- a/internal/profile/detectors.go +++ b/internal/profile/detectors.go @@ -16,7 +16,10 @@ const ( CapFirewall = "firewall" CapOverlay = "overlay" CapGraphicalSession = "graphical-session" - CapPrivileged = "privileged" + // CapSeat is hardware: somewhere a display server COULD run. CapGraphicalSession above is + // state: whether one IS running. Assignment needs the first. + CapSeat = "seat" + CapPrivileged = "privileged" ) // commandCapability is the shape most detectors take: run something, and treat a working @@ -172,6 +175,7 @@ func Default(runner Runner) []Detector { return []Detector{ privileged{}, graphicalSession{}, + seat{}, commandCapability{ name: CapContainerRuntime, command: "docker", args: []string{"info", "--format", "{{.ServerVersion}}"}, why: "asks the daemon for its version — a running daemon, not an installed client", diff --git a/internal/profile/seat.go b/internal/profile/seat.go new file mode 100644 index 0000000..09fff1d --- /dev/null +++ b/internal/profile/seat.go @@ -0,0 +1,90 @@ +package profile + +import ( + "context" + "os" + "path/filepath" + "sort" + "strings" +) + +// A seat is somewhere a display server could run: a graphics device with a display attached. +// +// **Not the same question as `graphical-session`**, and the difference is what makes it worth +// having. That one asks whether a session is running *now*, which is state; this asks whether one +// could ever run here, which is hardware. Deciding whether to assign a display server needs the +// second — a headless server can never have one, a workstation with nothing installed yet can, +// and until this existed those two looked identical. The mesh would have assigned xorg to the +// server and found out at apply time. +// +// **`seat` rather than `display`, and the distinction matters most on a phone.** An Android +// device plainly has a screen and has no seat: nothing there is going to take a DRM device and +// present an X or Wayland session on it. A capability called `display` would answer *yes* and be +// useless; `seat` answers *no*, which is the true and useful answer. +// +// The term is logind's, and it means what is wanted here: a set of hardware one person sits at. + +// SeatSource is where the kernel reports what is attached. A variable so a test can point it at a +// directory it made, rather than at whatever this machine happens to have. +var SeatSource = "/sys/class/drm" + +type seat struct{} + +func (seat) Name() string { return CapSeat } + +func (seat) Detect(context.Context) Verdict { + const how = "/sys/class/drm/*/status — a connector the kernel reports as connected" + + entries, err := os.ReadDir(SeatSource) + if err != nil { + // No DRM subsystem at all: a container, a phone, a machine with no graphics stack. Said + // as what was looked for rather than as an error, because this is the ordinary answer on + // most nodes and not a fault on any of them. + return Verdict{ + Name: CapSeat, Present: false, + Detail: "no graphics devices are present (" + SeatSource + " is not readable)", + How: how, + } + } + + var connected, disconnected []string + for _, e := range entries { + status, err := os.ReadFile(filepath.Join(SeatSource, e.Name(), "status")) + if err != nil { + // Cards themselves have no status file; only connectors do. Skipping is right and + // not a failure to report. + continue + } + switch strings.TrimSpace(string(status)) { + case "connected": + connected = append(connected, e.Name()) + default: + disconnected = append(disconnected, e.Name()) + } + } + sort.Strings(connected) + + if len(connected) > 0 { + return Verdict{ + Name: CapSeat, Present: true, + Detail: strings.Join(connected, ", "), + How: how, + } + } + if len(disconnected) > 0 { + // The distinction worth drawing: this machine has graphics hardware and nothing plugged + // into it. A server with a GPU and no monitor, which is a different thing from a machine + // with no graphics at all — and a person deciding where a desktop goes wants to know + // which they are looking at. + return Verdict{ + Name: CapSeat, Present: false, + Detail: "a graphics device with no display attached", + How: how, + } + } + return Verdict{ + Name: CapSeat, Present: false, + Detail: "no display connectors are present", + How: how, + } +} diff --git a/internal/profile/seat_test.go b/internal/profile/seat_test.go new file mode 100644 index 0000000..d403440 --- /dev/null +++ b/internal/profile/seat_test.go @@ -0,0 +1,114 @@ +package profile + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +// drm builds what the kernel would expose, so the cases below are the real ones rather than a +// fake's idea of them: a workstation, a server with a card and no monitor, a machine with no +// graphics at all. +func drm(t *testing.T, connectors map[string]string) { + t.Helper() + dir := t.TempDir() + for name, status := range connectors { + if err := os.MkdirAll(filepath.Join(dir, name), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, name, "status"), []byte(status+"\n"), 0o644); err != nil { + t.Fatal(err) + } + } + // The card itself has no status file, and skipping it must not be mistaken for an answer. + if err := os.MkdirAll(filepath.Join(dir, "card0"), 0o755); err != nil { + t.Fatal(err) + } + old := SeatSource + SeatSource = dir + t.Cleanup(func() { SeatSource = old }) +} + +func TestAMachineWithAMonitorHasASeat(t *testing.T) { + drm(t, map[string]string{"card0-DP-1": "connected", "card0-HDMI-A-1": "disconnected"}) + + got := seat{}.Detect(context.Background()) + if !got.Present { + t.Fatalf("a machine with a connected display has no seat: %s", got.Detail) + } + if !strings.Contains(got.Detail, "card0-DP-1") { + t.Errorf("the answer does not say which connector: %q", got.Detail) + } + if strings.Contains(got.Detail, "HDMI") { + t.Errorf("a disconnected connector was counted: %q", got.Detail) + } +} + +func TestAServerWithAGraphicsCardAndNoMonitorHasNoSeat(t *testing.T) { + // The case this capability exists for. A display server assigned here would install, start, + // and have nowhere to draw — and the mesh would have thought it succeeded. + drm(t, map[string]string{"card0-HDMI-A-1": "disconnected", "card0-DP-1": "disconnected"}) + + got := seat{}.Detect(context.Background()) + if got.Present { + t.Fatal("a machine with nothing plugged in reported a seat") + } + // And it says which of the two "no" answers this is, because a person deciding where a + // desktop goes wants to know whether the hardware is there. + if !strings.Contains(got.Detail, "no display attached") { + t.Errorf("the answer does not distinguish this from having no graphics at all: %q", got.Detail) + } +} + +func TestAMachineWithNoGraphicsAtAllHasNoSeat(t *testing.T) { + // A container, or a phone, where the DRM subsystem is not there to read. The ordinary answer + // on most nodes and a fault on none of them, so it is a plain "no" rather than an error. + old := SeatSource + SeatSource = filepath.Join(t.TempDir(), "not-here") + t.Cleanup(func() { SeatSource = old }) + + got := seat{}.Detect(context.Background()) + if got.Present { + t.Fatal("a machine with no graphics subsystem reported a seat") + } + if got.Detail == "" || strings.Contains(strings.ToLower(got.Detail), "error") { + t.Errorf("absence was reported as a failure: %q", got.Detail) + } +} + +func TestAnUnknownConnectorIsNotASeat(t *testing.T) { + // Writeback connectors and some virtual devices report "unknown". Counting those would give + // a seat to machines that have none — and this workstation has one, so it is not theoretical. + drm(t, map[string]string{"card0-Writeback-1": "unknown"}) + + if (seat{}).Detect(context.Background()).Present { + t.Fatal("a connector reporting \"unknown\" was counted as a display") + } +} + +func TestTheAnswerSaysHowItKnows(t *testing.T) { + // Every detector says how, so a wrong answer can be found rather than argued about. + drm(t, map[string]string{"card0-DP-1": "connected"}) + if !strings.Contains((seat{}).Detect(context.Background()).How, "status") { + t.Error("the seat detector does not say what it looked at") + } +} + +func TestASeatIsNotAGraphicalSession(t *testing.T) { + // The two are different questions and the whole point of adding one was that they had been + // answered by the same thing. A machine can have a seat and no session — that is every + // workstation before anything is installed on it, and exactly where a display server should + // be assigned. + drm(t, map[string]string{"card0-DP-1": "connected"}) + t.Setenv("DISPLAY", "") + t.Setenv("WAYLAND_DISPLAY", "") + + if !(seat{}).Detect(context.Background()).Present { + t.Fatal("a machine with a monitor and no session has no seat") + } + if (graphicalSession{}).Detect(context.Background()).Present { + t.Fatal("a machine with no session reported one") + } +}