diff --git a/modules/docker/daemon.go b/modules/docker/daemon.go new file mode 100644 index 000000000..f3e88d7f7 --- /dev/null +++ b/modules/docker/daemon.go @@ -0,0 +1,44 @@ +package docker + +import ( + "os" + "path/filepath" + "strings" + + "github.com/docker/docker/pkg/pidfile" +) + +// resolvePidFilePath expands "auto" into a guess at the pid file's location, or +// "" when there is no local pid file to read. Set the path explicitly if the +// daemon runs with a --pidfile that isn't next to its socket. +func resolvePidFilePath(configured string, daemonHost string) string { + if configured != "auto" { + return configured + } + + socket, ok := strings.CutPrefix(daemonHost, "unix://") + if !ok { + return "" + } + + return filepath.Join(filepath.Dir(socket), "docker.pid") +} + +// daemonIsRunning reports whether dockerd is up, judged only from its pid file: +// under socket activation, asking the API is what starts it. +func daemonIsRunning(path string) (running bool, known bool) { + if path == "" { + return false, false + } + + pid, err := pidfile.Read(path) + if os.IsNotExist(err) { + return false, true + } + if err != nil { + return false, false + } + + // pidfile.Read reports 0 for a stale file. + return pid != 0, true +} diff --git a/modules/docker/daemon_test.go b/modules/docker/daemon_test.go new file mode 100644 index 000000000..3014e2bab --- /dev/null +++ b/modules/docker/daemon_test.go @@ -0,0 +1,62 @@ +package docker + +import ( + "os" + "path/filepath" + "strconv" + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_resolvePidFilePath(t *testing.T) { + tests := []struct { + name string + configured string + daemonHost string + expected string + }{ + {"unset", "", "unix:///var/run/docker.sock", ""}, + {"explicit path", "/tmp/docker.pid", "unix:///var/run/docker.sock", "/tmp/docker.pid"}, + {"auto, rootful", "auto", "unix:///var/run/docker.sock", "/var/run/docker.pid"}, + {"auto, rootless", "auto", "unix:///run/user/1000/docker.sock", "/run/user/1000/docker.pid"}, + {"auto, remote", "auto", "tcp://192.168.1.10:2376", ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, resolvePidFilePath(tt.configured, tt.daemonHost)) + }) + } +} + +func Test_daemonIsRunning(t *testing.T) { + dir := t.TempDir() + + pidFile := func(name, contents string) string { + path := filepath.Join(dir, name) + assert.NoError(t, os.WriteFile(path, []byte(contents), 0o644)) + return path + } + + tests := []struct { + name string + path string + running bool + known bool + }{ + {"no path", "", false, false}, + {"absent file", filepath.Join(dir, "absent.pid"), false, true}, + {"live pid", pidFile("live.pid", strconv.Itoa(os.Getpid())), true, true}, + {"stale pid", pidFile("stale.pid", "0"), false, true}, + {"malformed", pidFile("garbage.pid", "nope"), false, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + running, known := daemonIsRunning(tt.path) + assert.Equal(t, tt.known, known) + assert.Equal(t, tt.running, running) + }) + } +} diff --git a/modules/docker/example-conf.yml b/modules/docker/example-conf.yml index fd8801508..a7c3f60bb 100644 --- a/modules/docker/example-conf.yml +++ b/modules/docker/example-conf.yml @@ -7,7 +7,7 @@ wtf: focused: orange normal: gray checked: yellow - highlight: + highlight: fore: black back: gray rows: @@ -34,4 +34,6 @@ wtf: width: 3 refreshInterval: 1 labelColor: lightblue - \ No newline at end of file + # Read dockerd's pid file instead of the API to check if it's up, so a + # socket-activated daemon isn't started on every refresh. Omit to disable. + pidFilePath: "auto" diff --git a/modules/docker/settings.go b/modules/docker/settings.go index 940626a4e..2484d4228 100644 --- a/modules/docker/settings.go +++ b/modules/docker/settings.go @@ -15,7 +15,8 @@ const ( type Settings struct { *cfg.Common - labelColor string + labelColor string + pidFilePath string } // NewSettingsFromYAML creates and returns an instance of Settings with configuration options populated @@ -23,6 +24,10 @@ func NewSettingsFromYAML(name string, ymlConfig *config.Config, globalConfig *co settings := Settings{ Common: cfg.NewCommonSettingsFromModule(name, defaultTitle, defaultFocusable, ymlConfig, globalConfig), labelColor: ymlConfig.UString("labelColor", "white"), + + // Path to dockerd's pid file, read instead of probing the API so a + // socket-activated daemon isn't woken up. "auto" derives it; "" disables. + pidFilePath: ymlConfig.UString("pidFilePath", ""), } return &settings diff --git a/modules/docker/widget.go b/modules/docker/widget.go index db548bd7c..b26d92de8 100644 --- a/modules/docker/widget.go +++ b/modules/docker/widget.go @@ -12,6 +12,7 @@ type Widget struct { view.TextWidget cli *client.Client settings *Settings + pidFilePath string displayBuffer string } @@ -28,6 +29,7 @@ func NewWidget(tviewApp *tview.Application, redrawChan chan bool, pages *tview.P widget.displayBuffer = fmt.Errorf("could not create client: %w", err).Error() } else { widget.cli = cli + widget.pidFilePath = resolvePidFilePath(settings.pidFilePath, cli.DaemonHost()) } widget.refreshDisplayBuffer() @@ -53,6 +55,11 @@ func (widget *Widget) refreshDisplayBuffer() { return } + if running, known := daemonIsRunning(widget.pidFilePath); known && !running { + widget.displayBuffer = fmt.Sprintf("[%s] docker daemon is not running[white]\n", widget.settings.Colors.Subheading) + return + } + widget.displayBuffer = "" widget.displayBuffer += fmt.Sprintf("[%s] System[white]\n", widget.settings.Colors.Subheading)