Files
mesh-catalog/modules/mounts/cmd/mounts/verbs.go
T
jochen 6f78899827 mounts: take an SMB source's username from its entry of sources, not a setting of its own
The live module check refused smb-users: a key naming a user is the
operator's value and may have no default, and a setting without one would
refuse to compose mounts on every node until set. The username now goes in
the source's URL (smb://<user>@<host>/<share>), percent-decoded; a
password there is refused unsaid, and a source without a user waits for
sources (hq ADR 0283, issue 386).
2026-10-10 21:30:31 +02:00

156 lines
7.0 KiB
Go

package main
// The seat node-mounts' verbs (novox/hq ADR 0263), served as `node-mounts.<verb>` so the runtime answers
// them on the seat's subject, and the module's health check.
import (
"context"
"fmt"
"path/filepath"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
)
// Seat is the node seat this module holds.
const Seat = "node-mounts"
func verb(name, description string, input map[string]any, run func(a map[string]any) (any, error)) stdio.Tool {
return stdio.Tool{Name: Seat + "." + name, Description: description, Input: input, Run: run}
}
var nameArg = map[string]any{"type": "string", "description": "the share's or source's name, as the module's settings give it"}
// share is one share or source the process last found, by name.
func (m *Machine) share(name string) (ShareStatus, error) {
st, err := readJSON[Status](m, StatusFile)
if err != nil {
return ShareStatus{}, fmt.Errorf("the module's process has left no account yet: %v", err)
}
for _, s := range st.Shares {
if s.Name == name {
return s, nil
}
}
return ShareStatus{}, fmt.Errorf("this machine is asked to mount nothing named %q", name)
}
// ownPair refuses a share whose units are not both the module's.
func (m *Machine) ownPair(s ShareStatus) error {
if s.MountUnit == "" || !IsOwnUnit(m, filepath.Join(UnitDir, s.MountUnit)) || !IsOwnUnit(m, filepath.Join(UnitDir, s.AutoUnit)) {
return fmt.Errorf("%s has no unit pair of the module's (%s): %s", s.Name, s.State, s.Why)
}
return nil
}
// Verbs are the seat's verbs and the health check, over one machine.
func Verbs(m *Machine) []stdio.Tool {
ctx := context.Background()
return []stdio.Tool{
verb("list", "Every mount point on this machine — its fstab line, its mount and automount units, whether it is "+
"mounted now — and every share and occasional source the module is asked to mount, with its state (armed, "+
"mounted, absent, unreachable, failed, waiting), since when and why; a waiting source names the setting or secret it waits for. Credentials are never answered. Replaces cat "+
"/etc/fstab, findmnt and systemctl list-units --type=mount. (r)",
map[string]any{"network": map[string]any{"type": "boolean", "description": "only network filesystems (default false)"}},
func(a map[string]any) (any, error) {
st, err := readJSON[Status](m, StatusFile)
out := map[string]any{"machine": m.List(flag(a, "network"), "")}
if err != nil {
out["shares"] = "the module's process has left no account yet: " + err.Error()
} else {
out["shares"], out["looked"], out["problems"] = st.Shares, st.Looked, st.Problems
}
return out, nil
}),
verb("test", "Whether one share's or source's server answers now (TCP 2049 for NFS, 445 for SMB; for a disk, "+
"whether it is plugged in), tried without touching its mount point. Replaces showmount -e and ping. (r)",
map[string]any{"name": nameArg},
func(a map[string]any) (any, error) {
s, err := m.share(text(a, "name"))
if err != nil {
return nil, err
}
if s.Probe == "" {
return map[string]any{"name": s.Name, "answers": false, "said": "nothing to try: " + s.Why}, nil
}
ap := NewApplier()
ap.M = m
p := Pair{Kind: s.Kind, Probe: s.Probe}
start := time.Now()
var tried error
if p.Kind == "disk" {
if !m.present(p.Probe) {
tried = fmt.Errorf("the disk %s is not plugged in", p.Probe)
}
} else {
tried = ap.Dial(p.Probe, ProbeTimeout)
}
out := map[string]any{"name": s.Name, "tried": s.Probe, "answers": tried == nil, "took_ms": time.Since(start).Milliseconds()}
if tried != nil {
out["said"] = tried.Error()
}
return out, nil
}),
verb("mount", "Mount one share or source now, instead of waiting for its first use: its mount unit is "+
"started. Replaces mount. (a)",
map[string]any{"name": nameArg},
func(a map[string]any) (any, error) {
s, err := m.share(text(a, "name"))
if err == nil {
err = m.ownPair(s)
}
if err != nil {
return nil, err
}
if _, err := m.Escalate(ctx, "systemctl", "start", s.MountUnit); err != nil {
return nil, err
}
return map[string]any{"name": s.Name, "said": fmt.Sprintf("%s is mounted at %s.", s.Name, s.Mountpoint)}, nil
}),
verb("unmount", "Release one share or source now: its mount unit is stopped, and its automount stays armed, "+
"so the next use mounts it again. Replaces umount. (a)",
map[string]any{"name": nameArg},
func(a map[string]any) (any, error) {
s, err := m.share(text(a, "name"))
if err == nil {
err = m.ownPair(s)
}
if err != nil {
return nil, err
}
// Never stop a mount whose filesystem is not its own source (review of 2026-10-08, finding 4c).
info, _ := m.read("/proc/self/mountinfo")
if err := m.notOwnSource(s.Kind, s.What, s.Mountpoint, ParseMountinfo(info)); err != nil {
return nil, fmt.Errorf("%s is not released: %v", s.Name, err)
}
if _, err := m.Escalate(ctx, "systemctl", "stop", s.MountUnit); err != nil {
return nil, err
}
return map[string]any{"name": s.Name, "said": fmt.Sprintf("%s is released; its automount waits for the next use.", s.Name)}, nil
}),
verb("adopt", "Take over one hand-written /etc/fstab line, on a person's word: refused unless this module's own "+
"unit pair for the same mount point is written and its automount armed. Then the line is commented out with "+
"a dated marker (any password in it redacted), the file as it was is kept beside it readable by root only, "+
"and the service manager reloads. Without confirm, a dry run that changes nothing. Replaces sed -i /etc/fstab. (a)",
map[string]any{
"mountpoint": map[string]any{"type": "string", "description": "the mount point of the line, as fstab names it"},
"confirm": map[string]any{"type": "boolean", "description": "true to change the file; otherwise a dry run"},
"why": map[string]any{"type": "string", "description": "why the line is taken over (required with confirm)"},
},
func(a map[string]any) (any, error) {
return m.Adopt(ctx, text(a, "mountpoint"), flag(a, "confirm"), text(a, "why"), time.Now())
}),
{
Name: "mounts_health",
Description: "Whether this machine's shares are healthy, as the module's health check (novox/hq ADR 0263, ADR 0283): " +
"unhealthy when a share that should always be there has been unreachable or failed for five minutes, an automount " +
"is not armed, something the settings name cannot be set up (a unit file that is not the module's), or the " +
"module's process has not looked for two minutes; with why, one sentence per finding. An occasional source that " +
"is absent is healthy, and said as not present since when. A source whose username (in its entry of the setting sources) or " +
"password (the secret smb-password-<source>) is not given waits for the operator: with nothing else found the " +
"answer is waits, naming each, never unhealthy. (r)",
Run: func(map[string]any) (any, error) { return m.Health(time.Now()), nil },
},
}
}