package main import ( "context" "fmt" "math" "strconv" "strings" stdio "git.novox.be/novox/mesh-sdk/go" ) // Tools are the module's tools over one machine. Every one but zfs_snapshot only reads; zfs_snapshot only // adds a snapshot. Nothing here destroys, rolls back, sets a property or sends: those change the // operator's data and need a decision of their own. func Tools(m *Machine) []stdio.Tool { ctx := context.Background() dataset := func(d string) map[string]any { return map[string]any{"type": "string", "description": d} } return []stdio.Tool{ { Name: "zfs_datasets", Description: "Every ZFS filesystem and volume on this machine: name, mountpoint, used, available and referenced " + "(bytes), compression ratio, sharenfs, how many snapshots it has and the space they hold together " + "(usedbysnapshots: what destroying all of them would free), each in a line under `said`. A machine " + "without ZFS answers applicable false, with why. Replaces zfs list. (r)", Run: func(map[string]any) (any, error) { return m.DatasetsNow(ctx) }, }, { Name: "zfs_snapshots", Description: "The snapshots of one dataset (exactly that one, not its children), or of every dataset when none is " + "named, oldest first: name, creation time, used (the space only that snapshot holds: what destroying it " + "alone would free) and referenced. Replaces zfs list -t snapshot. (r)", Input: map[string]any{"dataset": dataset("the dataset, as zfs_datasets names it (optional: every snapshot when left out)")}, Run: func(a map[string]any) (any, error) { return m.SnapshotsOf(ctx, text(a, "dataset")) }, }, { Name: "zfs_snapshot", Description: "Take a snapshot dataset@name of one dataset, a person's act with why: refused for a dataset that is not " + "on this machine, a name with anything but letters, digits, '.', '_' and '-', a snapshot that already exists, " + "or no why. Only that dataset, never its children. Runs zfs snapshot -- dataset@name through sudo -n, then " + "answers what zfs list shows: the snapshot made, its creation time, any other snapshot that came or went " + "meanwhile, the dataset's free space and the pool's capacity. From then on the snapshot holds every block " + "of the dataset deleted or overwritten, until it is destroyed by hand. A command that ran out of time is " + "answered from the list, or as outcome unknown. A name beginning mesh- is refused (the scheduled run's). Recorded as the event snapshot.taken.person; ZFS keeps it in the " + "pool's history too. It adds a snapshot and changes nothing else. Replaces zfs snapshot. (a)", Input: map[string]any{ "dataset": dataset("the dataset, as zfs_datasets names it"), "name": map[string]any{"type": "string", "description": "the snapshot's name, after the @: letters, digits, '.', '_' and '-'"}, "why": map[string]any{"type": "string", "description": "why, in one line; kept with the act"}, }, Run: func(a map[string]any) (any, error) { if _, given := a["recursive"]; given { return nil, fmt.Errorf("recursive does not exist in this version: zfs_snapshot snapshots one dataset, never its children") } ans, err := m.TakeSnapshot(ctx, text(a, "dataset"), text(a, "name"), text(a, "why"), stdio.CallerOf(a)) if err != nil { if _, absentHere := err.(errAbsent); absentHere { return NotApplicable{Applicable: false, Said: err.Error()}, nil } return nil, err } return ans, nil }, }, handTool(m, "zfs_snapshots_run_now", ActSnapshots, "Take the scheduled ZFS snapshots now, as the scheduled run does (a person's act with why): writes the act into the state `schedule` and starts zfs-mesh-run.service through the service manager; the run takes it under the same lock and guards. Does not wait: answers started at once and says zfs_module_events reads its outcome. (a)"), handTool(m, "zfs_snapshots_prune_now", ActPrune, "Remove the scheduled ZFS snapshots past their keep count now, as the scheduled run does, only those its own ledger holds (a person's act with why). Does not wait: answers started at once and says zfs_module_events reads its outcome. (a)"), handTool(m, "zfs_config_apply", ActApply, "Converge the node to its config file now, as the apply does (a person's act with why): the same lock and guards as the apply. Does not wait: answers started at once and says zfs_module_events reads its outcome. (a)"), { Name: "zfs_snapshots_pause", Description: "Pause the scheduled ZFS snapshots: writes `paused` {by, why, at} in the state `schedule`; the run then takes and removes nothing until zfs_snapshots_resume. (a)", Input: map[string]any{"why": map[string]any{"type": "string", "description": "why, in one line; kept with the act"}}, Run: func(a map[string]any) (any, error) { if err := m.Pause(stdio.CallerOf(a), text(a, "why")); err != nil { return nil, err } return map[string]any{"paused": true}, nil }, }, { Name: "zfs_snapshots_resume", Description: "Resume the scheduled ZFS snapshots: deletes `paused` from the state `schedule`. (a)", Run: func(map[string]any) (any, error) { if err := m.Resume(); err != nil { return nil, err } return map[string]any{"paused": false}, nil }, }, { Name: "zfs_snapshots_record", Description: "Record the scheduled run's `taken` or `removed` ZFS snapshot in the ledger `snapshots`: the run is a process, " + "which cannot write a ledger, so it calls this tool. Refused for a snapshot not named mesh--: the ledger " + "holds only the run's. (a)", Input: map[string]any{ "operation": map[string]any{"type": "string", "description": "taken or removed"}, "dataset": map[string]any{"type": "string", "description": "the dataset"}, "name": map[string]any{"type": "string", "description": "the snapshot's name after the @"}, "kind": map[string]any{"type": "string", "description": "hourly, daily, weekly or monthly"}, "at": map[string]any{"type": "string", "description": "when it was taken, RFC 3339 in UTC"}, }, Run: func(a map[string]any) (any, error) { e := LedgerEntry{Dataset: text(a, "dataset"), Name: text(a, "name"), Kind: text(a, "kind"), At: text(a, "at")} if err := m.Record(text(a, "operation"), e, stdio.CallIDOf(a)); err != nil { return nil, err } return map[string]any{"recorded": true}, nil }, }, readTool("zfs_config", "Every value the module owns with its config value, what runs on the node, and how it stands: not managed, as set, "+ "not applied yet, waiting (on what, since when), refused (why), failed, or taking effect at the next load of the kernel module; the "+ "package's files the module never writes (with a .pacnew beside them) and what the module says before it leaves a node: the next load of "+ "the kernel module uses ZFS's defaults, the three units stay, /etc/hostid stays. Changes nothing. (r)", nil, func(map[string]any) (any, error) { return m.ConfigNow(ctx) }), readTool("zfs_snapshots_schedule", "The schedule of the scheduled ZFS snapshots: the keep counts, when each kind next falls due, whether it is paused, "+ "the entries past their keep count the next run would remove, and the ledger `{\"ledger\": [...]}` of every scheduled ZFS snapshot the run took "+ "and did not remove. The scheduled run reads the ledger from here. Changes nothing. (r)", nil, func(map[string]any) (any, error) { return m.SnapshotSchedule(ctx) }), readTool("zfs_module_events", "The module's own events, newest first, from its events stream (config.applied, config.refused, config.waiting, "+ "snapshot.taken.person and .schedule, snapshot.removed.person and .schedule, snapshot.skipped.person and .schedule, run.skipped, scrub.requested), "+ "the last N of one kind or of all, with how many of each kind. Not ZFS's own events: zfs_events reads those. Changes nothing. (r)", map[string]any{"last": map[string]any{"type": "integer", "description": "how many to show (default 20, 1 to 500)"}, "kind": map[string]any{"type": "string", "description": "one kind of event (optional: every kind when left out)"}}, func(a map[string]any) (any, error) { n, err := whole(a, "last", 20, 1, 500) if err != nil { return nil, err } return m.ModuleEvents(n, text(a, "kind")) }), readTool("zfs_snapshots_check", "Compares the ledger with what ZFS holds: names each ledger entry whose ZFS snapshot is gone and each ZFS snapshot "+ "named mesh- that the ledger does not hold. Changes nothing. (r)", nil, func(map[string]any) (any, error) { return m.CheckSnapshots(ctx) }), readTool("zfs_findings", "The event follower's findings: one key for each pool or device that is not ONLINE, deleted when it is ONLINE again or "+ "leaves the pool. Changes nothing. (r)", nil, func(map[string]any) (any, error) { return m.FindingsNow() }), { Name: "zfs_pools", Description: "Every imported pool's health in a line: its state (ONLINE, DEGRADED, FAULTED, …) with status's own " + "explanation and action, size, allocated and free (bytes), capacity and fragmentation %, the error count of " + "its devices, its data errors and its last scan. Replaces zpool list and zpool status -x. (r)", Run: func(map[string]any) (any, error) { return m.PoolsNow(ctx) }, }, { Name: "zfs_space", Description: "Where the space goes: each pool's size, allocated, free, capacity % and fragmentation %; then every " + "dataset, biggest first, with what uses it (usedbydataset, usedbysnapshots, usedbychildren, " + "usedbyrefreservation), its quota, refquota, reservation and refreservation where set, the compression and " + "its ratio, the logical size against the physical and what compression saves. Replaces zfs list -o space " + "and zpool list -v. (r)", Run: func(map[string]any) (any, error) { return m.Space(ctx) }, }, { Name: "zfs_pool_layout", Description: "Each pool's layout as status gives it: its vdevs (mirror, raidz, draid) and their disks by the name the " + "pool knows them by (by-id where it was imported so), each with its state and its read, write and checksum " + "error counts and status's note (resilvering, was /dev/…); then the log, cache, special and dedup devices " + "and the spares. Replaces zpool status and zpool status -v. (r)", Run: func(map[string]any) (any, error) { return m.Layout(ctx) }, }, { Name: "zfs_scrub", Description: "Each pool's scans as status gives them: its scrub (or error scrub) and its resilver, the pool-wide one or " + "each vdev's rebuild; when each started and ended, how long it took, what it repaired and the errors it " + "found, or, while one runs, its progress and the time to go; and how many days since the last finished " + "scrub, with a WARNING past what the schedule allows. Then the module's scrub schedule: the pool, the " + "cadence, the day and time, its timer's state and the next run, and how the last run ended. With history " + "true, also each pool's history (zpool history -i, as root through sudo -n) for the last finished scrub and " + "resilver status no longer shows. Replaces zpool history -i. (r)", Input: map[string]any{"history": map[string]any{"type": "boolean", "description": "true to read each pool's history too (default false)"}}, Run: func(a map[string]any) (any, error) { h, err := flag(a, "history") if err != nil { return nil, err } return m.Scrubs(ctx, h) }, }, scrubAct("start", "Start a scrub of one pool, or resume a paused one: zpool scrub -- pool through sudo -n, a person's "+ "act with why. A scrub reads every block and rewrites from redundancy only a block it finds damaged; it adds "+ "I/O load for its whole length (hours to days on a large pool) and never changes what a file holds. "+ "Answers what status says afterwards; recorded as the event scrub.requested. Replaces zpool scrub. (a)", m), scrubAct("pause", "Pause one pool's running scrub, keeping its place: zpool scrub -p -- pool through sudo -n, with why; "+ "zfs_scrub_start resumes it. Answers what status says afterwards; recorded as the event scrub.requested. "+ "Replaces zpool scrub -p. (a)", m), scrubAct("stop", "Stop one pool's scrub, losing its place: zpool scrub -s -- pool through sudo -n, with why. Answers "+ "what status says afterwards; recorded as the event scrub.requested. Replaces zpool scrub -s. (a)", m), { Name: "zfs_io", Description: "A sample of every pool's I/O over a few seconds (zpool iostat -v, the report since boot left out): " + "read and write operations per second and bytes per second per pool, vdev and device, with each " + "vdev's allocated and free. Replaces zpool iostat and zpool iostat -v. (r)", Input: map[string]any{"seconds": map[string]any{"type": "integer", "description": "how long to sample, in seconds (default 3, 1 to 10)"}}, Run: func(a map[string]any) (any, error) { n, err := whole(a, "seconds", 3, 1, 10) if err != nil { return nil, err } return m.IO(ctx, n) }, }, { Name: "zfs_arc", Description: "The ARC, ZFS's cache in memory, since the machine started, from /proc/spl/kstat/zfs/arcstats: its " + "size, target, minimum and maximum (bytes), hits, misses and hit ratio %, the ratio for what was asked " + "for alone (not read ahead), and the L2ARC on a cache device where there is one. Replaces arcstat, " + "arc_summary and cat /proc/spl/kstat/zfs/arcstats. (r)", Run: func(map[string]any) (any, error) { return m.Arc() }, }, { Name: "zfs_events", Description: "ZFS's recent events, newest first, as root through sudo -n: by default only errors (I/O, checksum and " + "device errors) and changes of state (a device's state, a scrub or resilver started or finished, a pool " + "imported), each with its time, class, pool, device and state; all true gives every event. ZFS keeps " + "a bounded number since its module loaded. Replaces zpool events and zpool events -v. (r)", Input: map[string]any{ "all": map[string]any{"type": "boolean", "description": "true for every event, not only errors and changes of state (default false)"}, "limit": map[string]any{"type": "integer", "description": "how many to show (default 50, at most 500)"}, }, Run: func(a map[string]any) (any, error) { all, err := flag(a, "all") if err != nil { return nil, err } n, err := whole(a, "limit", 50, 1, 500) if err != nil { return nil, err } return m.Events(ctx, all, n) }, }, { Name: "zfs_properties", Description: "Every property of one dataset with where its value comes from (local, default, inherited from …): " + "compression, recordsize, atime, sync, xattr, sharenfs, sharesmb, mountpoint, readonly, encryption, " + "keystatus, keyformat and encryptionroot and the rest, the usual ones said first in lines. Never a key: " + "keylocation's value is withheld. Replaces zfs get all. (r)", Input: map[string]any{"dataset": dataset("the dataset, as zfs_datasets names it")}, Run: func(a map[string]any) (any, error) { return m.PropertiesOf(ctx, text(a, "dataset")) }, }, { Name: "zfs_health", Description: "Whether this machine's ZFS is healthy, as the module's health check (novox/hq ADR 0240). Unhealthy " + "for a fault now: a pool not ONLINE (with status's reason and action), a device not online, a device's " + "error counts that rose since the last look (unhealthy for 15 minutes after the rise), data errors, a " + "resilver that finished with errors, a pool more than 90 % full, no pool imported, or ZFS not on this " + "machine. Warnings leave it healthy: error counts from before, to clear by hand once understood; a pool " + "more than 80 % full; a resilver running; a last scrub later than its schedule allows (or 35 days without " + "one); a scheduled scrub that failed, or a schedule whose timer does not run. With why, one sentence per " + "finding. (r)", Run: func(map[string]any) (any, error) { return m.Health(ctx), nil }, }, } } // scrubAct is one of the scrub's acts as a tool. func scrubAct(act, description string, m *Machine) stdio.Tool { return stdio.Tool{ Name: "zfs_scrub_" + act, Description: description, Input: map[string]any{ "pool": map[string]any{"type": "string", "description": "the pool, as zfs_pools names it"}, "why": map[string]any{"type": "string", "description": "why, in one line; kept with the act"}, }, Run: func(a map[string]any) (any, error) { ans, err := m.ScrubAct(context.Background(), act, text(a, "pool"), text(a, "why")) if err != nil { if _, absentHere := err.(errAbsent); absentHere { return NotApplicable{Applicable: false, Said: err.Error()}, nil } return nil, err } return ans, nil }, } } func text(a map[string]any, key string) string { s, _ := a[key].(string) return strings.TrimSpace(s) } // flag is a yes-or-no argument: true only when given as true; anything that is not a yes or a no is // refused rather than read as no. func flag(a map[string]any, key string) (bool, error) { switch v := a[key].(type) { case nil: return false, nil case bool: return v, nil case string: switch strings.ToLower(strings.TrimSpace(v)) { case "true": return true, nil case "false", "": return false, nil } return false, fmt.Errorf("%s is true or false, not %q", key, v) } return false, fmt.Errorf("%s is true or false", key) } // whole is a whole-number argument, defaulted, refused outside least..most. func whole(a map[string]any, key string, fallback, least, most int) (int, error) { v, given := a[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 || n > most { return 0, fmt.Errorf("%s must be from %d to %d, not %d", key, least, most, n) } return n, nil } // handTool is one hand act as a tool. func handTool(m *Machine, name, act, description string) stdio.Tool { return stdio.Tool{ Name: name, Description: description, Input: map[string]any{"why": map[string]any{"type": "string", "description": "why, in one line; kept with the act"}}, Run: func(a map[string]any) (any, error) { return m.HandAct(context.Background(), act, stdio.CallerOf(a), text(a, "why")) }, } } // readTool is one of the module's own read tools. func readTool(name, description string, input map[string]any, run func(map[string]any) (any, error)) stdio.Tool { return stdio.Tool{Name: name, Description: description, Input: input, Run: run} }