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).
156 lines
7.0 KiB
Go
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 },
|
|
},
|
|
}
|
|
}
|