package main // The seat node-mounts' verbs (novox/hq ADR 0263), served as `node-mounts.` 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-) 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 }, }, } }