Merge pull request 'Add disk-load: a machine's iowait, disk load and who does the I/O (hq issue 312)' (#120) from feat/disk-load into main
This commit was merged in pull request #120.
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
# disk-load
|
||||
|
||||
How loaded a machine's storage is, read from `/proc` and `/sys` and nothing else: the share of CPU time
|
||||
spent waiting on I/O, I/O pressure stall, each disk's busy share, throughput, IOPS, wait and queue
|
||||
depth, the filesystems' space, and the units, containers and processes doing the I/O (novox/hq issue
|
||||
312). It changes nothing and declares no resources, so it can be assigned to every machine.
|
||||
|
||||
## Why it exists, and why here
|
||||
|
||||
Investigating why the controller's checks ran five to seven times slower on the control node (hq issue
|
||||
306) needed disk numbers, and no tool in the mesh reported any: not iowait, not a disk's utilisation,
|
||||
not throughput, latency or which process wrote. The operator's rule is that a missing tool is built in
|
||||
the module that owns the area, never worked round with a login to the machine.
|
||||
|
||||
No module owned the area. `memory-pressure` reads the same kind of thing for memory, but it owns
|
||||
compressed swap and systemd-oomd: assigning it to a machine to read the disks would change how that
|
||||
machine handles memory. No node seat holds the machine's storage either. Every seat is a role one
|
||||
module holds on each machine, with verbs that act through it, and a new seat costs a controller change
|
||||
first (hq ADR 0246). A report that reads kernel counters needs neither a role nor exclusivity. So this
|
||||
is a small module with tools only, like `netcheck` beside the network. If the mesh later gives a
|
||||
machine's resources a seat, these tools become its verbs.
|
||||
|
||||
## Tools
|
||||
|
||||
All four only read.
|
||||
|
||||
| tool | what | replaces |
|
||||
|---|---|---|
|
||||
| `disk_load` | Samples a window (`seconds`, default 2, at most 10). It answers the CPU's iowait, user, system and idle %; I/O pressure (some and full, over the window and as avg10/60/300); per disk busy %, read and write MiB/s, IOPS, wait per request in ms, queue depth and requests in flight, with the filesystems each disk holds; the filesystems' space; and the busiest units and processes (`processes`, default 5, 0 for none). `said` puts each finding in one line, flagged past a line (below) | `iostat -x`, `vmstat`, `iotop`, `df` |
|
||||
| `disk_top` | Over a window. `by=unit` (the default) ranks systemd units and containers by bytes moved, from their cgroups. `by=container` ranks containers by name, and `by=process` ranks processes | `iotop` |
|
||||
| `disk_filesystems` | Each filesystem that stores data, said once however many places it is mounted: size, used and free GiB, used % as `df` counts it, inodes used % | `df -h`, `df -i` |
|
||||
| `disk_devices` | Each disk without sampling: model, size, whether it spins, scheduler, queue limit, what it is built on, and since boot the GiB read and written, the busy share and the wait per read and per write | `lsblk`, `cat /proc/diskstats` |
|
||||
|
||||
### How each number is read
|
||||
|
||||
- **iowait** is from `/proc/stat`'s `cpu` line: the share of every CPU's time in the window spent idle
|
||||
with I/O outstanding. Guest time is left out, because the kernel already counts it in user.
|
||||
- **A disk** is read from `/proc/diskstats` at both ends of the window, with iostat's arithmetic.
|
||||
Busy is the share of the window with any request in flight. Wait is the time spent on the
|
||||
requests completed, divided by their number. Queue depth is the weighted time divided by the window.
|
||||
Sectors there are always 512 bytes. A counter that went backwards (a device removed and added)
|
||||
reads as 0, never as a huge number. Loop devices, RAM disks and zram are left out unless `all`;
|
||||
zram is memory, and `memory-pressure` reports it.
|
||||
- **Pressure** is `/proc/pressure/io`. The share of the window comes from the `total` counter, in
|
||||
microseconds. A kernel without PSI is said as not available.
|
||||
- **Units and containers** come from each cgroup's `io.stat`, which every account can read. Only the
|
||||
innermost `.service` or `.scope` counts, because `io.stat` includes descendants. Only whole disks
|
||||
built on nothing count: a write through an encrypted or logical volume is charged to the volume and
|
||||
again to the disk under it, and a loop device's writes are charged again to the filesystem its file
|
||||
sits on. Container names come from `docker ps`; if the runtime cannot be asked, a container is
|
||||
given by its short id.
|
||||
- **Processes** come from `/proc/<pid>/io` (`read_bytes`, `write_bytes`, which is storage, not the
|
||||
page cache). Another account's process can be read only when the tool runner is root. The unreadable
|
||||
are counted and said, and the units above miss none of them. A process that started inside the
|
||||
window is left out.
|
||||
- **Filesystems** come from the process's own mount table, keeping only types that store data
|
||||
(ext4, xfs, btrfs, vfat, NFS and the like). Each one is checked with statfs, given 2 s per mount so
|
||||
a dead network mount costs one line.
|
||||
|
||||
### The lines
|
||||
|
||||
| finding | past |
|
||||
|---|---|
|
||||
| iowait HIGH | 20 % of CPU time |
|
||||
| I/O pressure HIGH | some task stalled 10 % of the window, or avg60 ≥ 10 % |
|
||||
| disk SATURATED | busy 90 % of the window |
|
||||
| filesystem NEARLY FULL | 90 % of its space or of its inodes |
|
||||
|
||||
Busy % is how iostat counts it, so it overstates an NVMe disk, which serves many requests at once. Read
|
||||
it beside the wait and the queue depth.
|
||||
|
||||
## Tests
|
||||
|
||||
`go test ./...`. The tests read a tree standing in for `/proc` and `/sys`. Its fixture is a server
|
||||
writing 100 MiB in 2 s to an encrypted root on an NVMe disk, with a spinning disk idle beside it. They
|
||||
cover:
|
||||
|
||||
- iowait, each disk's numbers, and the encrypted root named, with what it sits on and what it holds;
|
||||
- loop, zram and partitions left out unless asked for;
|
||||
- pressure, and a kernel without it;
|
||||
- a counter reset, and busy held at 100;
|
||||
- units counted once, by innermost cgroup and bottom disk;
|
||||
- containers by name, or by short id if the runtime cannot be asked;
|
||||
- processes ranked, one that started inside the window left out, unreadable ones counted;
|
||||
- filesystems said once, mount escapes undone, a statfs that fails said;
|
||||
- the window's bounds;
|
||||
- the manifest: tools listed equal tools served, all read-only, no resources, no machine named.
|
||||
@@ -0,0 +1,169 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// cgroupIO is a cgroup's bytes and requests to storage, from its io.stat. Unlike /proc/<pid>/io, io.stat
|
||||
// is readable by every account, so a unit or a container is seen whoever runs the tool runner.
|
||||
type cgroupIO struct {
|
||||
read, write, rios, wios uint64
|
||||
}
|
||||
|
||||
// parseIOStat sums the lines of an io.stat ("259:0 rbytes=… wbytes=… rios=… wios=…") over the devices
|
||||
// counted counts; every device when counted is nil.
|
||||
func parseIOStat(s string, counted map[string]bool) cgroupIO {
|
||||
var c cgroupIO
|
||||
for _, line := range strings.Split(s, "\n") {
|
||||
f := strings.Fields(line)
|
||||
if len(f) < 2 || (counted != nil && !counted[f[0]]) {
|
||||
continue
|
||||
}
|
||||
for _, kv := range f[1:] {
|
||||
k, v, _ := strings.Cut(kv, "=")
|
||||
n, _ := strconv.ParseUint(v, 10, 64)
|
||||
switch k {
|
||||
case "rbytes":
|
||||
c.read += n
|
||||
case "wbytes":
|
||||
c.write += n
|
||||
case "rios":
|
||||
c.rios += n
|
||||
case "wios":
|
||||
c.wios += n
|
||||
}
|
||||
}
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// bottomDevices are the major:minor of the disks I/O finally lands on. A write through an encrypted
|
||||
// or logical volume is charged to the volume and again to the disk under it, and a loop device's to the
|
||||
// filesystem its file is on, so only a whole disk built on nothing is counted. nil (count every line)
|
||||
// when /proc/diskstats cannot be read.
|
||||
func (m *Machine) bottomDevices() map[string]bool {
|
||||
text := m.read("/proc/diskstats")
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
stats := parseDiskstats(text)
|
||||
disks := map[string]bool{}
|
||||
for _, name := range m.blockDevices(stats, false) {
|
||||
if len(m.below(name)) == 0 {
|
||||
disks[name] = true
|
||||
}
|
||||
}
|
||||
out := map[string]bool{}
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
f := strings.Fields(line)
|
||||
if len(f) > 2 && disks[f[2]] {
|
||||
out[f[0]+":"+f[1]] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// unitIO is every unit's storage I/O, by its cgroup's path: the innermost .service or .scope, because
|
||||
// io.stat counts a cgroup's descendants too and an outer unit would count them twice.
|
||||
func (m *Machine) unitIO(counted map[string]bool) map[string]cgroupIO {
|
||||
base := m.path("/sys/fs/cgroup")
|
||||
var units []string
|
||||
filepath.WalkDir(base, func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil || !d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if n := d.Name(); strings.HasSuffix(n, ".service") || strings.HasSuffix(n, ".scope") {
|
||||
rel, _ := filepath.Rel(base, p)
|
||||
units = append(units, filepath.ToSlash(rel))
|
||||
}
|
||||
return nil
|
||||
})
|
||||
outer := map[string]bool{}
|
||||
for _, u := range units {
|
||||
for dir := filepath.Dir(u); dir != "." && dir != "/"; dir = filepath.Dir(dir) {
|
||||
outer[dir] = true
|
||||
}
|
||||
}
|
||||
out := map[string]cgroupIO{}
|
||||
for _, u := range units {
|
||||
if outer[u] {
|
||||
continue
|
||||
}
|
||||
text := m.read("/sys/fs/cgroup/" + u + "/io.stat")
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
out[u] = parseIOStat(text, counted)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// topUnits is the units, or the containers, that moved the most bytes between two readings of io.stat.
|
||||
func (m *Machine) topUnits(a, b map[string]cgroupIO, window time.Duration, by string, limit int) []Process {
|
||||
s := window.Seconds()
|
||||
groups := map[string]*Process{}
|
||||
var names map[string]string
|
||||
for path, end := range b {
|
||||
start, ok := a[path]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
r, w := sub(end.read, start.read), sub(end.write, start.write)
|
||||
if r+w == 0 {
|
||||
continue
|
||||
}
|
||||
unit := filepath.Base(path)
|
||||
key := unit
|
||||
var container string
|
||||
if id := containerID.FindStringSubmatch("/" + path); id != nil {
|
||||
if names == nil {
|
||||
names = m.containerNames()
|
||||
}
|
||||
container = id[1][:12]
|
||||
if n, ok := names[id[1]]; ok {
|
||||
container = n
|
||||
}
|
||||
}
|
||||
if by == "container" {
|
||||
if container == "" {
|
||||
continue
|
||||
}
|
||||
key = container
|
||||
}
|
||||
g, ok := groups[key]
|
||||
if !ok {
|
||||
g = &Process{Unit: unit, Container: container}
|
||||
if by == "container" {
|
||||
g.Unit = ""
|
||||
}
|
||||
groups[key] = g
|
||||
}
|
||||
g.ReadMiB += r
|
||||
g.WriteMiB += w
|
||||
g.bytes += r + w
|
||||
g.ReadIOPS += round1(sub(end.rios, start.rios) / s)
|
||||
g.WriteIOPS += round1(sub(end.wios, start.wios) / s)
|
||||
}
|
||||
out := make([]Process, 0, len(groups))
|
||||
for _, g := range groups {
|
||||
out = append(out, *g)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].bytes != out[j].bytes {
|
||||
return out[i].bytes > out[j].bytes
|
||||
}
|
||||
return out[i].Unit+out[i].Container < out[j].Unit+out[j].Container
|
||||
})
|
||||
if len(out) > limit {
|
||||
out = out[:limit]
|
||||
}
|
||||
for i := range out {
|
||||
inMiB(&out[i], s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// storedOn are the filesystem types that hold data on a device or a server. Everything else in the
|
||||
// mount table (proc, sysfs, cgroup, tmpfs, overlay layers, squashfs images…) is the kernel's or a copy.
|
||||
var storedOn = map[string]bool{
|
||||
"ext2": true, "ext3": true, "ext4": true, "xfs": true, "btrfs": true, "zfs": true, "f2fs": true,
|
||||
"bcachefs": true, "vfat": true, "exfat": true, "ntfs": true, "ntfs3": true, "fuseblk": true,
|
||||
"nfs": true, "nfs4": true, "cifs": true, "smb3": true,
|
||||
}
|
||||
|
||||
// mount is one line of the mount table.
|
||||
type mount struct{ source, target, fstype string }
|
||||
|
||||
// unescape undoes the mount table's octal escapes (a space is \040).
|
||||
func unescape(s string) string {
|
||||
if !strings.Contains(s, `\`) {
|
||||
return s
|
||||
}
|
||||
var b strings.Builder
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] == '\\' && i+3 < len(s) {
|
||||
o := s[i+1 : i+4]
|
||||
if len(o) == 3 && o[0] >= '0' && o[0] <= '3' && o[1] >= '0' && o[1] <= '7' && o[2] >= '0' && o[2] <= '7' {
|
||||
b.WriteByte((o[0]-'0')<<6 | (o[1]-'0')<<3 | (o[2] - '0'))
|
||||
i += 3
|
||||
continue
|
||||
}
|
||||
}
|
||||
b.WriteByte(s[i])
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// mounts is this process's mount table, only the filesystems that store data.
|
||||
func (m *Machine) mounts() []mount {
|
||||
var out []mount
|
||||
for _, line := range strings.Split(m.read("/proc/self/mounts"), "\n") {
|
||||
f := strings.Fields(line)
|
||||
if len(f) < 3 || !storedOn[f[2]] {
|
||||
continue
|
||||
}
|
||||
out = append(out, mount{source: unescape(f[0]), target: unescape(f[1]), fstype: f[2]})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Filesystem is one filesystem's space, once however many places it is mounted at.
|
||||
type Filesystem struct {
|
||||
Mount string `json:"mount"`
|
||||
AlsoAt []string `json:"also_at,omitempty"`
|
||||
Source string `json:"source"`
|
||||
Device string `json:"device,omitempty"`
|
||||
Type string `json:"type"`
|
||||
SizeGiB float64 `json:"size_gib"`
|
||||
UsedGiB float64 `json:"used_gib"`
|
||||
AvailGiB float64 `json:"available_gib"`
|
||||
UsedPct float64 `json:"used_pct"`
|
||||
InodesUsed float64 `json:"inodes_used_pct,omitempty"`
|
||||
Unanswered string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// blockName is the kernel's name for the device a mount's source names: /dev/nvme0n1p3 is nvme0n1p3,
|
||||
// /dev/mapper/root the dm-N whose name is root; "" for a source that is no local device.
|
||||
func (m *Machine) blockName(source string, mappers map[string]string) string {
|
||||
if !strings.HasPrefix(source, "/dev/") {
|
||||
return ""
|
||||
}
|
||||
if name, ok := strings.CutPrefix(source, "/dev/mapper/"); ok {
|
||||
return mappers[name]
|
||||
}
|
||||
// /dev/disk/by-uuid/… and the like are links the system made; reading where one points creates nothing.
|
||||
if m.Root == "/" {
|
||||
if real, err := filepath.EvalSymlinks(source); err == nil {
|
||||
source = real
|
||||
}
|
||||
}
|
||||
return filepath.Base(source)
|
||||
}
|
||||
|
||||
// mappers is each device-mapper name with the dm-N it is.
|
||||
func (m *Machine) mappers() map[string]string {
|
||||
out := map[string]string{}
|
||||
for _, dev := range m.names("/sys/block") {
|
||||
if name := m.mapperName(dev); name != "" {
|
||||
out[name] = dev
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Filesystems is the space of every filesystem that stores data. A filesystem mounted at several
|
||||
// places (a btrfs subvolume, a bind mount) is said once, at its first mount, with the others beside it.
|
||||
func (m *Machine) Filesystems() []Filesystem {
|
||||
mappers := m.mappers()
|
||||
var out []Filesystem
|
||||
seen := map[string]int{}
|
||||
for _, mt := range m.mounts() {
|
||||
key := mt.source
|
||||
if i, ok := seen[key]; ok {
|
||||
out[i].AlsoAt = append(out[i].AlsoAt, mt.target)
|
||||
continue
|
||||
}
|
||||
fs := Filesystem{Mount: mt.target, Source: mt.source, Type: mt.fstype, Device: m.blockName(mt.source, mappers)}
|
||||
st, err := m.Statfs(m.path(mt.target))
|
||||
if err != nil {
|
||||
fs.Unanswered = err.Error()
|
||||
} else if st.Size > 0 {
|
||||
used := st.Size - st.Free
|
||||
fs.SizeGiB = round1(float64(st.Size) / gib)
|
||||
fs.UsedGiB = round1(float64(used) / gib)
|
||||
fs.AvailGiB = round1(float64(st.Avail) / gib)
|
||||
// As df counts it: used against what an ordinary user could have, so the reserve shows as full.
|
||||
if used+st.Avail > 0 {
|
||||
fs.UsedPct = round1(float64(used) / float64(used+st.Avail) * 100)
|
||||
}
|
||||
if st.Files > 0 {
|
||||
fs.InodesUsed = round1(float64(st.Files-st.FilesFree) / float64(st.Files) * 100)
|
||||
}
|
||||
}
|
||||
seen[key] = len(out)
|
||||
out = append(out, fs)
|
||||
}
|
||||
if out == nil {
|
||||
out = []Filesystem{}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fake is a machine for a test: a tree standing in for /proc and /sys, a statfs answering from a table,
|
||||
// a runner answering from a table, and a wait that turns the tree into its second reading.
|
||||
type fake struct {
|
||||
t *testing.T
|
||||
root string
|
||||
later map[string]string
|
||||
gone []string
|
||||
space map[string]FSStat
|
||||
answers map[string]string
|
||||
calls []string
|
||||
waited time.Duration
|
||||
}
|
||||
|
||||
func newFake(t *testing.T) *fake {
|
||||
t.Helper()
|
||||
return &fake{t: t, root: t.TempDir(), later: map[string]string{}, space: map[string]FSStat{}, answers: map[string]string{}}
|
||||
}
|
||||
|
||||
func (f *fake) machine() *Machine {
|
||||
return &Machine{
|
||||
Root: f.root,
|
||||
Run: func(_ context.Context, name string, args ...string) (string, error) {
|
||||
line := strings.TrimSpace(name + " " + strings.Join(args, " "))
|
||||
f.calls = append(f.calls, line)
|
||||
if out, ok := f.answers[line]; ok {
|
||||
return out, nil
|
||||
}
|
||||
return "", fmt.Errorf("%s: not found", name)
|
||||
},
|
||||
Wait: func(d time.Duration) {
|
||||
f.waited += d
|
||||
for p, c := range f.later {
|
||||
f.file(p, c)
|
||||
}
|
||||
for _, p := range f.gone {
|
||||
os.RemoveAll(filepath.Join(f.root, p))
|
||||
}
|
||||
},
|
||||
Statfs: func(path string) (FSStat, error) {
|
||||
rel, _ := filepath.Rel(f.root, path)
|
||||
s, ok := f.space[filepath.Clean("/"+filepath.ToSlash(rel))]
|
||||
if !ok {
|
||||
return FSStat{}, fmt.Errorf("no such filesystem")
|
||||
}
|
||||
return s, nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// file writes a file under the fake root.
|
||||
func (f *fake) file(path, content string) {
|
||||
f.t.Helper()
|
||||
full := filepath.Join(f.root, path)
|
||||
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
||||
f.t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(full, []byte(content), 0o644); err != nil {
|
||||
f.t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// dir makes a directory under the fake root: sysfs's entries that are links on a real machine are
|
||||
// directories here, because the module only lists their names.
|
||||
func (f *fake) dir(path string) {
|
||||
f.t.Helper()
|
||||
if err := os.MkdirAll(filepath.Join(f.root, path), 0o755); err != nil {
|
||||
f.t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// then is a file's content at the second reading.
|
||||
func (f *fake) then(path, content string) { f.later[path] = content }
|
||||
@@ -0,0 +1,264 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The lines past which a reading is said as a finding, not only a number.
|
||||
const (
|
||||
busyLine = 90.0 // a device with a request in flight this share of the window is saturated
|
||||
iowaitLine = 20.0 // the processors idle on I/O this share of their time
|
||||
stallLine = 10.0 // some task stalled on I/O this share of the window
|
||||
fullLine = 90.0 // a filesystem this full, or this short of inodes
|
||||
)
|
||||
|
||||
// reading is everything read at one moment of the window.
|
||||
type reading struct {
|
||||
cpu cpuTimes
|
||||
disks map[string]diskCounters
|
||||
psi PSI
|
||||
psiOK bool
|
||||
procs map[int]procIO
|
||||
cantRead int
|
||||
units map[string]cgroupIO
|
||||
}
|
||||
|
||||
func (m *Machine) readNow(withProcesses bool, counted map[string]bool) (reading, error) {
|
||||
var r reading
|
||||
var err error
|
||||
if r.cpu, err = parseCPU(m.read("/proc/stat")); err != nil {
|
||||
return r, err
|
||||
}
|
||||
r.disks = parseDiskstats(m.read("/proc/diskstats"))
|
||||
r.psi, r.psiOK = parsePSI(m.read("/proc/pressure/io"))
|
||||
if withProcesses {
|
||||
r.procs, r.cantRead = m.processIO()
|
||||
r.units = m.unitIO(counted)
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// Load is the machine's disk load over one window.
|
||||
type Load struct {
|
||||
WindowS float64 `json:"window_s"`
|
||||
Said []string `json:"said"`
|
||||
CPU CPU `json:"cpu"`
|
||||
Pressure any `json:"io_pressure"`
|
||||
Devices []Device `json:"devices"`
|
||||
Filesystems []Filesystem `json:"filesystems,omitempty"`
|
||||
Units []Process `json:"units,omitempty"`
|
||||
Processes []Process `json:"processes,omitempty"`
|
||||
Unreadable int `json:"processes_unreadable,omitempty"`
|
||||
Note string `json:"note,omitempty"`
|
||||
}
|
||||
|
||||
// LoadOver reads the machine, waits the window, reads it again and says what happened between.
|
||||
// processes is how many of the busiest processes to name (0 for none); all keeps every device and
|
||||
// partition, not only the whole disks.
|
||||
func (m *Machine) LoadOver(window time.Duration, processes int, all, filesystems bool) (Load, error) {
|
||||
counted := m.bottomDevices()
|
||||
a, err := m.readNow(processes > 0, counted)
|
||||
if err != nil {
|
||||
return Load{}, err
|
||||
}
|
||||
m.Wait(window)
|
||||
b, err := m.readNow(processes > 0, counted)
|
||||
if err != nil {
|
||||
return Load{}, err
|
||||
}
|
||||
l := Load{WindowS: window.Seconds(), CPU: cpuShare(a.cpu, b.cpu)}
|
||||
|
||||
mounts := map[string][]string{}
|
||||
if filesystems {
|
||||
l.Filesystems = m.Filesystems()
|
||||
for _, fs := range l.Filesystems {
|
||||
if fs.Device == "" {
|
||||
continue
|
||||
}
|
||||
mounts[fs.Device] = append(mounts[fs.Device], fs.Mount)
|
||||
if p := m.parentOf(fs.Device); p != fs.Device {
|
||||
mounts[p] = append(mounts[p], fs.Mount+" ("+fs.Device+")")
|
||||
}
|
||||
}
|
||||
}
|
||||
l.Devices = []Device{}
|
||||
for _, name := range m.blockDevices(b.disks, all) {
|
||||
start, ok := a.disks[name]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
d := deviceOver(name, start, b.disks[name], window)
|
||||
d.Name = m.mapperName(name)
|
||||
d.On = m.below(name)
|
||||
d.Mounts = mounts[name]
|
||||
l.Devices = append(l.Devices, d)
|
||||
}
|
||||
sort.SliceStable(l.Devices, func(i, j int) bool { return l.Devices[i].BusyPct > l.Devices[j].BusyPct })
|
||||
|
||||
if a.psiOK && b.psiOK {
|
||||
l.Pressure = psiOver(a.psi, b.psi, window)
|
||||
} else {
|
||||
l.Pressure = "not available: this kernel has no /proc/pressure/io (pressure stall information is off)"
|
||||
}
|
||||
if processes > 0 {
|
||||
l.Units = m.topUnits(a.units, b.units, window, "unit", processes)
|
||||
l.Processes = m.topProcesses(a.procs, b.procs, window, processes)
|
||||
l.Unreadable = b.cantRead
|
||||
if b.cantRead > 0 {
|
||||
l.Note = unreadableNote(b.cantRead)
|
||||
}
|
||||
}
|
||||
l.Said = say(l)
|
||||
return l, nil
|
||||
}
|
||||
|
||||
func say(l Load) []string {
|
||||
var out []string
|
||||
c := l.CPU
|
||||
line := fmt.Sprintf("CPU: %.1f %% waiting on I/O, %.1f %% user, %.1f %% system, %.1f %% idle, over %g s on %d CPUs", c.IOWaitPct, c.UserPct, c.SystemPct, c.IdlePct, l.WindowS, c.CPUs)
|
||||
if c.IOWaitPct >= iowaitLine {
|
||||
line += " — HIGH: the processors sat idle waiting on storage"
|
||||
}
|
||||
out = append(out, line)
|
||||
if p, ok := l.Pressure.(PSI); ok {
|
||||
line := fmt.Sprintf("I/O pressure: some task stalled %.1f %% of the window (avg10 %.1f %%, avg60 %.1f %%, avg300 %.1f %%)", p.Some.WindowPct, p.Some.Avg10, p.Some.Avg60, p.Some.Avg300)
|
||||
if p.Full != nil {
|
||||
line += fmt.Sprintf("; every task stalled %.1f %%", p.Full.WindowPct)
|
||||
}
|
||||
if p.Some.WindowPct >= stallLine || p.Some.Avg60 >= stallLine {
|
||||
line += " — HIGH"
|
||||
}
|
||||
out = append(out, line)
|
||||
}
|
||||
for _, d := range l.Devices {
|
||||
name := d.Device
|
||||
if d.Name != "" {
|
||||
name += " (" + d.Name + ")"
|
||||
}
|
||||
line := fmt.Sprintf("%s: busy %.1f %%, read %.1f MiB/s (%.1f IOPS, %.1f ms wait), write %.1f MiB/s (%.1f IOPS, %.1f ms wait), queue depth %.1f",
|
||||
name, d.BusyPct, d.ReadMiBs, d.ReadIOPS, d.ReadWaitMs, d.WriteMiBs, d.WriteIOPS, d.WriteWaitMs, d.QueueDepth)
|
||||
if len(d.Mounts) > 0 {
|
||||
line += ", holds " + strings.Join(d.Mounts, ", ")
|
||||
}
|
||||
if d.BusyPct >= busyLine {
|
||||
line += " — SATURATED"
|
||||
}
|
||||
out = append(out, line)
|
||||
}
|
||||
for _, fs := range l.Filesystems {
|
||||
if fs.Unanswered != "" {
|
||||
out = append(out, fmt.Sprintf("%s: not read (%s)", fs.Mount, fs.Unanswered))
|
||||
continue
|
||||
}
|
||||
if fs.UsedPct >= fullLine || fs.InodesUsed >= fullLine {
|
||||
out = append(out, fmt.Sprintf("%s: %.1f %% full (%.1f GiB of %.1f GiB, %.1f GiB free), inodes %.1f %% used — NEARLY FULL", fs.Mount, fs.UsedPct, fs.UsedGiB, fs.SizeGiB, fs.AvailGiB, fs.InodesUsed))
|
||||
}
|
||||
}
|
||||
for i, u := range l.Units {
|
||||
if i == 3 {
|
||||
break
|
||||
}
|
||||
name := u.Unit
|
||||
if u.Container != "" {
|
||||
name = "container " + u.Container
|
||||
}
|
||||
out = append(out, fmt.Sprintf("busiest unit: %s read %.2f MiB/s, wrote %.2f MiB/s", name, u.ReadMiBs, u.WriteMiBs))
|
||||
}
|
||||
for i, p := range l.Processes {
|
||||
if i == 3 {
|
||||
break
|
||||
}
|
||||
where := p.Unit
|
||||
if p.Container != "" {
|
||||
where = "container " + p.Container
|
||||
}
|
||||
out = append(out, fmt.Sprintf("busiest process: %s (pid %d, %s) read %.2f MiB/s, wrote %.2f MiB/s", p.Command, p.PID, orNone(where), p.ReadMiBs, p.WriteMiBs))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// unreadableNote says what a list of processes misses when the tool runner is not root.
|
||||
func unreadableNote(n int) string {
|
||||
return fmt.Sprintf("%d processes' I/O could not be read: they are another account's, and the tool runner is not root, "+
|
||||
"so a busy one may be missing from the processes; the units, read from their cgroups, miss none", n)
|
||||
}
|
||||
|
||||
func orNone(s string) string {
|
||||
if s == "" {
|
||||
return "no unit"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// DeviceInfo is one disk as it is, and what it has done since the machine started.
|
||||
type DeviceInfo struct {
|
||||
Device string `json:"device"`
|
||||
Name string `json:"name,omitempty"`
|
||||
On []string `json:"on,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
SizeGiB float64 `json:"size_gib"`
|
||||
Rotational bool `json:"rotational"`
|
||||
Scheduler string `json:"scheduler,omitempty"`
|
||||
QueueLimit int `json:"queue_limit,omitempty"`
|
||||
ReadGiB float64 `json:"read_since_boot_gib"`
|
||||
WrittenGiB float64 `json:"written_since_boot_gib"`
|
||||
BusySincePct float64 `json:"busy_since_boot_pct"`
|
||||
ReadWaitMs float64 `json:"read_wait_since_boot_ms"`
|
||||
WriteWaitMs float64 `json:"write_wait_since_boot_ms"`
|
||||
}
|
||||
|
||||
// DevicesNow is every disk with its kind, its size, its scheduler and its totals since boot: the
|
||||
// question "is this a spinning disk" and "how much has it written" without a window.
|
||||
func (m *Machine) DevicesNow(all bool) ([]DeviceInfo, float64) {
|
||||
stats := parseDiskstats(m.read("/proc/diskstats"))
|
||||
uptime := 0.0
|
||||
if f := strings.Fields(m.read("/proc/uptime")); len(f) > 0 {
|
||||
uptime, _ = strconv.ParseFloat(f[0], 64)
|
||||
}
|
||||
out := []DeviceInfo{}
|
||||
for _, name := range m.blockDevices(stats, all) {
|
||||
c := stats[name]
|
||||
base := "/sys/block/" + name
|
||||
if !m.exists(base) {
|
||||
base = "/sys/block/" + m.parentOf(name) + "/" + name
|
||||
}
|
||||
sectors, _ := strconv.ParseFloat(strings.TrimSpace(m.read(base+"/size")), 64)
|
||||
d := DeviceInfo{
|
||||
Device: name,
|
||||
Name: m.mapperName(name),
|
||||
On: m.below(name),
|
||||
Model: strings.TrimSpace(m.read(base + "/device/model")),
|
||||
SizeGiB: round1(sectors * 512 / gib),
|
||||
Rotational: strings.TrimSpace(m.read(base+"/queue/rotational")) == "1",
|
||||
Scheduler: activeScheduler(m.read(base + "/queue/scheduler")),
|
||||
ReadGiB: round1(float64(c.readSectors) * 512 / gib),
|
||||
WrittenGiB: round1(float64(c.writeSectors) * 512 / gib),
|
||||
}
|
||||
d.QueueLimit, _ = strconv.Atoi(strings.TrimSpace(m.read(base + "/queue/nr_requests")))
|
||||
if uptime > 0 {
|
||||
d.BusySincePct = round1(float64(c.busyMs) / (uptime * 1000) * 100)
|
||||
}
|
||||
if c.reads > 0 {
|
||||
d.ReadWaitMs = round1(float64(c.readMs) / float64(c.reads))
|
||||
}
|
||||
if c.writes > 0 {
|
||||
d.WriteWaitMs = round1(float64(c.writeMs) / float64(c.writes))
|
||||
}
|
||||
out = append(out, d)
|
||||
}
|
||||
return out, round1(uptime / 3600)
|
||||
}
|
||||
|
||||
// activeScheduler is the bracketed one of "mq-deadline kyber [bfq] none".
|
||||
func activeScheduler(s string) string {
|
||||
if i := strings.Index(s, "["); i >= 0 {
|
||||
if j := strings.Index(s[i:], "]"); j > 0 {
|
||||
return s[i+1 : i+j]
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const store = "4f1c2a9e8b7d6c5a4f1c2a9e8b7d6c5a4f1c2a9e8b7d6c5a4f1c2a9e8b7d6c5a"
|
||||
|
||||
// aServerUnderWrites is a machine whose store writes 100 MiB in a 2 s window to an encrypted root on an
|
||||
// NVMe disk: the shape of the control node in issue 312. Lines are as a 6.x kernel writes them, with
|
||||
// the four discard and two flush fields at the end of /proc/diskstats.
|
||||
func aServerUnderWrites(t *testing.T) *fake {
|
||||
f := newFake(t)
|
||||
f.file("/proc/stat", "cpu 1000 0 500 8000 100 0 0 0 0 0\ncpu0 500 0 250 4000 50 0 0 0 0 0\ncpu1 500 0 250 4000 50 0 0 0 0 0\nintr 1\n")
|
||||
f.then("/proc/stat", "cpu 1100 0 550 8250 500 0 0 0 0 0\ncpu0 550 0 275 4125 250 0 0 0 0 0\ncpu1 550 0 275 4125 250 0 0 0 0 0\nintr 2\n")
|
||||
|
||||
f.file("/proc/diskstats", strings.Join([]string{
|
||||
" 259 0 nvme0n1 1000 0 80000 500 2000 0 400000 4000 0 10000 20000 0 0 0 0 10 5",
|
||||
" 259 1 nvme0n1p1 10 0 100 5 0 0 0 0 0 5 5 0 0 0 0 0 0",
|
||||
" 259 2 nvme0n1p2 990 0 79900 495 2000 0 400000 4000 0 9995 19995 0 0 0 0 0 0",
|
||||
" 254 0 dm-0 990 0 79900 600 2000 0 400000 5000 0 10000 21000 0 0 0 0 0 0",
|
||||
" 8 0 sda 50 0 4000 900 0 0 0 0 0 800 900 0 0 0 0 0 0",
|
||||
" 7 0 loop0 5 0 10 1 0 0 0 0 0 1 1 0 0 0 0 0 0",
|
||||
" 252 0 zram0 100 0 800 1 900 0 7200 9 0 9 10 0 0 0 0 0 0",
|
||||
}, "\n")+"\n")
|
||||
f.then("/proc/diskstats", strings.Join([]string{
|
||||
" 259 0 nvme0n1 1200 0 120960 900 3000 0 604800 29000 12 11960 45400 0 0 0 0 12 6",
|
||||
" 259 1 nvme0n1p1 10 0 100 5 0 0 0 0 0 5 5 0 0 0 0 0 0",
|
||||
" 259 2 nvme0n1p2 1190 0 120860 895 3000 0 604800 29000 12 11955 45395 0 0 0 0 0 0",
|
||||
" 254 0 dm-0 1190 0 120860 1100 3000 0 604800 32000 14 11980 50000 0 0 0 0 0 0",
|
||||
" 8 0 sda 50 0 4000 900 0 0 0 0 0 800 900 0 0 0 0 0 0",
|
||||
" 7 0 loop0 5 0 10 1 0 0 0 0 0 1 1 0 0 0 0 0 0",
|
||||
" 252 0 zram0 900 0 7200 9 9000 0 72000 90 0 90 100 0 0 0 0 0 0",
|
||||
}, "\n")+"\n")
|
||||
|
||||
f.file("/proc/pressure/io", "some avg10=38.50 avg60=12.00 avg300=3.10 total=1000000\nfull avg10=25.00 avg60=8.00 avg300=2.00 total=500000\n")
|
||||
f.then("/proc/pressure/io", "some avg10=44.00 avg60=14.20 avg300=3.50 total=1900000\nfull avg10=29.00 avg60=9.10 avg300=2.20 total=1100000\n")
|
||||
|
||||
f.file("/sys/block/nvme0n1/size", "1000215216\n")
|
||||
f.file("/sys/block/nvme0n1/queue/rotational", "0\n")
|
||||
f.file("/sys/block/nvme0n1/queue/scheduler", "[none] mq-deadline kyber\n")
|
||||
f.file("/sys/block/nvme0n1/queue/nr_requests", "1023\n")
|
||||
f.file("/sys/block/nvme0n1/device/model", "Example NVMe 512GB \n")
|
||||
f.dir("/sys/block/nvme0n1/nvme0n1p1")
|
||||
f.dir("/sys/block/nvme0n1/nvme0n1p2")
|
||||
f.file("/sys/block/dm-0/dm/name", "root\n")
|
||||
f.dir("/sys/block/dm-0/slaves/nvme0n1p2")
|
||||
f.file("/sys/block/dm-0/size", "999000000\n")
|
||||
f.file("/sys/block/sda/size", "7814037168\n")
|
||||
f.file("/sys/block/sda/queue/rotational", "1\n")
|
||||
f.file("/sys/block/sda/queue/scheduler", "mq-deadline [bfq] none\n")
|
||||
f.dir("/sys/block/sda/sda1")
|
||||
f.dir("/sys/block/loop0")
|
||||
f.dir("/sys/block/zram0")
|
||||
f.file("/proc/uptime", "36000.00 70000.00\n")
|
||||
|
||||
f.file("/proc/self/mounts", strings.Join([]string{
|
||||
"proc /proc proc rw,nosuid 0 0",
|
||||
"/dev/mapper/root / ext4 rw,relatime 0 0",
|
||||
"tmpfs /tmp tmpfs rw 0 0",
|
||||
"/dev/nvme0n1p1 /boot vfat rw 0 0",
|
||||
"/dev/mapper/root /var/lib/docker ext4 rw,relatime 0 0",
|
||||
"overlay /var/lib/docker/overlay2/abc/merged overlay rw 0 0",
|
||||
`/dev/sda1 /mnt/back\040up ext4 rw 0 0`,
|
||||
}, "\n")+"\n")
|
||||
f.space["/"] = FSStat{Size: 100 * gib, Free: 10 * gib, Avail: 5 * gib, Files: 1000, FilesFree: 600}
|
||||
f.space["/boot"] = FSStat{Size: gib, Free: gib / 2, Avail: gib / 2, Files: 0}
|
||||
|
||||
proc := func(pid, comm, cgroup string, r0, w0, r1, w1 int) {
|
||||
f.file("/proc/"+pid+"/comm", comm+"\n")
|
||||
f.file("/proc/"+pid+"/cgroup", cgroup+"\n")
|
||||
f.file("/proc/"+pid+"/io", ioFile(r0, w0))
|
||||
f.then("/proc/"+pid+"/io", ioFile(r1, w1))
|
||||
}
|
||||
proc("100", "postgres", "0::/system.slice/docker-"+store+".scope", 0, 0, 0, 100*mib)
|
||||
proc("101", "postgres", "0::/system.slice/docker-"+store+".scope", 0, 0, 10*mib, 0)
|
||||
proc("200", "systemd-journal", "0::/system.slice/systemd-journald.service", 0, 0, 0, 2*mib)
|
||||
proc("201", "sleep", "0::/user.slice/user-1000.slice/session-1.scope", 0, 0, 0, 0)
|
||||
f.dir("/proc/300") // another account's: its io is not readable
|
||||
f.then("/proc/400/io", ioFile(0, 50*mib))
|
||||
cg := func(path, at0, at1 string) {
|
||||
f.file("/sys/fs/cgroup/"+path+"/io.stat", at0)
|
||||
f.then("/sys/fs/cgroup/"+path+"/io.stat", at1)
|
||||
}
|
||||
zero := "259:0 rbytes=0 wbytes=0 rios=0 wios=0 dbytes=0 dios=0\n254:0 rbytes=0 wbytes=0 rios=0 wios=0 dbytes=0 dios=0\n7:0 rbytes=0 wbytes=0 rios=0 wios=0 dbytes=0 dios=0\n"
|
||||
// The store's writes are charged to the encrypted volume and again to the disk under it; a loop
|
||||
// device's to the filesystem its file is on. Only the disk is counted.
|
||||
cg("system.slice/docker-"+store+".scope", zero,
|
||||
"259:0 rbytes="+itoa(10*mib)+" wbytes="+itoa(100*mib)+" rios=200 wios=1000 dbytes=0 dios=0\n"+
|
||||
"254:0 rbytes="+itoa(10*mib)+" wbytes="+itoa(100*mib)+" rios=200 wios=1000 dbytes=0 dios=0\n"+
|
||||
"7:0 rbytes=0 wbytes="+itoa(900*mib)+" rios=0 wios=9000 dbytes=0 dios=0\n")
|
||||
cg("system.slice/systemd-journald.service", zero, "259:0 rbytes=0 wbytes="+itoa(2*mib)+" rios=0 wios=20 dbytes=0 dios=0\n")
|
||||
cg("system.slice/idle.service", zero, zero)
|
||||
// io.stat counts a cgroup's descendants: the user's manager holds the application's bytes again.
|
||||
cg("user.slice/user-1000.slice/user@1000.service", zero, "259:0 rbytes=0 wbytes="+itoa(5*mib)+" rios=0 wios=50 dbytes=0 dios=0\n")
|
||||
cg("user.slice/user-1000.slice/user@1000.service/app.slice/editor.scope", zero, "259:0 rbytes=0 wbytes="+itoa(5*mib)+" rios=0 wios=50 dbytes=0 dios=0\n")
|
||||
f.answers["docker ps --no-trunc --format {{.ID}}\t{{.Names}}"] = store + "\tstore-check\n"
|
||||
return f
|
||||
}
|
||||
|
||||
func ioFile(read, write int) string {
|
||||
return "rchar: 99999999\nwchar: 99999999\nsyscr: 1\nsyscw: 1\nread_bytes: " + itoa(read) + "\nwrite_bytes: " + itoa(write) + "\ncancelled_write_bytes: 0\n"
|
||||
}
|
||||
|
||||
func itoa(n int) string {
|
||||
b, _ := json.Marshal(n)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func device(t *testing.T, l Load, name string) Device {
|
||||
t.Helper()
|
||||
for _, d := range l.Devices {
|
||||
if d.Device == name {
|
||||
return d
|
||||
}
|
||||
}
|
||||
t.Fatalf("no device %s in %+v", name, l.Devices)
|
||||
return Device{}
|
||||
}
|
||||
|
||||
func TestAWindowSaysIOWaitEachDisksLoadAndPressure(t *testing.T) {
|
||||
f := aServerUnderWrites(t)
|
||||
l, err := f.machine().LoadOver(2*time.Second, 5, false, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if f.waited != 2*time.Second {
|
||||
t.Errorf("waited %s, not the window", f.waited)
|
||||
}
|
||||
if l.CPU != (CPU{CPUs: 2, IOWaitPct: 50, UserPct: 12.5, SystemPct: 6.3, IdlePct: 31.3}) {
|
||||
t.Errorf("cpu %+v", l.CPU)
|
||||
}
|
||||
|
||||
n := device(t, l, "nvme0n1")
|
||||
want := Device{Device: "nvme0n1", Mounts: []string{"/boot (nvme0n1p1)"}, BusyPct: 98, ReadMiBs: 10, WriteMiBs: 50,
|
||||
ReadIOPS: 100, WriteIOPS: 500, ReadWaitMs: 2, WriteWaitMs: 25, QueueDepth: 12.7, InFlight: 12}
|
||||
if got, _ := json.Marshal(n); string(got) != string(must(json.Marshal(want))) {
|
||||
t.Errorf("nvme0n1\n got %s\nwant %s", got, must(json.Marshal(want)))
|
||||
}
|
||||
root := device(t, l, "dm-0")
|
||||
if root.Name != "root" || strings.Join(root.On, ",") != "nvme0n1p2" || strings.Join(root.Mounts, ",") != "/" {
|
||||
t.Errorf("the encrypted root is said by its name, what it is on and what it holds: %+v", root)
|
||||
}
|
||||
if sda := device(t, l, "sda"); sda.BusyPct != 0 || sda.ReadMiBs != 0 {
|
||||
t.Errorf("an idle disk: %+v", sda)
|
||||
}
|
||||
for _, d := range l.Devices {
|
||||
if d.Device == "loop0" || d.Device == "zram0" || d.Device == "nvme0n1p2" {
|
||||
t.Errorf("%s is not a disk and was said", d.Device)
|
||||
}
|
||||
}
|
||||
if l.Devices[0].Device != "dm-0" && l.Devices[0].Device != "nvme0n1" {
|
||||
t.Errorf("the busiest first: %s", l.Devices[0].Device)
|
||||
}
|
||||
|
||||
p, ok := l.Pressure.(PSI)
|
||||
if !ok || p.Some.WindowPct != 45 || p.Full == nil || p.Full.WindowPct != 30 || p.Some.Avg10 != 44 {
|
||||
t.Errorf("pressure %+v", l.Pressure)
|
||||
}
|
||||
|
||||
if len(l.Processes) != 3 {
|
||||
t.Fatalf("three processes moved bytes in the window, one started inside it: %+v", l.Processes)
|
||||
}
|
||||
top := l.Processes[0]
|
||||
if top.PID != 100 || top.Command != "postgres" || top.Container != "store-check" || top.WriteMiBs != 50 || top.WriteMiB != 100 {
|
||||
t.Errorf("the busiest process, named by its container: %+v", top)
|
||||
}
|
||||
if j := l.Processes[2]; j.Unit != "systemd-journald.service" || j.Container != "" || j.WriteMiBs != 1 {
|
||||
t.Errorf("a service's process, named by its unit: %+v", j)
|
||||
}
|
||||
if len(l.Units) != 3 || l.Units[0].WriteIOPS != 500 || l.Units[2].Unit != "systemd-journald.service" {
|
||||
t.Errorf("units %+v", l.Units)
|
||||
}
|
||||
if l.Unreadable != 1 || !strings.Contains(l.Note, "1 processes' I/O could not be read") {
|
||||
t.Errorf("an unreadable process is counted and said: %d %q", l.Unreadable, l.Note)
|
||||
}
|
||||
|
||||
said := strings.Join(l.Said, "\n")
|
||||
for _, s := range []string{
|
||||
"CPU: 50.0 % waiting on I/O", "HIGH: the processors sat idle waiting on storage",
|
||||
"I/O pressure: some task stalled 45.0 % of the window", "every task stalled 30.0 %",
|
||||
"nvme0n1: busy 98.0 %, read 10.0 MiB/s (100.0 IOPS, 2.0 ms wait), write 50.0 MiB/s (500.0 IOPS, 25.0 ms wait), queue depth 12.7, holds /boot (nvme0n1p1) — SATURATED",
|
||||
"dm-0 (root): busy 99.0 %", "holds / — SATURATED",
|
||||
"/: 94.7 % full (90.0 GiB of 100.0 GiB, 5.0 GiB free)", "NEARLY FULL",
|
||||
`/mnt/back up: not read (no such filesystem)`,
|
||||
"busiest process: postgres (pid 100, container store-check) read 0.00 MiB/s, wrote 50.00 MiB/s",
|
||||
"busiest unit: container store-check read 5.00 MiB/s, wrote 50.00 MiB/s",
|
||||
"busiest unit: editor.scope read 0.00 MiB/s, wrote 2.50 MiB/s",
|
||||
} {
|
||||
if !strings.Contains(said, s) {
|
||||
t.Errorf("said lacks %q:\n%s", s, said)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func must(b []byte, err error) []byte {
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func TestAllSaysEveryDeviceAndPartition(t *testing.T) {
|
||||
l, err := aServerUnderWrites(t).machine().LoadOver(2*time.Second, 0, true, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var names []string
|
||||
for _, d := range l.Devices {
|
||||
names = append(names, d.Device)
|
||||
}
|
||||
sort.Strings(names)
|
||||
if strings.Join(names, ",") != "dm-0,loop0,nvme0n1,nvme0n1p1,nvme0n1p2,sda,zram0" {
|
||||
t.Errorf("all: %v", names)
|
||||
}
|
||||
if l.Processes != nil || l.Filesystems != nil {
|
||||
t.Errorf("processes=0 names none and reads none: %+v %+v", l.Processes, l.Filesystems)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAKernelWithoutPressureStallSaysSo(t *testing.T) {
|
||||
f := aServerUnderWrites(t)
|
||||
os.Remove(f.root + "/proc/pressure/io")
|
||||
delete(f.later, "/proc/pressure/io")
|
||||
l, err := f.machine().LoadOver(time.Second, 0, false, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s, ok := l.Pressure.(string); !ok || !strings.HasPrefix(s, "not available") {
|
||||
t.Errorf("pressure %+v", l.Pressure)
|
||||
}
|
||||
}
|
||||
|
||||
func TestACounterThatWentBackwardsReadsAsNothingNotAsAnEnormousNumber(t *testing.T) {
|
||||
a := diskCounters{reads: 100, readSectors: 1000, busyMs: 5000}
|
||||
b := diskCounters{reads: 10, readSectors: 100, busyMs: 10}
|
||||
d := deviceOver("sdx", a, b, time.Second)
|
||||
if d.ReadIOPS != 0 || d.ReadMiBs != 0 || d.BusyPct != 0 {
|
||||
t.Errorf("a reset counter: %+v", d)
|
||||
}
|
||||
if c := cpuShare(cpuTimes{iowait: 10, idle: 10}, cpuTimes{iowait: 5, idle: 10}); c.IOWaitPct != 0 {
|
||||
t.Errorf("cpu over no time: %+v", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBusyIsHeldAtAHundred(t *testing.T) {
|
||||
d := deviceOver("sdx", diskCounters{}, diskCounters{busyMs: 1500}, time.Second)
|
||||
if d.BusyPct != 100 {
|
||||
t.Errorf("busy %v", d.BusyPct)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAStatWithoutACPULineIsAnError(t *testing.T) {
|
||||
if _, err := parseCPU("intr 1\n"); err == nil {
|
||||
t.Error("no cpu line was accepted")
|
||||
}
|
||||
if _, err := parseCPU("cpu 1 2 3\n"); err == nil {
|
||||
t.Error("a short cpu line was accepted")
|
||||
}
|
||||
f := newFake(t)
|
||||
if _, err := f.machine().LoadOver(time.Second, 0, false, false); err == nil {
|
||||
t.Error("a machine without /proc/stat answered")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevicesSayWhatEachDiskIsAndHasDoneSinceBoot(t *testing.T) {
|
||||
ds, up := aServerUnderWrites(t).machine().DevicesNow(false)
|
||||
if up != 10 {
|
||||
t.Errorf("up %v hours", up)
|
||||
}
|
||||
by := map[string]DeviceInfo{}
|
||||
for _, d := range ds {
|
||||
by[d.Device] = d
|
||||
}
|
||||
n := by["nvme0n1"]
|
||||
if n.Model != "Example NVMe 512GB" || n.Rotational || n.Scheduler != "none" || n.QueueLimit != 1023 || n.SizeGiB != 476.9 {
|
||||
t.Errorf("nvme0n1 %+v", n)
|
||||
}
|
||||
if n.WrittenGiB != 0.2 || n.ReadWaitMs != 0.5 || n.WriteWaitMs != 2 || n.BusySincePct != 0 {
|
||||
t.Errorf("nvme0n1 since boot %+v", n)
|
||||
}
|
||||
if s := by["sda"]; !s.Rotational || s.Scheduler != "bfq" || s.SizeGiB != 3726 || s.BusySincePct != 0 {
|
||||
t.Errorf("sda %+v", s)
|
||||
}
|
||||
if r := by["dm-0"]; r.Name != "root" || strings.Join(r.On, ",") != "nvme0n1p2" {
|
||||
t.Errorf("dm-0 %+v", r)
|
||||
}
|
||||
if _, ok := by["zram0"]; ok {
|
||||
t.Error("zram is memory, not a disk")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPartitionsSizeIsReadUnderItsDisk(t *testing.T) {
|
||||
f := aServerUnderWrites(t)
|
||||
f.file("/sys/block/nvme0n1/nvme0n1p1/size", "2097152\n")
|
||||
ds, _ := f.machine().DevicesNow(true)
|
||||
for _, d := range ds {
|
||||
if d.Device == "nvme0n1p1" && d.SizeGiB != 1 {
|
||||
t.Errorf("nvme0n1p1 %+v", d)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CommandTimeout bounds the one command this module runs (the container runtime's list of names).
|
||||
const CommandTimeout = 5 * time.Second
|
||||
|
||||
// StatfsTimeout bounds the look at one mount: a network filesystem whose server is gone blocks statfs
|
||||
// for as long as it likes, and must cost the answer one line, never the whole call.
|
||||
const StatfsTimeout = 2 * time.Second
|
||||
|
||||
// Runner runs one command and answers its standard output. It is injected so that the tools are tested
|
||||
// against recorded answers rather than this machine's daemons.
|
||||
type Runner func(ctx context.Context, name string, args ...string) (string, error)
|
||||
|
||||
// ExecRunner runs a command on the machine, bounded by CommandTimeout.
|
||||
func ExecRunner(ctx context.Context, name string, args ...string) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, CommandTimeout)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, name, args...)
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout, cmd.Stderr = &stdout, &stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
said := strings.TrimSpace(stderr.String())
|
||||
if said != "" {
|
||||
return "", fmt.Errorf("%s: %w: %s", name, err, said)
|
||||
}
|
||||
return "", fmt.Errorf("%s: %w", name, err)
|
||||
}
|
||||
return stdout.String(), nil
|
||||
}
|
||||
|
||||
// FSStat is what statfs says of one mounted filesystem, in bytes and inodes.
|
||||
type FSStat struct {
|
||||
Size, Free, Avail uint64
|
||||
Files, FilesFree uint64
|
||||
}
|
||||
|
||||
// Machine is what the module reads: a filesystem root (the real one, or a test's tree standing in for
|
||||
// /proc and /sys), a way to run a command, a way to wait out the sampling window, and statfs.
|
||||
type Machine struct {
|
||||
Root string
|
||||
Run Runner
|
||||
Wait func(time.Duration)
|
||||
Statfs func(path string) (FSStat, error)
|
||||
}
|
||||
|
||||
// Here is the machine this process runs on.
|
||||
func Here() *Machine {
|
||||
return &Machine{Root: "/", Run: ExecRunner, Wait: time.Sleep, Statfs: statfs}
|
||||
}
|
||||
|
||||
func statfs(path string) (FSStat, error) {
|
||||
type answer struct {
|
||||
s FSStat
|
||||
err error
|
||||
}
|
||||
done := make(chan answer, 1)
|
||||
go func() {
|
||||
var s syscall.Statfs_t
|
||||
if err := syscall.Statfs(path, &s); err != nil {
|
||||
done <- answer{err: err}
|
||||
return
|
||||
}
|
||||
bs := uint64(s.Bsize)
|
||||
done <- answer{s: FSStat{Size: s.Blocks * bs, Free: s.Bfree * bs, Avail: s.Bavail * bs, Files: s.Files, FilesFree: s.Ffree}}
|
||||
}()
|
||||
select {
|
||||
case a := <-done:
|
||||
return a.s, a.err
|
||||
case <-time.After(StatfsTimeout):
|
||||
return FSStat{}, fmt.Errorf("statfs did not answer within %s", StatfsTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Machine) path(p string) string { return filepath.Join(m.Root, p) }
|
||||
|
||||
// read is a file's content; "" when it cannot be read.
|
||||
func (m *Machine) read(p string) string {
|
||||
b, err := os.ReadFile(m.path(p))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func (m *Machine) exists(p string) bool {
|
||||
_, err := os.Stat(m.path(p))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// names lists the entries of a directory, sorted; none when it cannot be read.
|
||||
func (m *Machine) names(dir string) []string {
|
||||
entries, err := os.ReadDir(m.path(dir))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
out = append(out, e.Name())
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// round1 rounds to one decimal, for what a person reads.
|
||||
func round1(f float64) float64 {
|
||||
if f < 0 {
|
||||
return -float64(int64(-f*10+0.5)) / 10
|
||||
}
|
||||
return float64(int64(f*10+0.5)) / 10
|
||||
}
|
||||
|
||||
const mib = 1 << 20
|
||||
const gib = 1 << 30
|
||||
@@ -0,0 +1,20 @@
|
||||
// The disk-load module's Go bundle (novox/hq ADR 0188, ADR 0193, issue 312): a process the node's
|
||||
// runtime launches and speaks MCP over stdio to. It reads the machine's storage load from /proc and
|
||||
// /sys — iowait, pressure stall, each disk's busy share, throughput, IOPS, wait and queue depth, the
|
||||
// filesystems' space and the processes doing the I/O — and changes nothing.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// An empty name serves as the module the runtime names (MESH_SERVED_MODULE): disk-load.
|
||||
if err := stdio.Serve("", Tools(Here())); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// procIO is what /proc/<pid>/io says a process caused to be read from and written to storage: not
|
||||
// what it read from the page cache, which rchar and wchar count.
|
||||
type procIO struct {
|
||||
read, write uint64
|
||||
}
|
||||
|
||||
// processIO reads every process's storage counters. A process of another account is unreadable unless
|
||||
// this runtime runs as root; those are counted, never guessed.
|
||||
func (m *Machine) processIO() (map[int]procIO, int) {
|
||||
out := map[int]procIO{}
|
||||
unreadable := 0
|
||||
for _, name := range m.names("/proc") {
|
||||
pid, err := strconv.Atoi(name)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
text := m.read("/proc/" + name + "/io")
|
||||
if text == "" {
|
||||
if m.exists("/proc/" + name) {
|
||||
unreadable++
|
||||
}
|
||||
continue
|
||||
}
|
||||
var p procIO
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
k, v, ok := strings.Cut(line, ":")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
n, _ := strconv.ParseUint(strings.TrimSpace(v), 10, 64)
|
||||
switch k {
|
||||
case "read_bytes":
|
||||
p.read = n
|
||||
case "write_bytes":
|
||||
p.write = n
|
||||
}
|
||||
}
|
||||
out[pid] = p
|
||||
}
|
||||
return out, unreadable
|
||||
}
|
||||
|
||||
// Process is one process's storage I/O over the window, or a unit's or a container's (with its requests
|
||||
// per second, which io.stat counts and /proc/<pid>/io does not).
|
||||
type Process struct {
|
||||
PID int `json:"pid,omitempty"`
|
||||
Command string `json:"command,omitempty"`
|
||||
Unit string `json:"unit,omitempty"`
|
||||
Container string `json:"container,omitempty"`
|
||||
ReadMiBs float64 `json:"read_mib_s"`
|
||||
WriteMiBs float64 `json:"write_mib_s"`
|
||||
ReadMiB float64 `json:"read_mib"`
|
||||
WriteMiB float64 `json:"write_mib"`
|
||||
ReadIOPS float64 `json:"read_iops,omitempty"`
|
||||
WriteIOPS float64 `json:"write_iops,omitempty"`
|
||||
bytes float64
|
||||
}
|
||||
|
||||
var containerID = regexp.MustCompile(`(?:docker-|/docker/)([0-9a-f]{64})`)
|
||||
|
||||
// placeOf is the systemd unit and the container a process runs in, from its cgroup.
|
||||
func (m *Machine) placeOf(pid int) (unit, container string) {
|
||||
for _, line := range strings.Split(m.read("/proc/"+strconv.Itoa(pid)+"/cgroup"), "\n") {
|
||||
parts := strings.SplitN(line, ":", 3)
|
||||
if len(parts) != 3 || (parts[0] != "0" && !strings.Contains(parts[1], "name=systemd")) {
|
||||
continue
|
||||
}
|
||||
path := strings.TrimSpace(parts[2])
|
||||
if id := containerID.FindStringSubmatch(path); id != nil {
|
||||
container = id[1]
|
||||
}
|
||||
segs := strings.Split(path, "/")
|
||||
for i := len(segs) - 1; i >= 0; i-- {
|
||||
if strings.HasSuffix(segs[i], ".service") || strings.HasSuffix(segs[i], ".scope") {
|
||||
unit = segs[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if parts[0] == "0" {
|
||||
break
|
||||
}
|
||||
}
|
||||
return unit, container
|
||||
}
|
||||
|
||||
// containerNames is each running container's full id with its name, from the container runtime; none
|
||||
// when it cannot be asked, and then a container is said by its short id.
|
||||
func (m *Machine) containerNames() map[string]string {
|
||||
out := map[string]string{}
|
||||
if m.Run == nil {
|
||||
return out
|
||||
}
|
||||
text, err := m.Run(context.Background(), "docker", "ps", "--no-trunc", "--format", "{{.ID}}\t{{.Names}}")
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
id, name, ok := strings.Cut(strings.TrimSpace(line), "\t")
|
||||
if ok {
|
||||
out[id] = name
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// topProcesses is the processes that moved the most bytes to and from storage between the two
|
||||
// readings, the largest first, each with the unit and the container it runs in.
|
||||
func (m *Machine) topProcesses(a, b map[int]procIO, window time.Duration, limit int) []Process {
|
||||
var moved []Process
|
||||
for pid, end := range b {
|
||||
start, ok := a[pid]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
r, w := sub(end.read, start.read), sub(end.write, start.write)
|
||||
if r+w == 0 {
|
||||
continue
|
||||
}
|
||||
// Bytes until inMiB says them in MiB.
|
||||
moved = append(moved, Process{PID: pid, ReadMiB: r, WriteMiB: w, bytes: r + w})
|
||||
}
|
||||
sort.Slice(moved, func(i, j int) bool {
|
||||
if moved[i].bytes != moved[j].bytes {
|
||||
return moved[i].bytes > moved[j].bytes
|
||||
}
|
||||
return moved[i].PID < moved[j].PID
|
||||
})
|
||||
if len(moved) > limit {
|
||||
moved = moved[:limit]
|
||||
}
|
||||
var names map[string]string
|
||||
for i := range moved {
|
||||
p := &moved[i]
|
||||
p.Command = strings.TrimSpace(m.read("/proc/" + strconv.Itoa(p.PID) + "/comm"))
|
||||
var id string
|
||||
p.Unit, id = m.placeOf(p.PID)
|
||||
if id != "" {
|
||||
if names == nil {
|
||||
names = m.containerNames()
|
||||
}
|
||||
p.Container = id[:12]
|
||||
if n, ok := names[id]; ok {
|
||||
p.Container = n
|
||||
}
|
||||
}
|
||||
inMiB(p, window.Seconds())
|
||||
}
|
||||
if moved == nil {
|
||||
moved = []Process{}
|
||||
}
|
||||
return moved
|
||||
}
|
||||
|
||||
// inMiB turns a reading's bytes into MiB and MiB/s, to two decimals: a process writing a log moves
|
||||
// kilobytes, and one decimal of a MiB would say it moved nothing.
|
||||
func inMiB(p *Process, s float64) {
|
||||
r, w := p.ReadMiB, p.WriteMiB
|
||||
p.ReadMiBs, p.WriteMiBs = round2(r/mib/s), round2(w/mib/s)
|
||||
p.ReadMiB, p.WriteMiB = round2(r/mib), round2(w/mib)
|
||||
}
|
||||
|
||||
func round2(f float64) float64 { return float64(int64(f*100+0.5)) / 100 }
|
||||
@@ -0,0 +1,281 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// cpuTimes is the first line of /proc/stat, in clock ticks: what every CPU together spent its time on.
|
||||
type cpuTimes struct {
|
||||
user, nice, system, idle, iowait, irq, softirq, steal uint64
|
||||
cpus int
|
||||
}
|
||||
|
||||
// total leaves guest time out: the kernel already counts it in user and nice.
|
||||
func (c cpuTimes) total() uint64 {
|
||||
return c.user + c.nice + c.system + c.idle + c.iowait + c.irq + c.softirq + c.steal
|
||||
}
|
||||
|
||||
func parseCPU(stat string) (cpuTimes, error) {
|
||||
var c cpuTimes
|
||||
found := false
|
||||
for _, line := range strings.Split(stat, "\n") {
|
||||
f := strings.Fields(line)
|
||||
if len(f) == 0 || !strings.HasPrefix(f[0], "cpu") {
|
||||
continue
|
||||
}
|
||||
if f[0] != "cpu" {
|
||||
c.cpus++
|
||||
continue
|
||||
}
|
||||
if len(f) < 9 {
|
||||
return c, fmt.Errorf("/proc/stat: the cpu line has %d fields, not at least 9", len(f))
|
||||
}
|
||||
v := make([]uint64, 8)
|
||||
for i := range v {
|
||||
n, err := strconv.ParseUint(f[i+1], 10, 64)
|
||||
if err != nil {
|
||||
return c, fmt.Errorf("/proc/stat: %q is not a count", f[i+1])
|
||||
}
|
||||
v[i] = n
|
||||
}
|
||||
c.user, c.nice, c.system, c.idle, c.iowait, c.irq, c.softirq, c.steal = v[0], v[1], v[2], v[3], v[4], v[5], v[6], v[7]
|
||||
found = true
|
||||
}
|
||||
if !found {
|
||||
return c, fmt.Errorf("/proc/stat has no cpu line")
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// CPU is how all the processors together spent the window, in percent of their time.
|
||||
type CPU struct {
|
||||
CPUs int `json:"cpus"`
|
||||
IOWaitPct float64 `json:"iowait_pct"`
|
||||
UserPct float64 `json:"user_pct"`
|
||||
SystemPct float64 `json:"system_pct"`
|
||||
IdlePct float64 `json:"idle_pct"`
|
||||
StealPct float64 `json:"steal_pct"`
|
||||
}
|
||||
|
||||
func cpuShare(a, b cpuTimes) CPU {
|
||||
total := float64(b.total() - a.total())
|
||||
pct := func(x, y uint64) float64 {
|
||||
if total <= 0 || y < x {
|
||||
return 0
|
||||
}
|
||||
return round1(float64(y-x) / total * 100)
|
||||
}
|
||||
return CPU{
|
||||
CPUs: b.cpus,
|
||||
IOWaitPct: pct(a.iowait, b.iowait),
|
||||
UserPct: pct(a.user+a.nice, b.user+b.nice),
|
||||
SystemPct: pct(a.system+a.irq+a.softirq, b.system+b.irq+b.softirq),
|
||||
IdlePct: pct(a.idle, b.idle),
|
||||
StealPct: pct(a.steal, b.steal),
|
||||
}
|
||||
}
|
||||
|
||||
// diskCounters is one line of /proc/diskstats (Documentation/admin-guide/iostats.rst). Sectors are
|
||||
// always 512 bytes there, whatever the device's own sector size.
|
||||
type diskCounters struct {
|
||||
reads, readSectors, readMs uint64
|
||||
writes, writeSectors, writeMs uint64
|
||||
inFlight, busyMs, weightedMs uint64
|
||||
}
|
||||
|
||||
func parseDiskstats(s string) map[string]diskCounters {
|
||||
out := map[string]diskCounters{}
|
||||
for _, line := range strings.Split(s, "\n") {
|
||||
f := strings.Fields(line)
|
||||
if len(f) < 14 {
|
||||
continue
|
||||
}
|
||||
n := make([]uint64, 11)
|
||||
ok := true
|
||||
for i := range n {
|
||||
v, err := strconv.ParseUint(f[i+3], 10, 64)
|
||||
if err != nil {
|
||||
ok = false
|
||||
break
|
||||
}
|
||||
n[i] = v
|
||||
}
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
out[f[2]] = diskCounters{
|
||||
reads: n[0], readSectors: n[2], readMs: n[3],
|
||||
writes: n[4], writeSectors: n[6], writeMs: n[7],
|
||||
inFlight: n[8], busyMs: n[9], weightedMs: n[10],
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Device is one block device over the window.
|
||||
type Device struct {
|
||||
Device string `json:"device"`
|
||||
Name string `json:"name,omitempty"`
|
||||
On []string `json:"on,omitempty"`
|
||||
Mounts []string `json:"mounts,omitempty"`
|
||||
BusyPct float64 `json:"busy_pct"`
|
||||
ReadMiBs float64 `json:"read_mib_s"`
|
||||
WriteMiBs float64 `json:"write_mib_s"`
|
||||
ReadIOPS float64 `json:"read_iops"`
|
||||
WriteIOPS float64 `json:"write_iops"`
|
||||
ReadWaitMs float64 `json:"read_wait_ms"`
|
||||
WriteWaitMs float64 `json:"write_wait_ms"`
|
||||
QueueDepth float64 `json:"queue_depth"`
|
||||
InFlight uint64 `json:"in_flight"`
|
||||
}
|
||||
|
||||
func sub(b, a uint64) float64 {
|
||||
if b < a {
|
||||
return 0
|
||||
}
|
||||
return float64(b - a)
|
||||
}
|
||||
|
||||
// deviceOver is iostat's arithmetic: busy is the share of the window with any request in flight, wait
|
||||
// the time a completed request took from queueing to completion, queue depth the requests in flight
|
||||
// on average.
|
||||
func deviceOver(name string, a, b diskCounters, window time.Duration) Device {
|
||||
ms := float64(window.Milliseconds())
|
||||
s := window.Seconds()
|
||||
reads, writes := sub(b.reads, a.reads), sub(b.writes, a.writes)
|
||||
wait := func(spent, n float64) float64 {
|
||||
if n == 0 {
|
||||
return 0
|
||||
}
|
||||
return round1(spent / n)
|
||||
}
|
||||
busy := sub(b.busyMs, a.busyMs) / ms * 100
|
||||
if busy > 100 {
|
||||
busy = 100
|
||||
}
|
||||
return Device{
|
||||
Device: name,
|
||||
BusyPct: round1(busy),
|
||||
ReadMiBs: round1(sub(b.readSectors, a.readSectors) * 512 / mib / s),
|
||||
WriteMiBs: round1(sub(b.writeSectors, a.writeSectors) * 512 / mib / s),
|
||||
ReadIOPS: round1(reads / s),
|
||||
WriteIOPS: round1(writes / s),
|
||||
ReadWaitMs: wait(sub(b.readMs, a.readMs), reads),
|
||||
WriteWaitMs: wait(sub(b.writeMs, a.writeMs), writes),
|
||||
QueueDepth: round1(sub(b.weightedMs, a.weightedMs) / ms),
|
||||
InFlight: b.inFlight,
|
||||
}
|
||||
}
|
||||
|
||||
// blockDevices are the whole devices in /sys/block, without the ones that are not a disk: loop devices,
|
||||
// RAM disks and compressed swap in RAM (zram, the memory-pressure module's). all keeps every one, and
|
||||
// every partition /proc/diskstats names.
|
||||
func (m *Machine) blockDevices(stats map[string]diskCounters, all bool) []string {
|
||||
var out []string
|
||||
if all {
|
||||
for name := range stats {
|
||||
out = append(out, name)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
for _, name := range m.names("/sys/block") {
|
||||
if strings.HasPrefix(name, "loop") || strings.HasPrefix(name, "ram") || strings.HasPrefix(name, "zram") {
|
||||
continue
|
||||
}
|
||||
if _, ok := stats[name]; ok {
|
||||
out = append(out, name)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// mapperName is a device-mapper device's own name (dm-0 is "root"), "" for any other device.
|
||||
func (m *Machine) mapperName(dev string) string {
|
||||
return strings.TrimSpace(m.read("/sys/block/" + dev + "/dm/name"))
|
||||
}
|
||||
|
||||
// below is what a device-mapper or RAID device is built on.
|
||||
func (m *Machine) below(dev string) []string { return m.names("/sys/block/" + dev + "/slaves") }
|
||||
|
||||
// parentOf is the whole device a partition is on; the device itself when it is whole.
|
||||
func (m *Machine) parentOf(dev string) string {
|
||||
if m.exists("/sys/block/" + dev) {
|
||||
return dev
|
||||
}
|
||||
for _, disk := range m.names("/sys/block") {
|
||||
if m.exists("/sys/block/" + disk + "/" + dev) {
|
||||
return disk
|
||||
}
|
||||
}
|
||||
return dev
|
||||
}
|
||||
|
||||
// PSI is the kernel's pressure stall information for I/O: the share of time some (or all) runnable
|
||||
// tasks were stalled waiting on it.
|
||||
type PSI struct {
|
||||
Some PSILine `json:"some"`
|
||||
Full *PSILine `json:"full,omitempty"`
|
||||
}
|
||||
|
||||
// PSILine is one line, its running averages and its share of the sampled window.
|
||||
type PSILine struct {
|
||||
WindowPct float64 `json:"window_pct"`
|
||||
Avg10 float64 `json:"avg10_pct"`
|
||||
Avg60 float64 `json:"avg60_pct"`
|
||||
Avg300 float64 `json:"avg300_pct"`
|
||||
totalUs uint64
|
||||
}
|
||||
|
||||
func parsePSI(s string) (PSI, bool) {
|
||||
var p PSI
|
||||
some := false
|
||||
for _, line := range strings.Split(s, "\n") {
|
||||
f := strings.Fields(line)
|
||||
if len(f) < 5 {
|
||||
continue
|
||||
}
|
||||
var l PSILine
|
||||
for _, kv := range f[1:] {
|
||||
k, v, _ := strings.Cut(kv, "=")
|
||||
switch k {
|
||||
case "avg10":
|
||||
l.Avg10, _ = strconv.ParseFloat(v, 64)
|
||||
case "avg60":
|
||||
l.Avg60, _ = strconv.ParseFloat(v, 64)
|
||||
case "avg300":
|
||||
l.Avg300, _ = strconv.ParseFloat(v, 64)
|
||||
case "total":
|
||||
l.totalUs, _ = strconv.ParseUint(v, 10, 64)
|
||||
}
|
||||
}
|
||||
switch f[0] {
|
||||
case "some":
|
||||
p.Some, some = l, true
|
||||
case "full":
|
||||
full := l
|
||||
p.Full = &full
|
||||
}
|
||||
}
|
||||
return p, some
|
||||
}
|
||||
|
||||
func psiOver(a, b PSI, window time.Duration) PSI {
|
||||
share := func(x, y PSILine) PSILine {
|
||||
y.WindowPct = round1(sub(y.totalUs, x.totalUs) / float64(window.Microseconds()) * 100)
|
||||
if y.WindowPct > 100 {
|
||||
y.WindowPct = 100
|
||||
}
|
||||
return y
|
||||
}
|
||||
out := PSI{Some: share(a.Some, b.Some)}
|
||||
if a.Full != nil && b.Full != nil {
|
||||
full := share(*a.Full, *b.Full)
|
||||
out.Full = &full
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
func tool(t *testing.T, m *Machine, name string) stdio.Tool {
|
||||
t.Helper()
|
||||
for _, tl := range Tools(m) {
|
||||
if tl.Name == name {
|
||||
return tl
|
||||
}
|
||||
}
|
||||
t.Fatalf("no tool %s", name)
|
||||
return stdio.Tool{}
|
||||
}
|
||||
|
||||
func TestTheManifestNamesExactlyTheToolsTheBundleServesAndNoMachine(t *testing.T) {
|
||||
raw, err := os.ReadFile("../../module.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m struct {
|
||||
Tools []string `json:"tools"`
|
||||
Resources []any `json:"resources"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var served []string
|
||||
for _, tl := range Tools(newFake(t).machine()) {
|
||||
served = append(served, tl.Name)
|
||||
if !strings.HasSuffix(tl.Description, "(r)") {
|
||||
t.Errorf("%s only reads and does not say so", tl.Name)
|
||||
}
|
||||
}
|
||||
sort.Strings(served)
|
||||
sort.Strings(m.Tools)
|
||||
if strings.Join(served, ",") != strings.Join(m.Tools, ",") {
|
||||
t.Fatalf("served %v, listed %v", served, m.Tools)
|
||||
}
|
||||
if len(m.Resources) != 0 {
|
||||
t.Errorf("the module only reads, and declares %d resources", len(m.Resources))
|
||||
}
|
||||
for _, banned := range []string{"g14", "novox", "ace", "shanks", "jochen", "/home/"} {
|
||||
if strings.Contains(string(raw), banned) {
|
||||
t.Errorf("the manifest says %q", banned)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiskLoadSamplesTwoSecondsAndNamesFiveProcessesByDefault(t *testing.T) {
|
||||
f := aServerUnderWrites(t)
|
||||
out, err := tool(t, f.machine(), "disk_load").Run(map[string]any{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
l := out.(Load)
|
||||
if f.waited != 2*time.Second || l.WindowS != 2 || len(l.Processes) != 3 || len(l.Units) != 3 || len(l.Filesystems) != 3 {
|
||||
t.Errorf("waited %s, %+v", f.waited, l)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheWindowIsAWholeNumberOfSecondsHeldAtTen(t *testing.T) {
|
||||
f := aServerUnderWrites(t)
|
||||
load := tool(t, f.machine(), "disk_load")
|
||||
for _, bad := range []any{0.0, -1.0, 1.5, "soon", true} {
|
||||
if _, err := load.Run(map[string]any{"seconds": bad}); err == nil {
|
||||
t.Errorf("seconds %v was accepted", bad)
|
||||
}
|
||||
}
|
||||
if _, err := load.Run(map[string]any{"seconds": 30.0}); err != nil || f.waited != 10*time.Second {
|
||||
t.Errorf("30 s was not held at 10: waited %s, %v", f.waited, err)
|
||||
}
|
||||
if _, err := load.Run(map[string]any{"processes": -1.0}); err == nil {
|
||||
t.Error("processes -1 was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiskTopRanksUnitsFromTheirCgroupsCountingEachByteOnce(t *testing.T) {
|
||||
f := aServerUnderWrites(t)
|
||||
out, err := tool(t, f.machine(), "disk_top").Run(map[string]any{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out.(map[string]any)["by"] != "unit" || f.waited != 2*time.Second {
|
||||
t.Errorf("the default: %+v, waited %s", out, f.waited)
|
||||
}
|
||||
got := out.(map[string]any)["top"].([]Process)
|
||||
want := []Process{
|
||||
{Unit: "docker-" + store + ".scope", Container: "store-check", ReadMiBs: 5, WriteMiBs: 50, ReadMiB: 10, WriteMiB: 100, ReadIOPS: 100, WriteIOPS: 500},
|
||||
{Unit: "editor.scope", WriteMiBs: 2.5, WriteMiB: 5, WriteIOPS: 25},
|
||||
{Unit: "systemd-journald.service", WriteMiBs: 1, WriteMiB: 2, WriteIOPS: 10},
|
||||
}
|
||||
if g, w := must(json.Marshal(got)), must(json.Marshal(want)); string(g) != string(w) {
|
||||
t.Errorf("by unit\n got %s\nwant %s", g, w)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiskTopByContainerNamesOnlyContainers(t *testing.T) {
|
||||
out, err := tool(t, aServerUnderWrites(t).machine(), "disk_top").Run(map[string]any{"by": "container", "seconds": 1.0})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := out.(map[string]any)["top"].([]Process)
|
||||
if len(got) != 1 || got[0].Container != "store-check" || got[0].Unit != "" || got[0].WriteMiBs != 100 {
|
||||
t.Errorf("by container %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiskTopByProcessSaysWhatItCouldNotRead(t *testing.T) {
|
||||
f := aServerUnderWrites(t)
|
||||
top := tool(t, f.machine(), "disk_top")
|
||||
out, err := top.Run(map[string]any{"by": "process", "limit": 2.0})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := out.(map[string]any)["top"].([]Process)
|
||||
if len(got) != 2 || got[0].PID != 100 || got[1].PID != 101 || got[1].ReadMiBs != 5 || got[1].Command != "postgres" {
|
||||
t.Errorf("by process %+v", got)
|
||||
}
|
||||
if out.(map[string]any)["processes_unreadable"] != 1 {
|
||||
t.Errorf("unreadable not said: %+v", out)
|
||||
}
|
||||
if _, err := top.Run(map[string]any{"by": "disk"}); err == nil {
|
||||
t.Error("by=disk was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAContainerTheRuntimeCannotNameIsSaidByItsShortID(t *testing.T) {
|
||||
f := aServerUnderWrites(t)
|
||||
delete(f.answers, "docker ps --no-trunc --format {{.ID}}\t{{.Names}}")
|
||||
out, err := tool(t, f.machine(), "disk_top").Run(map[string]any{"by": "process"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := out.(map[string]any)["top"].([]Process); got[0].Container != store[:12] {
|
||||
t.Errorf("%+v", got[0])
|
||||
}
|
||||
if n := len(f.calls); n != 1 {
|
||||
t.Errorf("the runtime was asked %d times, not once", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilesystemsAreSaidOnceWithTheirOtherMounts(t *testing.T) {
|
||||
out, err := tool(t, aServerUnderWrites(t).machine(), "disk_filesystems").Run(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fss := out.(map[string]any)["filesystems"].([]Filesystem)
|
||||
var mounts []string
|
||||
for _, fs := range fss {
|
||||
mounts = append(mounts, fs.Mount)
|
||||
}
|
||||
if strings.Join(mounts, ",") != "/,/boot,/mnt/back up" {
|
||||
t.Fatalf("mounts %v: proc, tmpfs and overlay are not stored data, and a second mount is not a second filesystem", mounts)
|
||||
}
|
||||
root := fss[0]
|
||||
if strings.Join(root.AlsoAt, ",") != "/var/lib/docker" || root.Device != "dm-0" || root.Type != "ext4" ||
|
||||
root.SizeGiB != 100 || root.UsedGiB != 90 || root.AvailGiB != 5 || root.UsedPct != 94.7 || root.InodesUsed != 40 {
|
||||
t.Errorf("root %+v", root)
|
||||
}
|
||||
if fss[1].Device != "nvme0n1p1" || fss[1].UsedPct != 50 || fss[1].InodesUsed != 0 {
|
||||
t.Errorf("boot %+v", fss[1])
|
||||
}
|
||||
if fss[2].Unanswered == "" {
|
||||
t.Errorf("a filesystem statfs could not read says so: %+v", fss[2])
|
||||
}
|
||||
said := strings.Join(out.(map[string]any)["said"].([]string), "\n")
|
||||
if !strings.Contains(said, "/ (ext4, /dev/mapper/root): 94.7 % used, 5.0 GiB free of 100.0 GiB — NEARLY FULL") ||
|
||||
!strings.Contains(said, "/boot (vfat, /dev/nvme0n1p1): 50.0 % used, 0.5 GiB free of 1.0 GiB\n") {
|
||||
t.Errorf("said:\n%s", said)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheMountTablesEscapesAreUndone(t *testing.T) {
|
||||
for in, want := range map[string]string{`/a\040b`: "/a b", `/tab\011x`: "/tab\tx", `/plain`: "/plain", `/end\04`: `/end\04`, `/back\134slash`: `/back\slash`} {
|
||||
if got := unescape(in); got != want {
|
||||
t.Errorf("%q: %q, not %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiskDevicesAnswersWithoutWaiting(t *testing.T) {
|
||||
f := aServerUnderWrites(t)
|
||||
out, err := tool(t, f.machine(), "disk_devices").Run(map[string]any{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if f.waited != 0 || len(out.(map[string]any)["devices"].([]DeviceInfo)) != 3 {
|
||||
t.Errorf("waited %s, %+v", f.waited, out)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
module diskload
|
||||
|
||||
go 1.22
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.6
|
||||
@@ -0,0 +1,2 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.6 h1:9qzdYONYbJdWcu6sxQcq9v1LI0JxcfkiKYkMUzJSkVQ=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.6/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"module": "disk-load",
|
||||
"version": "1",
|
||||
"tools": [
|
||||
"disk_load",
|
||||
"disk_top",
|
||||
"disk_filesystems",
|
||||
"disk_devices"
|
||||
],
|
||||
"build": {
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "tools-go",
|
||||
"kind": "bundle",
|
||||
"language": "go",
|
||||
"system": "arch",
|
||||
"from": "cmd/disk-load",
|
||||
"binary": "disk-load",
|
||||
"loads": [
|
||||
"disk-load"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user