Files
mesh-catalog/modules/disk-load/cmd/disk-load/tools.go
T
jochen 6fdf5293eb
mesh/merge-gate pass: builds new: modules/disk-load, sent nowhere; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
Add disk-load: no tool said a machine's iowait, disk load or who does the I/O (hq issue 312)
2026-10-08 10:47:41 +02:00

173 lines
6.0 KiB
Go

package main
import (
"fmt"
"math"
"strconv"
"strings"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
)
// Tools is the module's tools over one machine. Every one only reads.
func Tools(m *Machine) []stdio.Tool {
window := map[string]any{"type": "integer", "description": "how long to sample, in seconds (default 2, at most 10)"}
return []stdio.Tool{
{
Name: "disk_load",
Description: "How loaded this machine's storage is, sampled over a few seconds: the share of CPU time spent waiting on I/O (iowait), " +
"I/O pressure stall (PSI), and for each disk how busy it was (%), read and write throughput (MiB/s), IOPS, the average wait " +
"per request (ms) and the queue depth, with the filesystems it holds; then the filesystems' space and the processes " +
"and the units moving the most bytes (from their cgroups), with their container. Each finding is said in a line under `said`. Replaces iostat, vmstat, " +
"iotop and df. (r)",
Input: map[string]any{
"seconds": window,
"processes": map[string]any{"type": "integer", "description": "how many of the busiest units and processes to name (default 5, 0 for none, at most 50)"},
"all": map[string]any{"type": "boolean", "description": "every block device and partition, not only the whole disks (default false)"},
},
Run: func(args map[string]any) (any, error) {
w, err := seconds(args)
if err != nil {
return nil, err
}
n, err := count(args, "processes", 5, 0, 50)
if err != nil {
return nil, err
}
return m.LoadOver(w, n, flag(args, "all"), true)
},
},
{
Name: "disk_top",
Description: "What moved the most bytes to and from storage over a few seconds: by=unit (the default) each systemd unit " +
"and container from its cgroup's io.stat, with MiB/s and IOPS, complete whoever runs the tools; by=container only the " +
"containers, by name; by=process each process from /proc/<pid>/io, which misses another account's unless the tool " +
"runner is root. Replaces iotop. (r)",
Input: map[string]any{
"seconds": window,
"limit": map[string]any{"type": "integer", "description": "how many (default 10, at most 50)"},
"by": map[string]any{"type": "string", "enum": []string{"process", "unit", "container"}, "description": "what to rank (default unit)"},
},
Run: func(args map[string]any) (any, error) {
w, err := seconds(args)
if err != nil {
return nil, err
}
limit, err := count(args, "limit", 10, 1, 50)
if err != nil {
return nil, err
}
by := text(args, "by")
switch by {
case "":
by = "unit"
case "process", "unit", "container":
default:
return nil, fmt.Errorf("by is process, unit or container, not %q", by)
}
out := map[string]any{"window_s": w.Seconds(), "by": by}
if by != "process" {
counted := m.bottomDevices()
a := m.unitIO(counted)
m.Wait(w)
out["top"] = m.topUnits(a, m.unitIO(counted), w, by, limit)
return out, nil
}
a, _ := m.processIO()
m.Wait(w)
b, unreadable := m.processIO()
out["top"] = m.topProcesses(a, b, w, limit)
if unreadable > 0 {
out["processes_unreadable"] = unreadable
out["note"] = unreadableNote(unreadable)
}
return out, nil
},
},
{
Name: "disk_filesystems",
Description: "The space of every filesystem that stores data (not proc, tmpfs or overlay layers): size, used and free in GiB, " +
"used % as df counts it, and inodes used %. A filesystem mounted at several places is said once. Replaces df. (r)",
Run: func(map[string]any) (any, error) {
fss := m.Filesystems()
said := []string{}
for _, fs := range fss {
switch {
case fs.Unanswered != "":
said = append(said, fmt.Sprintf("%s: not read (%s)", fs.Mount, fs.Unanswered))
default:
line := fmt.Sprintf("%s (%s, %s): %.1f %% used, %.1f GiB free of %.1f GiB", fs.Mount, fs.Type, fs.Source, fs.UsedPct, fs.AvailGiB, fs.SizeGiB)
if fs.UsedPct >= fullLine || fs.InodesUsed >= fullLine {
line += " — NEARLY FULL"
}
said = append(said, line)
}
}
return map[string]any{"said": said, "filesystems": fss}, nil
},
},
{
Name: "disk_devices",
Description: "Each disk as it is, without sampling: model, size (GiB), whether it spins, its I/O scheduler and queue limit, what " +
"it is built on (for LVM, encryption, RAID), and since the machine started: GiB read and written, the share of the time it " +
"was busy and the average wait per read and per write (ms). Replaces lsblk and cat /proc/diskstats. (r)",
Input: map[string]any{
"all": map[string]any{"type": "boolean", "description": "every block device and partition, not only the whole disks (default false)"},
},
Run: func(args map[string]any) (any, error) {
ds, up := m.DevicesNow(flag(args, "all"))
return map[string]any{"up_hours": up, "devices": ds}, nil
},
},
}
}
func seconds(args map[string]any) (time.Duration, error) {
n, err := count(args, "seconds", 2, 1, 10)
return time.Duration(n) * time.Second, err
}
func text(args map[string]any, key string) string {
s, _ := args[key].(string)
return strings.TrimSpace(s)
}
func flag(args map[string]any, key string) bool {
switch v := args[key].(type) {
case bool:
return v
case string:
return v == "true"
}
return false
}
// count is a whole-number argument, defaulted, refused below least, held at most.
func count(args map[string]any, key string, fallback, least, most int) (int, error) {
v, given := args[key]
if !given || v == nil {
return fallback, nil
}
var n int
switch x := v.(type) {
case float64:
if x != math.Trunc(x) {
return 0, fmt.Errorf("%s must be a whole number, not %v", key, x)
}
n = int(x)
case string:
i, err := strconv.Atoi(strings.TrimSpace(x))
if err != nil {
return 0, fmt.Errorf("%s must be a whole number, not %q", key, x)
}
n = i
default:
return 0, fmt.Errorf("%s must be a whole number", key)
}
if n < least {
return 0, fmt.Errorf("%s must be at least %d", key, least)
}
return min(n, most), nil
}