Let mounts write each share's mount and automount, so no node depends on a hand-written fstab line (hq ADR 0263)
A machine mounts what its assignment names: a node's share from the provision nfs-share, whose path it reads from nfs-server's state on the bus, and occasional sources (an SMB device, a disk) whose absence is a state, not a fault. apply writes and arms the unit pairs as root; watch tries each source and judges the health; the operator's fstab lines are taken over only through adopt, on their word, once the replacement is armed. SMB logins are an outside party's secret, given with secret accept.
This commit is contained in:
+112
-7
@@ -1,15 +1,111 @@
|
||||
# mounts
|
||||
|
||||
What a machine mounts and what it shares over the network, read as tools (novox/hq research 038, shares
|
||||
and mounts managed by the mesh).
|
||||
The shares and the occasional sources a machine mounts, and what it mounts and shares, read as tools
|
||||
(novox/hq ADR 0263, to-be design 52 "Shares and mounts", from research 038). It holds the node seat
|
||||
`node-mounts`, one holder per machine.
|
||||
|
||||
A machine mounts only what its assignment names (ADR 0263 rule 5): nothing by default, and "every
|
||||
machine" is the same setting given once at the mesh-wide layer. A share of another machine comes from the
|
||||
provision `nfs-share`, which `nfs-server` provides on the machine that shares.
|
||||
|
||||
## What it owns
|
||||
|
||||
Nothing yet. This is the read half of the shares-and-mounts design: it was built because no tool could
|
||||
read a machine's fstab, its mount units or its NFS exports, which two failing fstab mounts on the
|
||||
workstation needed. The to-be design in novox/hq makes this module write the mount and automount units
|
||||
for the shares a node requires; until that design is decided, it declares no resource and changes
|
||||
nothing.
|
||||
- The packages `nfs-utils` and `cifs-utils`.
|
||||
- `/etc/mesh-mounts/mounts.conf`, placed by the mesh from the assignment: the settings `shares` and
|
||||
`sources`, the binding of `nfs-share` (`from`, the sharing machine, and `at`, where it is reached on
|
||||
the private network), the operator account, and where the module's secret is.
|
||||
- One `.mount` and one `.automount` unit per share or source, in `/etc/systemd/system`, named as
|
||||
`systemd-escape --path` names the mount point and opened by the marker
|
||||
`# Written by the mesh (module mounts, novox/hq ADR 0263).` A unit file without that marker is not the
|
||||
module's: it is never written over, stopped or removed, and the share whose mount point it holds is
|
||||
said as not set up.
|
||||
- The SMB logins, one file per source in the module's state directory, readable by root only.
|
||||
- `/var/lib/mesh-mounts/from-bus` (the bundle's: what the sharing machine exports) and `/run/mesh-mounts`
|
||||
(the processes': what was applied and what was found).
|
||||
|
||||
What it never does: chown or chmod a mount point (it makes one that is missing, and nothing else), touch a
|
||||
mounted filesystem to look at it, or remove a pair because the sharing machine did not answer. A pair is
|
||||
removed only when the settings stop naming its mount point. The files a share serves are the operator's
|
||||
(ADR 0051): the module mounts them and owns nothing in them.
|
||||
|
||||
## The unit pair
|
||||
|
||||
Only the automount is enabled (under `remote-fs.target`; a disk's under `local-fs.target`). The mount
|
||||
is never enabled: it is made at the first use of its mount point and released after ten idle minutes
|
||||
(`TimeoutIdleSec=600`). So nothing mounts at boot and nothing can fail a boot, and a mount point is always
|
||||
an autofs mount, so nothing writes into the empty directory under it.
|
||||
|
||||
| kind | What | Options | TimeoutSec |
|
||||
|---|---|---|---|
|
||||
| a node's share, read-write | `<at>:<path>`, `Type=nfs4` | `vers=4.2,hard,timeo=150,retrans=3,_netdev` | 30 |
|
||||
| a node's share, read-only | `<at>:<path>`, `Type=nfs4` | `ro,vers=4.2,soft,timeo=150,retrans=3,_netdev` | 30 |
|
||||
| an SMB source | `//<host>/<share>`, `Type=cifs` | `credentials=<file>,iocharset=utf8,_netdev,nofail,uid=…,gid=…` | 10 |
|
||||
| a disk | `/dev/disk/by-uuid/<uuid>`; the mount `BindsTo=` the disk's device | `nofail` | 10 |
|
||||
|
||||
A share is read-only when the setting says `:ro` or the sharing machine exports it read-only. A
|
||||
read-write share is mounted hard, so a write is never dropped while the server is away.
|
||||
|
||||
## Settings
|
||||
|
||||
Both are preferences whose default, `none`, mounts nothing (ADR 0262 allows no empty default).
|
||||
|
||||
- `shares`: space-separated `name=mountpoint`, each optionally `:ro`. The name is the share's on the
|
||||
sharing machine; its path there comes from that machine over the bus, never from this setting.
|
||||
For example `library=/srv/library downloads=/srv/downloads:ro`.
|
||||
- `sources`: space-separated occasional sources, each optionally `:ro`:
|
||||
`name=smb://<host>/<share>@<mountpoint>` or `name=disk:<filesystem uuid>@<mountpoint>`.
|
||||
For example `games=smb://192.0.2.76/Share@/mnt/games`.
|
||||
|
||||
A mount point is an absolute, plainly written path, not a directory of the system's own (`/etc`, `/usr`,
|
||||
`/run`, …). An entry that cannot be read is said, and only that entry is left out.
|
||||
|
||||
## The secret
|
||||
|
||||
`smb-credentials` is the module's own secret, issued by an outside party (the device's owner), so the mesh
|
||||
never makes one in its place. The operator gives it with
|
||||
`secret accept <node> mounts smb-credentials --from <file>`; the file holds one section per SMB source:
|
||||
|
||||
```
|
||||
[games]
|
||||
username=…
|
||||
password=…
|
||||
domain=… (optional)
|
||||
```
|
||||
|
||||
A source whose section is missing is not mounted, and is said with that remedy. No tool answers a
|
||||
password: the options, fstab lines and mountinfo the tools read are redacted.
|
||||
|
||||
## The processes and the bus
|
||||
|
||||
- `apply` (root, judged by its unit) writes and removes the unit pairs and the logins, makes a missing
|
||||
mount point, reloads the service manager when anything changed and arms every automount, every 30 s.
|
||||
- `watch` (root, judged by `mounts_health`) tries each source without touching its mount point — TCP 2049
|
||||
for a node's share, 445 for an SMB source, the device for a disk — and says each one's state.
|
||||
- The bundle (the operator account) reads what the sharing machine exports from `nfs-server`'s state
|
||||
`exports`, keyed by that machine, and leaves it for `apply`; a failed read keeps the last answer. It puts
|
||||
what `watch` found in the module's state `shares`, keyed by this machine. No module reads another's
|
||||
files (ADR 0260).
|
||||
|
||||
The states: `armed` (the automount waits), `mounted`, `absent` (an occasional source that is not there —
|
||||
a state, not a fault), `unreachable` (should be there and is not), `failed` (tried and refused, or not
|
||||
set up; `why` says which).
|
||||
|
||||
## Health
|
||||
|
||||
Unhealthy when a share that should always be there has been unreachable or failed for five minutes ("The
|
||||
share library of <machine> is not reachable from this machine since 19:21."), when an automount is not
|
||||
armed, when something the settings name cannot be set up (a missing login, a unit file that is not the
|
||||
module's), or when `watch` has not looked for two minutes. An occasional source that is absent is
|
||||
healthy, and said as not present since when.
|
||||
|
||||
## Taking over a hand-written line
|
||||
|
||||
The operator's own fstab lines for a share are taken over only through `adopt`, on their word, and only
|
||||
once the module's own pair for the same mount point is written and its automount armed: the replacement
|
||||
works first, then the line goes. Without `confirm` it is a dry run. With it (and `why`), the line is
|
||||
commented out as `# adopted by the mesh (module mounts) on <date>: <the line, any password redacted>`,
|
||||
the file as it was is kept as `/etc/fstab.mesh-adopted-<date-time>` readable by root only, and the
|
||||
service manager reloads. The module never takes a line over on its own.
|
||||
|
||||
## What it reads
|
||||
|
||||
@@ -31,7 +127,16 @@ as empty.
|
||||
|
||||
## Tools
|
||||
|
||||
What only root may do (`mount`, `unmount`, `adopt`) goes through `sudo -n`, as the packet filter's and the
|
||||
service manager's tools do; a refusal by sudo is said as one.
|
||||
|
||||
| tool | | answers |
|
||||
|---|---|---|
|
||||
| `mounts_list` | r | every mount point, its fstab line, its mount and automount units and whether it is mounted now; network filesystems marked; a network mount without automount or nofail said. Narrowed with `network` and `match` |
|
||||
| `mounts_exports` | r | the NFS exports declared and live, the ZFS share properties, the Samba shares, and whether nfs-server and smb are active; an export declared and not live, or live and declared nowhere, said |
|
||||
| `mounts_health` | r | the health check: healthy or not, with why, one sentence per finding |
|
||||
| `node-mounts.list` | r | the machine's mount points, and every share and source the module is asked to mount with its state, since when and why |
|
||||
| `node-mounts.test` | r | whether one share's or source's server answers now, tried without touching its mount point |
|
||||
| `node-mounts.mount` | a | mount one share or source now |
|
||||
| `node-mounts.unmount` | a | release one now; its automount stays armed |
|
||||
| `node-mounts.adopt` | a | take over one hand-written fstab line, as above |
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
package main
|
||||
|
||||
// Taking over a hand-written fstab line (novox/hq ADR 0263 rule 10, to-be 52 §5).
|
||||
//
|
||||
// The line is the operator's, so it is taken over only on a person's word (`confirm`, with why) and only
|
||||
// once the module's own unit pair for the same mount point exists and its automount is armed: the
|
||||
// replacement works first, then the line goes. It is commented out, not deleted, with a dated marker; a
|
||||
// password in it is redacted in the commented line, and the whole file as it was is kept beside it,
|
||||
// readable by root only, because it holds that password.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AdoptedPrefix opens a line the module took over.
|
||||
const AdoptedPrefix = "# adopted by the mesh (module mounts) on "
|
||||
|
||||
var passwordValue = regexp.MustCompile(`(?i)\b(password2?|pass|passwd)=[^,\s]*`)
|
||||
|
||||
// RedactLine replaces every credential's value in a raw fstab line.
|
||||
func RedactLine(line string) string {
|
||||
return passwordValue.ReplaceAllString(line, "${1}="+Redacted)
|
||||
}
|
||||
|
||||
// Adoption is what taking a line over does, or would do.
|
||||
type Adoption struct {
|
||||
Mountpoint string `json:"mountpoint"`
|
||||
Line int `json:"line"`
|
||||
Was string `json:"was"`
|
||||
Becomes string `json:"becomes"`
|
||||
Copy string `json:"copy,omitempty"`
|
||||
Done bool `json:"done"`
|
||||
Said string `json:"said"`
|
||||
}
|
||||
|
||||
// PlanAdoption finds the one uncommented fstab line for a mount point and what it becomes. `was` is
|
||||
// redacted; the new text keeps every other line byte for byte.
|
||||
func PlanAdoption(fstab, mountpoint string, now time.Time) (string, Adoption, error) {
|
||||
lines := strings.Split(fstab, "\n")
|
||||
hit := -1
|
||||
for i, raw := range lines {
|
||||
t := strings.TrimSpace(raw)
|
||||
if t == "" || strings.HasPrefix(t, "#") {
|
||||
continue
|
||||
}
|
||||
f := strings.Fields(t)
|
||||
if len(f) >= 2 && unescape(f[1]) == mountpoint {
|
||||
if hit >= 0 {
|
||||
return "", Adoption{}, fmt.Errorf("/etc/fstab has more than one line for %s (lines %d and %d); take it over by hand", mountpoint, hit+1, i+1)
|
||||
}
|
||||
hit = i
|
||||
}
|
||||
}
|
||||
if hit < 0 {
|
||||
return "", Adoption{}, fmt.Errorf("/etc/fstab has no line for %s", mountpoint)
|
||||
}
|
||||
becomes := AdoptedPrefix + now.Format("2006-01-02") + ": " + RedactLine(lines[hit])
|
||||
a := Adoption{Mountpoint: mountpoint, Line: hit + 1, Was: RedactLine(lines[hit]), Becomes: becomes}
|
||||
lines[hit] = becomes
|
||||
return strings.Join(lines, "\n"), a, nil
|
||||
}
|
||||
|
||||
// replacementArmed says whether the module's own pair for a mount point is written and its automount
|
||||
// armed, with why not.
|
||||
func (m *Machine) replacementArmed(ctx context.Context, mountpoint string) error {
|
||||
base := EscapePath(mountpoint)
|
||||
for _, unit := range []string{base + ".mount", base + ".automount"} {
|
||||
if !IsOwnUnit(m, filepath.Join(UnitDir, unit)) {
|
||||
return fmt.Errorf("the module has no unit pair for %s yet (%s is not its own): it takes a line over only once its replacement is in place", mountpoint, unit)
|
||||
}
|
||||
}
|
||||
shown, err := m.Run(ctx, "systemctl", "show", "-p", "ActiveState", "--value", base+".automount")
|
||||
if err != nil {
|
||||
return fmt.Errorf("the service manager did not say whether %s.automount is armed: %v", base, err)
|
||||
}
|
||||
if strings.TrimSpace(shown) != "active" {
|
||||
return fmt.Errorf("%s.automount is not armed (%s): the replacement does not work yet, so the line stays", base, strings.TrimSpace(shown))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AdoptDryRun is what taking the line over would do, changing nothing.
|
||||
func (m *Machine) AdoptDryRun(ctx context.Context, mountpoint string, now time.Time) (Adoption, error) {
|
||||
if err := CheckMountpoint(mountpoint); err != nil {
|
||||
return Adoption{}, err
|
||||
}
|
||||
fstab, err := m.read("/etc/fstab")
|
||||
if err != nil {
|
||||
return Adoption{}, errors.New(unreadable("/etc/fstab", err))
|
||||
}
|
||||
_, a, err := PlanAdoption(fstab, mountpoint, now)
|
||||
if err != nil {
|
||||
return Adoption{}, err
|
||||
}
|
||||
if err := m.replacementArmed(ctx, mountpoint); err != nil {
|
||||
a.Said = "Would be refused now: " + err.Error()
|
||||
return a, nil
|
||||
}
|
||||
a.Said = fmt.Sprintf("A dry run: line %d of /etc/fstab would be commented out and a copy of the file kept beside it. Nothing changed; call again with confirm and why.", a.Line)
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// AdoptAsRoot takes the line over: the process's half, run as root through sudo by the tool. It checks
|
||||
// everything again, since the tool's look was the operator account's.
|
||||
func (m *Machine) AdoptAsRoot(ctx context.Context, mountpoint string, now time.Time) (Adoption, error) {
|
||||
if err := CheckMountpoint(mountpoint); err != nil {
|
||||
return Adoption{}, err
|
||||
}
|
||||
if err := m.replacementArmed(ctx, mountpoint); err != nil {
|
||||
return Adoption{}, err
|
||||
}
|
||||
full := filepath.Join(m.Root, "etc/fstab")
|
||||
raw, err := os.ReadFile(full)
|
||||
if err != nil {
|
||||
return Adoption{}, errors.New(unreadable("/etc/fstab", err))
|
||||
}
|
||||
info, err := os.Stat(full)
|
||||
if err != nil {
|
||||
return Adoption{}, err
|
||||
}
|
||||
next, a, err := PlanAdoption(string(raw), mountpoint, now)
|
||||
if err != nil {
|
||||
return Adoption{}, err
|
||||
}
|
||||
a.Copy = "/etc/fstab.mesh-adopted-" + now.Format("20060102-150405")
|
||||
if err := os.WriteFile(filepath.Join(m.Root, a.Copy), raw, 0o600); err != nil {
|
||||
return Adoption{}, fmt.Errorf("the copy of /etc/fstab was not kept, so nothing changed: %w", err)
|
||||
}
|
||||
tmp := full + ".mesh-new"
|
||||
if err := os.WriteFile(tmp, []byte(next), info.Mode().Perm()); err != nil {
|
||||
return Adoption{}, fmt.Errorf("/etc/fstab was not written, so nothing changed: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmp, full); err != nil {
|
||||
os.Remove(tmp)
|
||||
return Adoption{}, fmt.Errorf("/etc/fstab was not written, so nothing changed: %w", err)
|
||||
}
|
||||
a.Done = true
|
||||
a.Said = fmt.Sprintf("Line %d of /etc/fstab, for %s, is commented out; the file as it was is kept at %s (root only, as it may hold a password).", a.Line, mountpoint, a.Copy)
|
||||
if _, err := m.Run(ctx, "systemctl", "daemon-reload"); err != nil {
|
||||
a.Said += " The service manager did not reload: " + err.Error()
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// Adopt is the verb: a dry run without confirm; with it, the root half through sudo.
|
||||
func (m *Machine) Adopt(ctx context.Context, mountpoint string, confirm bool, why string, now time.Time) (Adoption, error) {
|
||||
if !confirm {
|
||||
return m.AdoptDryRun(ctx, mountpoint, now)
|
||||
}
|
||||
if strings.TrimSpace(why) == "" {
|
||||
return Adoption{}, errors.New("say why the line is taken over (`why`): it is the operator's line, and the act is recorded")
|
||||
}
|
||||
self, err := os.Executable()
|
||||
if err != nil {
|
||||
return Adoption{}, err
|
||||
}
|
||||
out, err := m.Escalate(ctx, self, "adopt", mountpoint)
|
||||
var a Adoption
|
||||
if jerr := json.Unmarshal([]byte(strings.TrimSpace(out)), &a); jerr != nil {
|
||||
if err != nil {
|
||||
return Adoption{}, err
|
||||
}
|
||||
return Adoption{}, fmt.Errorf("the root half answered no adoption: %v", jerr)
|
||||
}
|
||||
if !a.Done && a.Said != "" {
|
||||
return a, errors.New(a.Said)
|
||||
}
|
||||
a.Said += " Why: " + strings.TrimSpace(why) + "."
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// RunAdopt is `mounts adopt <mountpoint>`, as root: the answer on stdout as JSON, also when refused.
|
||||
func RunAdopt(args []string) int {
|
||||
if len(args) != 1 {
|
||||
fmt.Fprintln(os.Stderr, "usage: mounts adopt <mountpoint>")
|
||||
return 2
|
||||
}
|
||||
a, err := Here().AdoptAsRoot(context.Background(), args[0], time.Now())
|
||||
if err != nil {
|
||||
a = Adoption{Mountpoint: args[0], Said: err.Error()}
|
||||
}
|
||||
raw, _ := json.Marshal(a)
|
||||
fmt.Println(string(raw))
|
||||
if err != nil {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,520 @@
|
||||
package main
|
||||
|
||||
// The module's two long-running processes, run by the node-engine as root (novox/hq ADR 0263, to-be 52 §2),
|
||||
// over one pass of the same code every 30 seconds:
|
||||
//
|
||||
// - `mounts apply` acts: it writes or removes the module's own unit pairs and the SMB logins, makes a
|
||||
// missing mount point and arms the automounts, and leaves what it could not do in AppliedFile. The
|
||||
// service manager judges it by its unit;
|
||||
// - `mounts watch` judges: it tries each source without touching a mount point and leaves what it found
|
||||
// in StatusFile, which the health check `mounts_health`, the tools and the bundle read.
|
||||
//
|
||||
// Neither reaches the bus: the bundle reads what the sharing machine exports and leaves it in ExportsFile,
|
||||
// and puts StatusFile in the module's state.
|
||||
//
|
||||
// What it never does: write, stop or remove a unit file without the module's marker; chown or chmod a
|
||||
// mount point (it makes one that is missing, and nothing else); remove a pair because the sharing machine
|
||||
// did not answer — a pair goes only when the settings stop naming it.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// LookEvery is how often the process looks.
|
||||
const LookEvery = 30 * time.Second
|
||||
|
||||
// ProbeTimeout bounds one try of a source.
|
||||
const ProbeTimeout = 2 * time.Second
|
||||
|
||||
// The states of a share (to-be 52 §2).
|
||||
const (
|
||||
StateArmed = "armed" // the automount waits for the first use
|
||||
StateMounted = "mounted" // mounted now
|
||||
StateAbsent = "absent" // an occasional source that is not there: a state, not a fault
|
||||
StateUnreachable = "unreachable" // should be there and is not
|
||||
StateFailed = "failed" // tried and refused, or not set up; why says which
|
||||
)
|
||||
|
||||
// ExportsAnswer is what the bundle left from the sharing machine's `node-nfs-server.exports`.
|
||||
type ExportsAnswer struct {
|
||||
Server string `json:"server"`
|
||||
At string `json:"at,omitempty"`
|
||||
Asked string `json:"asked"`
|
||||
Shares []ServerShare `json:"shares"`
|
||||
// Error is why the last ask got no answer; the shares are then the last answer's.
|
||||
Error string `json:"error,omitempty"`
|
||||
Answered string `json:"answered,omitempty"`
|
||||
}
|
||||
|
||||
// ShareStatus is one share or source as the process found it.
|
||||
type ShareStatus struct {
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
Occasional bool `json:"occasional"`
|
||||
Mountpoint string `json:"mountpoint"`
|
||||
What string `json:"what,omitempty"`
|
||||
Probe string `json:"probe,omitempty"`
|
||||
State string `json:"state"`
|
||||
Since string `json:"since"`
|
||||
Why string `json:"why,omitempty"`
|
||||
MountUnit string `json:"mount_unit,omitempty"`
|
||||
AutoUnit string `json:"automount_unit,omitempty"`
|
||||
}
|
||||
|
||||
// Status is what one look found.
|
||||
type Status struct {
|
||||
Looked string `json:"looked"`
|
||||
Server string `json:"server,omitempty"`
|
||||
Shares []ShareStatus `json:"shares"`
|
||||
Problems []string `json:"problems,omitempty"`
|
||||
Changed []string `json:"changed,omitempty"`
|
||||
}
|
||||
|
||||
// Applier is the process, over one machine. Act writes and arms; Judge tries and says. A test does both.
|
||||
type Applier struct {
|
||||
M *Machine
|
||||
Act bool
|
||||
Judge bool
|
||||
// Dial tries a host:port; Now is the clock; Lookup finds an account's uid and gid.
|
||||
Dial func(addr string, timeout time.Duration) error
|
||||
Now func() time.Time
|
||||
Lookup func(account string) (int, int, error)
|
||||
}
|
||||
|
||||
// NewApplier is the process on this machine.
|
||||
func NewApplier() *Applier {
|
||||
return &Applier{
|
||||
M: Here(),
|
||||
Dial: func(addr string, timeout time.Duration) error {
|
||||
c, err := net.DialTimeout("tcp", addr, timeout)
|
||||
if err == nil {
|
||||
c.Close()
|
||||
}
|
||||
return err
|
||||
},
|
||||
Now: time.Now,
|
||||
Lookup: func(account string) (int, int, error) {
|
||||
u, err := user.Lookup(account)
|
||||
if err != nil {
|
||||
return -1, -1, err
|
||||
}
|
||||
uid, _ := strconv.Atoi(u.Uid)
|
||||
gid, _ := strconv.Atoi(u.Gid)
|
||||
return uid, gid, nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Applier) path(p string) string { return filepath.Join(a.M.Root, p) }
|
||||
|
||||
// writeIfChanged writes a file through a temporary beside it, only when its content differs.
|
||||
func (a *Applier) writeIfChanged(p, content string, mode os.FileMode) (bool, error) {
|
||||
full := a.path(p)
|
||||
if old, err := os.ReadFile(full); err == nil && string(old) == content {
|
||||
if info, err := os.Stat(full); err == nil && info.Mode().Perm() == mode {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
||||
return false, err
|
||||
}
|
||||
tmp := full + ".mesh-new"
|
||||
if err := os.WriteFile(tmp, []byte(content), mode); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := os.Chmod(tmp, mode); err != nil {
|
||||
os.Remove(tmp)
|
||||
return false, err
|
||||
}
|
||||
return true, os.Rename(tmp, full)
|
||||
}
|
||||
|
||||
// ownUnits are the unit files in UnitDir that carry the module's marker, by name.
|
||||
func (a *Applier) ownUnits() map[string]bool {
|
||||
out := map[string]bool{}
|
||||
for _, pattern := range []string{"*.mount", "*.automount"} {
|
||||
for _, p := range a.M.glob(filepath.Join(UnitDir, pattern)) {
|
||||
if IsOwnUnit(a.M, p) {
|
||||
out[filepath.Base(p)] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// IsOwnUnit says whether a unit file opens with the module's marker.
|
||||
func IsOwnUnit(m *Machine, p string) bool {
|
||||
text, err := m.read(p)
|
||||
return err == nil && strings.HasPrefix(text, Marker)
|
||||
}
|
||||
|
||||
// unitStates are the service manager's states for some units: ActiveState, SubState, Result and
|
||||
// UnitFileState, by unit.
|
||||
func (a *Applier) unitStates(ctx context.Context, units []string) map[string]map[string]string {
|
||||
out := map[string]map[string]string{}
|
||||
if len(units) == 0 {
|
||||
return out
|
||||
}
|
||||
args := append([]string{"show", "-p", "Id,ActiveState,SubState,Result,UnitFileState", "--no-pager"}, units...)
|
||||
shown, _ := a.M.Run(ctx, "systemctl", args...)
|
||||
for _, block := range strings.Split(shown, "\n\n") {
|
||||
kv := map[string]string{}
|
||||
for _, line := range strings.Split(block, "\n") {
|
||||
if k, v, ok := strings.Cut(line, "="); ok {
|
||||
kv[k] = v
|
||||
}
|
||||
}
|
||||
if kv["Id"] != "" {
|
||||
out[kv["Id"]] = kv
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func readJSON[T any](m *Machine, p string) (T, error) {
|
||||
var v T
|
||||
text, err := m.read(p)
|
||||
if err != nil {
|
||||
return v, err
|
||||
}
|
||||
return v, json.Unmarshal([]byte(text), &v)
|
||||
}
|
||||
|
||||
// Look is one pass: the units written as asked, the automounts armed, and each share found.
|
||||
func (a *Applier) Look(ctx context.Context) Status {
|
||||
now := a.Now()
|
||||
st := Status{Looked: now.Format(time.RFC3339), Shares: []ShareStatus{}}
|
||||
previous, _ := readJSON[Status](a.M, StatusFile)
|
||||
since := map[string]ShareStatus{}
|
||||
for _, s := range previous.Shares {
|
||||
since[s.Name] = s
|
||||
}
|
||||
|
||||
text, err := a.M.read(ConfigFile)
|
||||
if err != nil {
|
||||
st.Problems = append(st.Problems, unreadable(ConfigFile, err)+"; nothing is written or removed until the mesh places it")
|
||||
a.finish(&st, nil)
|
||||
return st
|
||||
}
|
||||
cfg := ParseConfig(text)
|
||||
st.Server = cfg.ServerNode()
|
||||
st.Problems = append(st.Problems, cfg.Problems...)
|
||||
|
||||
exports, eerr := readJSON[ExportsAnswer](a.M, ExportsFile)
|
||||
byName := map[string]ServerShare{}
|
||||
for _, e := range exports.Shares {
|
||||
byName[e.Name] = e
|
||||
}
|
||||
|
||||
var pairs []Pair
|
||||
notSet := map[string]ShareStatus{} // a share named but not set up, with why
|
||||
for _, w := range cfg.Shares {
|
||||
e, ok := byName[w.Name]
|
||||
switch {
|
||||
case cfg.At == "":
|
||||
notSet[w.Name] = ShareStatus{Name: w.Name, Kind: "nfs", Mountpoint: w.Mountpoint, State: StateFailed,
|
||||
Why: "no machine shares nfs-share for this one: the binding gives no address"}
|
||||
case !ok && eerr != nil:
|
||||
notSet[w.Name] = ShareStatus{Name: w.Name, Kind: "nfs", Mountpoint: w.Mountpoint, State: StateUnreachable,
|
||||
Why: "what the sharing machine " + st.Server + " exports has not been read from the bus yet"}
|
||||
case !ok:
|
||||
why := "the sharing machine " + exports.Server + " does not export a share named " + w.Name
|
||||
if exports.Error != "" {
|
||||
why = "what the sharing machine " + st.Server + " exports could not be read from the bus: " + exports.Error
|
||||
}
|
||||
notSet[w.Name] = ShareStatus{Name: w.Name, Kind: "nfs", Mountpoint: w.Mountpoint, State: StateFailed, Why: why}
|
||||
case !strings.HasPrefix(e.Path, "/") || strings.ContainsAny(e.Path, " \t\n\\\"'"):
|
||||
notSet[w.Name] = ShareStatus{Name: w.Name, Kind: "nfs", Mountpoint: w.Mountpoint, State: StateFailed,
|
||||
Why: fmt.Sprintf("the sharing machine gave the path %q, which the module does not mount", e.Path)}
|
||||
default:
|
||||
pairs = append(pairs, NFSPair(w, cfg.At, e))
|
||||
}
|
||||
}
|
||||
|
||||
var creds map[string]SMBCredential
|
||||
needCreds := false
|
||||
for _, s := range cfg.Sources {
|
||||
needCreds = needCreds || s.Kind == "smb"
|
||||
}
|
||||
if needCreds {
|
||||
secret, err := a.M.read(cfg.Secret)
|
||||
if cfg.Secret == "" || err != nil {
|
||||
st.Problems = append(st.Problems, "the module's secret smb-credentials is not on this machine")
|
||||
} else {
|
||||
var p []string
|
||||
creds, p = ParseSMBCredentials(secret)
|
||||
for _, x := range p {
|
||||
st.Problems = append(st.Problems, "smb-credentials: "+x)
|
||||
}
|
||||
}
|
||||
}
|
||||
uid, gid := -1, -1
|
||||
if cfg.Account != "" && a.Lookup != nil {
|
||||
if u, g, err := a.Lookup(cfg.Account); err == nil {
|
||||
uid, gid = u, g
|
||||
}
|
||||
}
|
||||
for _, s := range cfg.Sources {
|
||||
switch s.Kind {
|
||||
case "disk":
|
||||
pairs = append(pairs, DiskPair(s))
|
||||
case "smb":
|
||||
c, ok := creds[s.Name]
|
||||
if !ok || cfg.State == "" {
|
||||
notSet[s.Name] = ShareStatus{Name: s.Name, Kind: "smb", Occasional: true, Mountpoint: s.Mountpoint, State: StateFailed,
|
||||
Why: fmt.Sprintf("its login is not given: put a [%s] section in the module's secret with `secret accept <node> mounts smb-credentials --from <file>`", s.Name)}
|
||||
continue
|
||||
}
|
||||
file := filepath.Join(cfg.State, "smb-"+s.Name+".credentials")
|
||||
changed, err := false, error(nil)
|
||||
if a.Act {
|
||||
changed, err = a.writeIfChanged(file, c.CredentialsFile(), 0o600)
|
||||
}
|
||||
if err != nil {
|
||||
notSet[s.Name] = ShareStatus{Name: s.Name, Kind: "smb", Occasional: true, Mountpoint: s.Mountpoint, State: StateFailed,
|
||||
Why: "its credentials file could not be written: " + err.Error()}
|
||||
continue
|
||||
}
|
||||
if changed {
|
||||
st.Changed = append(st.Changed, "the login of "+s.Name)
|
||||
}
|
||||
pairs = append(pairs, SMBPair(s, file, uid, gid))
|
||||
}
|
||||
}
|
||||
|
||||
// Every mount point the settings still name, set up or not: a pair is removed only when its mount
|
||||
// point is no longer named, never because an answer is missing.
|
||||
named := map[string]bool{}
|
||||
for _, w := range cfg.Shares {
|
||||
named[EscapePath(w.Mountpoint)] = true
|
||||
}
|
||||
for _, s := range cfg.Sources {
|
||||
named[EscapePath(s.Mountpoint)] = true
|
||||
}
|
||||
|
||||
own := a.ownUnits()
|
||||
mounted := ParseMountinfo(func() string { t, _ := a.M.read("/proc/self/mountinfo"); return t }())
|
||||
changed := false
|
||||
var written []Pair
|
||||
for _, p := range pairs {
|
||||
if err := a.foreign(p, own); err != nil {
|
||||
notSet[p.Name] = ShareStatus{Name: p.Name, Kind: p.Kind, Occasional: p.Occasional, Mountpoint: p.Mountpoint, What: p.What,
|
||||
State: StateFailed, Why: err.Error()}
|
||||
continue
|
||||
}
|
||||
if !a.Act {
|
||||
written = append(written, p)
|
||||
continue
|
||||
}
|
||||
c1, err1 := a.writeIfChanged(filepath.Join(UnitDir, p.MountUnit), p.Mount, 0o644)
|
||||
c2, err2 := a.writeIfChanged(filepath.Join(UnitDir, p.AutoUnit), p.Automount, 0o644)
|
||||
if err1 != nil || err2 != nil {
|
||||
notSet[p.Name] = ShareStatus{Name: p.Name, Kind: p.Kind, Occasional: p.Occasional, Mountpoint: p.Mountpoint, What: p.What,
|
||||
State: StateFailed, Why: fmt.Sprintf("its units could not be written: %v %v", err1, err2)}
|
||||
continue
|
||||
}
|
||||
if c1 || c2 {
|
||||
changed = true
|
||||
st.Changed = append(st.Changed, "the units of "+p.Name)
|
||||
}
|
||||
// A missing mount point is made; one that is there is left exactly as it is. An automount point
|
||||
// is in mountinfo and is never looked at, so a server that is away cannot hold this look.
|
||||
if _, there := mounted[p.Mountpoint]; !there {
|
||||
if _, err := os.Lstat(a.path(p.Mountpoint)); os.IsNotExist(err) {
|
||||
if err := os.MkdirAll(a.path(p.Mountpoint), 0o755); err == nil {
|
||||
st.Changed = append(st.Changed, "made the mount point "+p.Mountpoint)
|
||||
}
|
||||
}
|
||||
}
|
||||
written = append(written, p)
|
||||
}
|
||||
|
||||
// The module's own pairs whose mount point nothing names any more: stopped, disabled and removed.
|
||||
var gone []string
|
||||
for unit := range own {
|
||||
base := strings.TrimSuffix(strings.TrimSuffix(unit, ".automount"), ".mount")
|
||||
if !named[base] && a.Act {
|
||||
gone = append(gone, unit)
|
||||
}
|
||||
}
|
||||
sort.Strings(gone)
|
||||
for _, unit := range gone {
|
||||
if strings.HasSuffix(unit, ".automount") {
|
||||
a.M.Run(ctx, "systemctl", "disable", "--now", unit)
|
||||
} else {
|
||||
a.M.Run(ctx, "systemctl", "stop", unit)
|
||||
}
|
||||
}
|
||||
for _, unit := range gone {
|
||||
if err := os.Remove(a.path(filepath.Join(UnitDir, unit))); err == nil {
|
||||
changed = true
|
||||
st.Changed = append(st.Changed, "removed "+unit+", which nothing names any more")
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
if _, err := a.M.Run(ctx, "systemctl", "daemon-reload"); err != nil {
|
||||
st.Problems = append(st.Problems, "the service manager did not reload: "+err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// Arm every automount that is not.
|
||||
var units []string
|
||||
for _, p := range written {
|
||||
units = append(units, p.MountUnit, p.AutoUnit)
|
||||
}
|
||||
states := a.unitStates(ctx, units)
|
||||
for _, p := range written {
|
||||
auto := states[p.AutoUnit]
|
||||
if a.Act && (auto["UnitFileState"] != "enabled" || auto["ActiveState"] != "active") {
|
||||
if _, err := a.M.Run(ctx, "systemctl", "enable", "--now", p.AutoUnit); err != nil {
|
||||
st.Problems = append(st.Problems, fmt.Sprintf("%s was not armed: %v", p.AutoUnit, err))
|
||||
} else {
|
||||
st.Changed = append(st.Changed, "armed "+p.AutoUnit)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !a.Judge {
|
||||
a.finish(&st, notSet)
|
||||
return st
|
||||
}
|
||||
if len(written) > 0 {
|
||||
states = a.unitStates(ctx, units)
|
||||
}
|
||||
// What the acting process could not do, which only it knows: a unit or a login it could not write.
|
||||
if applied, err := readJSON[Applied](a.M, AppliedFile); err == nil && !a.Act {
|
||||
st.Problems = append(st.Problems, applied.Problems...)
|
||||
for _, s := range applied.NotSet {
|
||||
if _, said := notSet[s.Name]; !said {
|
||||
notSet[s.Name] = s
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, p := range written {
|
||||
if _, failed := notSet[p.Name]; failed {
|
||||
continue
|
||||
}
|
||||
s := ShareStatus{Name: p.Name, Kind: p.Kind, Occasional: p.Occasional, Mountpoint: p.Mountpoint, What: p.What,
|
||||
Probe: p.Probe, MountUnit: p.MountUnit, AutoUnit: p.AutoUnit}
|
||||
s.State, s.Why = a.judge(p, states, mounted)
|
||||
st.Shares = append(st.Shares, s)
|
||||
}
|
||||
for _, s := range notSet {
|
||||
st.Shares = append(st.Shares, s)
|
||||
}
|
||||
sort.Slice(st.Shares, func(i, j int) bool { return st.Shares[i].Name < st.Shares[j].Name })
|
||||
for i := range st.Shares {
|
||||
s := &st.Shares[i]
|
||||
if prev, ok := since[s.Name]; ok && prev.State == s.State && prev.Since != "" {
|
||||
s.Since = prev.Since
|
||||
} else {
|
||||
s.Since = st.Looked
|
||||
}
|
||||
}
|
||||
a.finish(&st, notSet)
|
||||
return st
|
||||
}
|
||||
|
||||
// AppliedFile is what the acting process could not do, for the judging one.
|
||||
const AppliedFile = RunDir + "/applied.json"
|
||||
|
||||
// Applied is the acting process's last pass.
|
||||
type Applied struct {
|
||||
Looked string `json:"looked"`
|
||||
Problems []string `json:"problems,omitempty"`
|
||||
NotSet []ShareStatus `json:"not_set,omitempty"`
|
||||
Changed []string `json:"changed,omitempty"`
|
||||
}
|
||||
|
||||
// finish leaves what this pass found: the acting process's account, the judging one's status.
|
||||
func (a *Applier) finish(st *Status, notSet map[string]ShareStatus) {
|
||||
if a.Act && !a.Judge {
|
||||
ap := Applied{Looked: st.Looked, Problems: st.Problems, Changed: st.Changed}
|
||||
for _, s := range notSet {
|
||||
ap.NotSet = append(ap.NotSet, s)
|
||||
}
|
||||
sort.Slice(ap.NotSet, func(i, j int) bool { return ap.NotSet[i].Name < ap.NotSet[j].Name })
|
||||
raw, _ := json.MarshalIndent(ap, "", " ")
|
||||
if _, err := a.writeIfChanged(AppliedFile, string(raw)+"\n", 0o644); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "mounts: what was applied was not written:", err)
|
||||
}
|
||||
}
|
||||
if a.Judge {
|
||||
a.writeStatus(st)
|
||||
}
|
||||
}
|
||||
|
||||
// foreign refuses a pair whose unit file exists and is not the module's.
|
||||
func (a *Applier) foreign(p Pair, own map[string]bool) error {
|
||||
for _, unit := range []string{p.MountUnit, p.AutoUnit} {
|
||||
if own[unit] {
|
||||
continue
|
||||
}
|
||||
if _, err := os.Lstat(a.path(filepath.Join(UnitDir, unit))); err == nil {
|
||||
return fmt.Errorf("%s already has a unit file that is not the module's (%s); it is left as it is, and this share is not mounted here", p.Mountpoint, filepath.Join(UnitDir, unit))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// judge is one pair's state. The source is tried first, without touching the mount point.
|
||||
func (a *Applier) judge(p Pair, states map[string]map[string]string, mounted map[string]Mounted) (string, string) {
|
||||
there := true
|
||||
var tried error
|
||||
if p.Kind == "disk" {
|
||||
if !a.M.present(p.Probe) {
|
||||
there, tried = false, fmt.Errorf("the disk %s is not plugged in", p.Probe)
|
||||
}
|
||||
} else if err := a.Dial(p.Probe, ProbeTimeout); err != nil {
|
||||
there, tried = false, err
|
||||
}
|
||||
mnt, auto := states[p.MountUnit], states[p.AutoUnit]
|
||||
cur, isMounted := mounted[p.Mountpoint]
|
||||
isMounted = isMounted && cur.Type != "autofs"
|
||||
switch {
|
||||
case !there && p.Occasional:
|
||||
return StateAbsent, "not present: " + tried.Error()
|
||||
case !there:
|
||||
return StateUnreachable, fmt.Sprintf("%s does not answer: %v", p.Probe, tried)
|
||||
case isMounted:
|
||||
return StateMounted, ""
|
||||
case mnt["ActiveState"] == "failed":
|
||||
return StateFailed, fmt.Sprintf("its last mount failed (%s); see the journal of %s", orDefault(mnt["Result"], "failed"), p.MountUnit)
|
||||
case auto["ActiveState"] == "active":
|
||||
return StateArmed, ""
|
||||
default:
|
||||
return StateFailed, fmt.Sprintf("its automount %s is not armed (%s)", p.AutoUnit, orDefault(auto["ActiveState"], "unknown"))
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Applier) writeStatus(st *Status) {
|
||||
raw, _ := json.MarshalIndent(st, "", " ")
|
||||
if _, err := a.writeIfChanged(StatusFile, string(raw)+"\n", 0o644); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "mounts: the status was not written:", err)
|
||||
}
|
||||
}
|
||||
|
||||
// RunApply looks every LookEvery until the context ends, saying on stderr what it changed.
|
||||
func RunApply(ctx context.Context, a *Applier) {
|
||||
for {
|
||||
st := a.Look(ctx)
|
||||
for _, c := range st.Changed {
|
||||
fmt.Fprintln(os.Stderr, "mounts:", c)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(LookEvery):
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package main
|
||||
|
||||
// The bundle's half on the bus (novox/hq ADR 0263 rule 9, ADR 0201). The bundle runs as the operator
|
||||
// account and reaches the bus through the runtime; the process runs as root and does not. So every 30
|
||||
// seconds the bundle:
|
||||
//
|
||||
// - reads what the sharing machine exports from nfs-server's state `exports`, keyed by that machine
|
||||
// (ADR 0201: a module reads another's state, never its files), and leaves it in ExportsFile for the
|
||||
// process. A failed read keeps the last answer's shares, so a bus that is away for a minute removes no
|
||||
// mount;
|
||||
// - puts what the process last found in the state `shares`, keyed by this machine, so a view or the
|
||||
// controller watches it without reading this machine's files.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SharesState is the state the module keeps on the bus, one key per machine.
|
||||
const SharesState = "shares"
|
||||
|
||||
// ExportsState is nfs-server's state this module reads: what each sharing machine exports, by machine.
|
||||
const ExportsState = "nfs-server.exports"
|
||||
|
||||
// Getter reads one key of a state, nil when it has none; Putter puts one key of a state.
|
||||
type (
|
||||
Getter func(key string) (json.RawMessage, error)
|
||||
Putter func(key string, value any) error
|
||||
)
|
||||
|
||||
// AskExports reads what the sharing machine exports, once, and answers what to leave for the process.
|
||||
func AskExports(cfg Config, previous ExportsAnswer, get Getter, now time.Time) ExportsAnswer {
|
||||
out := ExportsAnswer{Server: cfg.ServerNode(), At: cfg.At, Asked: now.Format(time.RFC3339)}
|
||||
if out.Server == "" {
|
||||
out.Error = "no machine shares nfs-share for this one"
|
||||
return out
|
||||
}
|
||||
raw, err := get(out.Server)
|
||||
var answer struct {
|
||||
Shares []ServerShare `json:"shares"`
|
||||
}
|
||||
if err == nil && len(raw) == 0 {
|
||||
err = fmt.Errorf("%s keeps nothing in nfs-server's state exports yet", out.Server)
|
||||
}
|
||||
if err == nil {
|
||||
err = json.Unmarshal(raw, &answer)
|
||||
if err == nil && answer.Shares == nil {
|
||||
err = fmt.Errorf("the answer names no shares")
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
out.Error = err.Error()
|
||||
if previous.Server == out.Server {
|
||||
out.Shares, out.Answered = previous.Shares, previous.Answered
|
||||
}
|
||||
return out
|
||||
}
|
||||
out.Shares, out.Answered = answer.Shares, out.Asked
|
||||
return out
|
||||
}
|
||||
|
||||
// Share is one pass of the bundle's work over a machine.
|
||||
func (m *Machine) Share(node string, get Getter, put Putter, last *string, now time.Time) []string {
|
||||
var said []string
|
||||
if text, err := m.read(ConfigFile); err == nil {
|
||||
cfg := ParseConfig(text)
|
||||
if len(cfg.Shares) > 0 {
|
||||
previous, _ := readJSON[ExportsAnswer](m, ExportsFile)
|
||||
answer := AskExports(cfg, previous, get, now)
|
||||
if answer.Error != "" {
|
||||
said = append(said, "what the sharing machine exports was not read: "+answer.Error)
|
||||
}
|
||||
raw, _ := json.MarshalIndent(answer, "", " ")
|
||||
full := filepath.Join(m.Root, ExportsFile)
|
||||
tmp := full + ".new"
|
||||
if err := os.WriteFile(tmp, append(raw, '\n'), 0o644); err != nil {
|
||||
said = append(said, "the answer was not left for the process: "+err.Error())
|
||||
} else if err := os.Rename(tmp, full); err != nil {
|
||||
said = append(said, "the answer was not left for the process: "+err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
if node != "" && put != nil {
|
||||
if text, err := m.read(StatusFile); err == nil && text != *last {
|
||||
var st Status
|
||||
if json.Unmarshal([]byte(text), &st) == nil {
|
||||
if err := put(node, st); err != nil {
|
||||
said = append(said, "the state was not put: "+err.Error())
|
||||
} else {
|
||||
*last = text
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return said
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
package main
|
||||
|
||||
// What this machine is asked to mount (novox/hq ADR 0263, to-be 52 §2), and where the module keeps
|
||||
// what it learns.
|
||||
//
|
||||
// The mesh renders ConfigFile from the assignment: the settings `shares` and `sources`, and the binding
|
||||
// of the provision `nfs-share` (`from`, the machine that shares, and `at`, where it is reached). The
|
||||
// long-running process reads it as root and writes the mount units; the bundle reads it as the operator
|
||||
// account to read what the sharing machine exports from the bus. Nothing in it is secret: the SMB credentials are the
|
||||
// module's own secret, in a file only root reads.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Where the module keeps its things. ConfigFile is placed by the mesh; the rest by the module's code.
|
||||
const (
|
||||
ConfigFile = "/etc/mesh-mounts/mounts.conf"
|
||||
// BusDir is written by the bundle, which runs as the operator account and reaches the bus; the
|
||||
// process, which runs as root and does not, reads what the sharing machine said from it.
|
||||
BusDir = "/var/lib/mesh-mounts/from-bus"
|
||||
ExportsFile = BusDir + "/exports.json"
|
||||
// RunDir is the process's: what it found on its last look, which the tools and the bundle read.
|
||||
RunDir = "/run/mesh-mounts"
|
||||
StatusFile = RunDir + "/status.json"
|
||||
// UnitDir is where the process writes each unit pair, beside the operator's own units.
|
||||
UnitDir = "/etc/systemd/system"
|
||||
)
|
||||
|
||||
// None is the value of an empty setting: a definition's default may not be empty (ADR 0262).
|
||||
const None = "none"
|
||||
|
||||
// Config is the rendered file, read.
|
||||
type Config struct {
|
||||
Shares []Wanted `json:"shares"`
|
||||
Sources []Source `json:"sources"`
|
||||
// Server is the machine sharing the provision `nfs-share` (the binding's `from`), and At where it is
|
||||
// reached on the private network (the binding's `at`).
|
||||
Server string `json:"server,omitempty"`
|
||||
At string `json:"at,omitempty"`
|
||||
// Secret is the path of the module's own secret `smb-credentials`; State is the module's state
|
||||
// directory, where the per-source credentials files are written.
|
||||
Secret string `json:"secret,omitempty"`
|
||||
State string `json:"state,omitempty"`
|
||||
Account string `json:"account,omitempty"`
|
||||
// Problems are what in the file could not be read; each is said, and its share is left out.
|
||||
Problems []string `json:"problems,omitempty"`
|
||||
}
|
||||
|
||||
// Wanted is one NFS share this machine mounts: the share's name on the sharing machine, and where.
|
||||
type Wanted struct {
|
||||
Name string `json:"name"`
|
||||
Mountpoint string `json:"mountpoint"`
|
||||
ReadOnly bool `json:"read_only"`
|
||||
}
|
||||
|
||||
// Source is one occasional source: a device that is not a node and is not always there.
|
||||
type Source struct {
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"` // smb or disk
|
||||
Host string `json:"host,omitempty"`
|
||||
Share string `json:"share,omitempty"`
|
||||
UUID string `json:"uuid,omitempty"`
|
||||
Mountpoint string `json:"mountpoint"`
|
||||
ReadOnly bool `json:"read_only"`
|
||||
}
|
||||
|
||||
var (
|
||||
nameRe = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,62}$`)
|
||||
hostRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9.-]{0,252}$`)
|
||||
shareRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9 ._$-]{0,79}$`)
|
||||
uuidRe = regexp.MustCompile(`^[A-Za-z0-9-]{4,64}$`)
|
||||
)
|
||||
|
||||
// CheckMountpoint refuses a mount point the module may not use: not absolute, not clean, the root, or
|
||||
// under a directory the system owns.
|
||||
func CheckMountpoint(p string) error {
|
||||
switch {
|
||||
case !strings.HasPrefix(p, "/"):
|
||||
return fmt.Errorf("the mount point %q is not an absolute path", p)
|
||||
case filepath.Clean(p) != p:
|
||||
return fmt.Errorf("the mount point %q is not written plainly (it would be %q)", p, filepath.Clean(p))
|
||||
case strings.ContainsAny(p, " \t\n\\\"'"):
|
||||
return fmt.Errorf("the mount point %q holds a space, a quote or a backslash", p)
|
||||
}
|
||||
for _, sys := range []string{"/", "/etc", "/usr", "/bin", "/sbin", "/lib", "/lib64", "/boot", "/proc", "/sys", "/dev", "/run", "/var", "/home", "/root", "/tmp"} {
|
||||
if p == sys {
|
||||
return fmt.Errorf("the mount point %q is the system's own directory", p)
|
||||
}
|
||||
}
|
||||
for _, sys := range []string{"/etc/", "/usr/", "/bin/", "/sbin/", "/lib/", "/boot/", "/proc/", "/sys/", "/dev/", "/run/"} {
|
||||
if strings.HasPrefix(p, sys) {
|
||||
return fmt.Errorf("the mount point %q is under %s, which is the system's", p, strings.TrimSuffix(sys, "/"))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func entries(v string) []string {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" || v == None {
|
||||
return nil
|
||||
}
|
||||
return strings.FieldsFunc(v, func(r rune) bool { return r == ' ' || r == ',' || r == '\n' || r == '\t' })
|
||||
}
|
||||
|
||||
func readOnly(s string) (string, bool) {
|
||||
if strings.HasSuffix(s, ":ro") {
|
||||
return strings.TrimSuffix(s, ":ro"), true
|
||||
}
|
||||
return strings.TrimSuffix(s, ":rw"), false
|
||||
}
|
||||
|
||||
// ParseShares reads the setting `shares`: space-separated `name=mountpoint`, each optionally `:ro`.
|
||||
// What cannot be read is said, and only that entry is left out.
|
||||
func ParseShares(v string) ([]Wanted, []string) {
|
||||
var out []Wanted
|
||||
var problems []string
|
||||
seen := map[string]bool{}
|
||||
for _, e := range entries(v) {
|
||||
name, rest, ok := strings.Cut(e, "=")
|
||||
if !ok {
|
||||
problems = append(problems, fmt.Sprintf("the share %q is not name=mountpoint", e))
|
||||
continue
|
||||
}
|
||||
point, ro := readOnly(rest)
|
||||
if !nameRe.MatchString(name) {
|
||||
problems = append(problems, fmt.Sprintf("the share name %q is not lower-case letters, digits and dashes", name))
|
||||
continue
|
||||
}
|
||||
if err := CheckMountpoint(point); err != nil {
|
||||
problems = append(problems, fmt.Sprintf("the share %s: %v", name, err))
|
||||
continue
|
||||
}
|
||||
if seen[name] || seen["@"+point] {
|
||||
problems = append(problems, fmt.Sprintf("the share %s or its mount point %s is named twice", name, point))
|
||||
continue
|
||||
}
|
||||
seen[name], seen["@"+point] = true, true
|
||||
out = append(out, Wanted{Name: name, Mountpoint: point, ReadOnly: ro})
|
||||
}
|
||||
return out, problems
|
||||
}
|
||||
|
||||
// ParseSources reads the setting `sources`: space-separated `name=smb://<host>/<share>@<mountpoint>` or
|
||||
// `name=disk:<uuid>@<mountpoint>`, each optionally `:ro`.
|
||||
func ParseSources(v string) ([]Source, []string) {
|
||||
var out []Source
|
||||
var problems []string
|
||||
seen := map[string]bool{}
|
||||
for _, e := range entries(v) {
|
||||
name, rest, ok := strings.Cut(e, "=")
|
||||
if !ok {
|
||||
problems = append(problems, fmt.Sprintf("the source %q is not name=<where>@<mountpoint>", e))
|
||||
continue
|
||||
}
|
||||
if !nameRe.MatchString(name) {
|
||||
problems = append(problems, fmt.Sprintf("the source name %q is not lower-case letters, digits and dashes", name))
|
||||
continue
|
||||
}
|
||||
at := strings.LastIndex(rest, "@")
|
||||
if at < 0 {
|
||||
problems = append(problems, fmt.Sprintf("the source %s names no mount point (…@<mountpoint>)", name))
|
||||
continue
|
||||
}
|
||||
where, pointRO := rest[:at], rest[at+1:]
|
||||
point, ro := readOnly(pointRO)
|
||||
if err := CheckMountpoint(point); err != nil {
|
||||
problems = append(problems, fmt.Sprintf("the source %s: %v", name, err))
|
||||
continue
|
||||
}
|
||||
s := Source{Name: name, Mountpoint: point, ReadOnly: ro}
|
||||
switch {
|
||||
case strings.HasPrefix(where, "smb://"):
|
||||
host, share, ok := strings.Cut(strings.TrimPrefix(where, "smb://"), "/")
|
||||
if !ok || !hostRe.MatchString(host) || !shareRe.MatchString(share) {
|
||||
problems = append(problems, fmt.Sprintf("the source %s: %q is not smb://<host>/<share>", name, where))
|
||||
continue
|
||||
}
|
||||
s.Kind, s.Host, s.Share = "smb", host, share
|
||||
case strings.HasPrefix(where, "disk:"):
|
||||
uuid := strings.TrimPrefix(where, "disk:")
|
||||
if !uuidRe.MatchString(uuid) {
|
||||
problems = append(problems, fmt.Sprintf("the source %s: %q is not disk:<filesystem uuid>", name, where))
|
||||
continue
|
||||
}
|
||||
s.Kind, s.UUID = "disk", uuid
|
||||
default:
|
||||
problems = append(problems, fmt.Sprintf("the source %s: %q is neither smb://<host>/<share> nor disk:<uuid>", name, where))
|
||||
continue
|
||||
}
|
||||
if seen[name] || seen["@"+point] {
|
||||
problems = append(problems, fmt.Sprintf("the source %s or its mount point %s is named twice", name, point))
|
||||
continue
|
||||
}
|
||||
seen[name], seen["@"+point] = true, true
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, problems
|
||||
}
|
||||
|
||||
// ParseConfig reads the rendered file: `key=value` lines.
|
||||
func ParseConfig(text string) Config {
|
||||
kv := map[string]string{}
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
k, v, ok := strings.Cut(line, "=")
|
||||
if ok {
|
||||
kv[strings.TrimSpace(k)] = strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
var c Config
|
||||
var p []string
|
||||
c.Shares, p = ParseShares(kv["shares"])
|
||||
c.Problems = append(c.Problems, p...)
|
||||
c.Sources, p = ParseSources(kv["sources"])
|
||||
c.Problems = append(c.Problems, p...)
|
||||
c.Server, c.At, c.Secret, c.State, c.Account = kv["server"], kv["at"], kv["secret"], kv["state"], kv["account"]
|
||||
if c.At != "" && !hostRe.MatchString(c.At) {
|
||||
c.Problems = append(c.Problems, fmt.Sprintf("the sharing machine's address %q is not a name or an address", c.At))
|
||||
c.At = ""
|
||||
}
|
||||
points := map[string]string{}
|
||||
for _, w := range c.Shares {
|
||||
points[w.Mountpoint] = w.Name
|
||||
}
|
||||
kept := c.Sources[:0]
|
||||
for _, s := range c.Sources {
|
||||
if other, dup := points[s.Mountpoint]; dup {
|
||||
c.Problems = append(c.Problems, fmt.Sprintf("the source %s mounts at %s, where the share %s already does", s.Name, s.Mountpoint, other))
|
||||
continue
|
||||
}
|
||||
kept = append(kept, s)
|
||||
}
|
||||
c.Sources = kept
|
||||
return c
|
||||
}
|
||||
|
||||
// ServerNode is the sharing machine, the key of its exports on the bus: the binding's `from`, else the first label of `at`.
|
||||
func (c Config) ServerNode() string {
|
||||
if c.Server != "" {
|
||||
return c.Server
|
||||
}
|
||||
if c.At == "" {
|
||||
return ""
|
||||
}
|
||||
first, _, _ := strings.Cut(c.At, ".")
|
||||
return first
|
||||
}
|
||||
|
||||
// SMBCredential is one source's login, from the module's own secret.
|
||||
type SMBCredential struct {
|
||||
Username string
|
||||
Password string
|
||||
Domain string
|
||||
}
|
||||
|
||||
// ParseSMBCredentials reads the secret `smb-credentials`: one section per source,
|
||||
//
|
||||
// [name]
|
||||
// username=…
|
||||
// password=…
|
||||
// domain=… (optional)
|
||||
//
|
||||
// Its values are never answered by any tool; a problem names the section and the key, never the value.
|
||||
func ParseSMBCredentials(text string) (map[string]SMBCredential, []string) {
|
||||
out := map[string]SMBCredential{}
|
||||
var problems []string
|
||||
section := ""
|
||||
for i, raw := range strings.Split(text, "\n") {
|
||||
line := strings.TrimSpace(raw)
|
||||
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
|
||||
section = strings.TrimSpace(line[1 : len(line)-1])
|
||||
if _, dup := out[section]; dup {
|
||||
problems = append(problems, fmt.Sprintf("the section [%s] is given twice; the first is kept", section))
|
||||
section = ""
|
||||
continue
|
||||
}
|
||||
out[section] = SMBCredential{}
|
||||
continue
|
||||
}
|
||||
k, v, ok := strings.Cut(line, "=")
|
||||
if !ok || section == "" {
|
||||
problems = append(problems, fmt.Sprintf("line %d is not key=value inside a [section]", i+1))
|
||||
continue
|
||||
}
|
||||
c := out[section]
|
||||
switch strings.ToLower(strings.TrimSpace(k)) {
|
||||
case "username", "user":
|
||||
c.Username = strings.TrimSpace(v)
|
||||
case "password", "pass":
|
||||
c.Password = strings.TrimRight(v, "\r")
|
||||
case "domain":
|
||||
c.Domain = strings.TrimSpace(v)
|
||||
default:
|
||||
problems = append(problems, fmt.Sprintf("[%s] has a key %q this module does not read", section, strings.TrimSpace(k)))
|
||||
}
|
||||
out[section] = c
|
||||
}
|
||||
for name, c := range out {
|
||||
if c.Username == "" {
|
||||
problems = append(problems, fmt.Sprintf("[%s] gives no username", name))
|
||||
}
|
||||
if strings.ContainsAny(c.Username+c.Password+c.Domain, "\n") {
|
||||
problems = append(problems, fmt.Sprintf("[%s] holds a line break", name))
|
||||
delete(out, name)
|
||||
}
|
||||
}
|
||||
return out, problems
|
||||
}
|
||||
|
||||
// CredentialsFile is a source's credentials in mount.cifs's format.
|
||||
func (c SMBCredential) CredentialsFile() string {
|
||||
s := "username=" + c.Username + "\npassword=" + c.Password + "\n"
|
||||
if c.Domain != "" {
|
||||
s += "domain=" + c.Domain + "\n"
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package main
|
||||
|
||||
// The module's health (novox/hq ADR 0240, ADR 0263 rule 8), judged from what the process last found.
|
||||
//
|
||||
// Unhealthy when a share that should always be there has been unreachable or failed for five minutes,
|
||||
// when an automount is not armed, when something the settings name cannot be set up, or when the
|
||||
// process has not looked for two minutes. An occasional source that is absent is healthy: it is said as
|
||||
// not present since when, and raises nothing.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Health is the answer the node-engine reads from a tool check.
|
||||
type Health struct {
|
||||
Healthy bool `json:"healthy"`
|
||||
Why string `json:"why"`
|
||||
Found []string `json:"found,omitempty"`
|
||||
Said []string `json:"said,omitempty"`
|
||||
}
|
||||
|
||||
// Grace is how long a share may be away before it is a fault; Stale how old the process's look may be.
|
||||
const (
|
||||
Grace = 5 * time.Minute
|
||||
Stale = 2 * time.Minute
|
||||
)
|
||||
|
||||
func clock(ts string) string {
|
||||
t, err := time.Parse(time.RFC3339, ts)
|
||||
if err != nil {
|
||||
return ts
|
||||
}
|
||||
return t.Local().Format("15:04")
|
||||
}
|
||||
|
||||
// Judge is the health of one status at a moment.
|
||||
func Judge(st Status, readErr error, now time.Time) Health {
|
||||
if readErr != nil {
|
||||
return Health{Healthy: false, Why: "The module's process has left no account of this machine's mounts (" + readErr.Error() + ").",
|
||||
Found: []string{"no status"}}
|
||||
}
|
||||
var found, said []string
|
||||
if looked, err := time.Parse(time.RFC3339, st.Looked); err != nil || now.Sub(looked) > Stale {
|
||||
found = append(found, fmt.Sprintf("The module's process last looked at %s, more than two minutes ago.", clock(st.Looked)))
|
||||
}
|
||||
for _, p := range st.Problems {
|
||||
found = append(found, "The module's settings or secret: "+p+".")
|
||||
}
|
||||
from := ""
|
||||
if st.Server != "" {
|
||||
from = " of " + st.Server
|
||||
}
|
||||
for _, s := range st.Shares {
|
||||
since, _ := time.Parse(time.RFC3339, s.Since)
|
||||
long := now.Sub(since) >= Grace
|
||||
switch s.State {
|
||||
case StateMounted, StateArmed:
|
||||
said = append(said, fmt.Sprintf("%s at %s: %s since %s.", s.Name, s.Mountpoint, s.State, clock(s.Since)))
|
||||
case StateAbsent:
|
||||
said = append(said, fmt.Sprintf("%s at %s: not present since %s.", s.Name, s.Mountpoint, clock(s.Since)))
|
||||
case StateUnreachable:
|
||||
line := fmt.Sprintf("The share %s%s is not reachable from this machine since %s (%s).", s.Name, from, clock(s.Since), s.Why)
|
||||
if s.Kind != "nfs" {
|
||||
line = fmt.Sprintf("The source %s is not reachable from this machine since %s (%s).", s.Name, clock(s.Since), s.Why)
|
||||
}
|
||||
if long {
|
||||
found = append(found, line)
|
||||
} else {
|
||||
said = append(said, line+" Raised after five minutes.")
|
||||
}
|
||||
default:
|
||||
line := fmt.Sprintf("The %s %s at %s cannot be mounted since %s: %s.", map[bool]string{true: "source", false: "share"}[s.Occasional],
|
||||
s.Name, s.Mountpoint, clock(s.Since), strings.TrimSuffix(s.Why, "."))
|
||||
if long || strings.Contains(s.Why, "not armed") || strings.Contains(s.Why, "not the module's") || strings.Contains(s.Why, "not given") {
|
||||
found = append(found, line)
|
||||
} else {
|
||||
said = append(said, line+" Raised after five minutes.")
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(found) > 0 {
|
||||
return Health{Healthy: false, Why: strings.Join(found, " "), Found: found, Said: said}
|
||||
}
|
||||
why := "Every share this machine is asked to mount is armed or mounted, and every occasional source is armed, mounted or not present."
|
||||
if len(st.Shares) == 0 {
|
||||
why = "This machine is asked to mount nothing."
|
||||
}
|
||||
return Health{Healthy: true, Why: why, Said: said}
|
||||
}
|
||||
|
||||
// Health reads the process's last look and judges it.
|
||||
func (m *Machine) Health(now time.Time) Health {
|
||||
st, err := readJSON[Status](m, StatusFile)
|
||||
return Judge(st, err, now)
|
||||
}
|
||||
@@ -42,11 +42,15 @@ type Machine struct {
|
||||
Root string
|
||||
Run Runner
|
||||
LookPath func(name string) bool
|
||||
// Escalate runs a command as root: as given where this process is root, else through `sudo -n`, as the
|
||||
// bundle runs as the operator account (novox/hq ADR 0175 §4) and the sudo module lets it.
|
||||
Escalate Runner
|
||||
}
|
||||
|
||||
// Here is the machine this process runs on.
|
||||
func Here() *Machine {
|
||||
return &Machine{Root: "/", Run: ExecRunner, LookPath: func(n string) bool { _, err := exec.LookPath(n); return err == nil }}
|
||||
return &Machine{Root: "/", Run: ExecRunner, Escalate: EscalatedRunner,
|
||||
LookPath: func(n string) bool { _, err := exec.LookPath(n); return err == nil }}
|
||||
}
|
||||
|
||||
// read answers one file's text under the root. Only regular files the module names are read; a
|
||||
@@ -81,3 +85,34 @@ func unreadable(path string, err error) string {
|
||||
return fmt.Sprintf("%s: not read (%v)", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
// EscalatedRunner runs a command as root, through sudo without a prompt where this process is not root.
|
||||
// A refusal by sudo is said as one, never read as an empty answer.
|
||||
func EscalatedRunner(ctx context.Context, name string, args ...string) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 25*time.Second)
|
||||
defer cancel()
|
||||
program, argv := name, args
|
||||
if os.Getuid() != 0 {
|
||||
program, argv = "sudo", append([]string{"-n", name}, args...)
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, program, argv...)
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout, cmd.Stderr = &stdout, &stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
said := strings.TrimSpace(stderr.String())
|
||||
if program == "sudo" && strings.HasPrefix(said, "sudo:") {
|
||||
return stdout.String(), fmt.Errorf("%s needs root, and the tool runner's account may not run it without a prompt: %s", name, said)
|
||||
}
|
||||
if said != "" {
|
||||
return stdout.String(), fmt.Errorf("%s: %w: %s", name, err, said)
|
||||
}
|
||||
return stdout.String(), fmt.Errorf("%s: %w", name, err)
|
||||
}
|
||||
return stdout.String(), nil
|
||||
}
|
||||
|
||||
// present says whether a device or a file is there. Never used on a mount point.
|
||||
func (m *Machine) present(p string) bool {
|
||||
_, err := os.Stat(filepath.Join(m.Root, p))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
@@ -1,20 +1,74 @@
|
||||
// The mounts module's Go bundle (novox/hq research 038): a process the node's runtime launches and
|
||||
// speaks MCP over stdio to. It reads what a machine mounts — its fstab, its mount and automount units,
|
||||
// what is mounted now — and what it shares over the network — its NFS exports, its ZFS share
|
||||
// properties, its Samba shares — and changes nothing.
|
||||
// The mounts module's Go bundle (novox/hq research 038, ADR 0263). One binary, three ways to run:
|
||||
//
|
||||
// - with no argument it is the bundle the node's tool runner launches and speaks MCP to over stdio, as the
|
||||
// operator account: the tools that read what a machine mounts and shares, the seat node-mounts' verbs
|
||||
// and the health check; and beside them the module's half on the bus (bus.go);
|
||||
// - `apply` and `watch` are the module's two long-running processes, run by the node-engine as root:
|
||||
// one writes the unit pair of every share and source the machine is asked to mount and arms the
|
||||
// automounts, the other tries each source and says what it found (apply.go);
|
||||
// - `adopt <mountpoint>` is the root half of the verb adopt, run by the bundle through sudo (adopt.go).
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) > 1 {
|
||||
switch os.Args[1] {
|
||||
case "apply", "watch":
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, os.Interrupt)
|
||||
defer stop()
|
||||
a := NewApplier()
|
||||
a.Act, a.Judge = os.Args[1] == "apply", os.Args[1] == "watch"
|
||||
RunApply(ctx, a)
|
||||
return
|
||||
case "adopt":
|
||||
os.Exit(RunAdopt(os.Args[2:]))
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "mounts: %q is not a way to run; with no argument it serves its tools, with apply it "+
|
||||
"writes and arms the machine's mounts, with watch it says how they are, with adopt <mountpoint> it takes over an fstab line\n", os.Args[1])
|
||||
os.Exit(2)
|
||||
}
|
||||
}
|
||||
m := Here()
|
||||
go shareOnTheBus(m)
|
||||
// An empty name serves as the module the runtime names (MESH_SERVED_MODULE): mounts.
|
||||
if err := stdio.Serve("", Tools(Here())); err != nil {
|
||||
if err := stdio.Serve("", Tools(m)); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// shareOnTheBus is the bundle's half on the bus, every LookEvery for as long as the bundle runs.
|
||||
func shareOnTheBus(m *Machine) {
|
||||
node := os.Getenv("MESH_NODE")
|
||||
get := func(key string) (json.RawMessage, error) {
|
||||
e, err := stdio.State(ExportsState).Get(key)
|
||||
if err != nil || e == nil {
|
||||
return nil, err
|
||||
}
|
||||
return e.Value, nil
|
||||
}
|
||||
put := func(key string, value any) error {
|
||||
_, err := stdio.State(SharesState).Put(key, value)
|
||||
return err
|
||||
}
|
||||
last, lastSaid := "", ""
|
||||
for {
|
||||
said := m.Share(node, get, put, &last, time.Now())
|
||||
if s := fmt.Sprint(said); len(said) > 0 && s != lastSaid {
|
||||
fmt.Fprintln(os.Stderr, "mounts:", s)
|
||||
lastSaid = s
|
||||
}
|
||||
time.Sleep(LookEvery)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type manifest struct {
|
||||
Tools []string `json:"tools"`
|
||||
Requires []string `json:"requires"`
|
||||
Reads []string `json:"reads"`
|
||||
Claims []map[string]any `json:"claims"`
|
||||
State []map[string]any `json:"state"`
|
||||
Settings map[string]map[string]any `json:"settings"`
|
||||
OwnSecrets map[string]map[string]string `json:"own-secrets"`
|
||||
Resources []map[string]any `json:"resources"`
|
||||
}
|
||||
|
||||
func readManifest(t *testing.T) (manifest, string) {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile("../../module.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m manifest
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return m, string(raw)
|
||||
}
|
||||
|
||||
// The tools the bundle serves are the manifest's tools and the seat's verbs, each saying whether it reads
|
||||
// or acts; the module requires the share, asks the server's seat and holds node-mounts.
|
||||
func TestTheManifestNamesWhatTheBundleServesAndWhatItNeeds(t *testing.T) {
|
||||
m, raw := readManifest(t)
|
||||
var tools, verbs []string
|
||||
for _, tl := range Tools(newSystem(t).machine()) {
|
||||
if !strings.HasSuffix(tl.Description, "(r)") && !strings.HasSuffix(tl.Description, "(a)") {
|
||||
t.Errorf("%s does not say whether it reads (r) or acts (a)", tl.Name)
|
||||
}
|
||||
if v, ok := strings.CutPrefix(tl.Name, Seat+"."); ok {
|
||||
verbs = append(verbs, v)
|
||||
} else {
|
||||
tools = append(tools, tl.Name)
|
||||
}
|
||||
}
|
||||
sort.Strings(tools)
|
||||
sort.Strings(m.Tools)
|
||||
if strings.Join(tools, ",") != strings.Join(m.Tools, ",") {
|
||||
t.Errorf("served %v, listed %v", tools, m.Tools)
|
||||
}
|
||||
if len(m.Claims) != 1 || m.Claims[0]["name"] != Seat || m.Claims[0]["scope"] != "node" {
|
||||
t.Fatalf("claims %v", m.Claims)
|
||||
}
|
||||
var serves []string
|
||||
for _, v := range m.Claims[0]["serves"].([]any) {
|
||||
serves = append(serves, v.(string))
|
||||
}
|
||||
sort.Strings(serves)
|
||||
sort.Strings(verbs)
|
||||
if strings.Join(serves, ",") != strings.Join(verbs, ",") || strings.Join(verbs, ",") != "adopt,list,mount,test,unmount" {
|
||||
t.Errorf("the seat serves %v, the bundle %v", serves, verbs)
|
||||
}
|
||||
if strings.Join(m.Requires, ",") != "nfs-share" {
|
||||
t.Errorf("requires %v", m.Requires)
|
||||
}
|
||||
if strings.Join(m.Reads, ",") != ExportsState || strings.Contains(raw, `"invokes"`) {
|
||||
t.Errorf("the module reads nfs-server's exports from the bus, and calls nothing: reads %v", m.Reads)
|
||||
}
|
||||
if len(m.State) != 1 || m.State[0]["name"] != SharesState || m.State[0]["per-machine"] != true {
|
||||
t.Errorf("state %v", m.State)
|
||||
}
|
||||
sec := m.OwnSecrets["smb-credentials"]
|
||||
if sec["issued-by"] != "outside" || sec["taken"] != "at-start" || !strings.HasPrefix(sec["path"], "${dir:state}/") {
|
||||
t.Errorf("the SMB logins are an outside party's, given with secret accept: %v", sec)
|
||||
}
|
||||
for _, key := range []string{"shares", "sources"} {
|
||||
s := m.Settings[key]
|
||||
if s["kind"] != "preference" || s["default"] != None || s["why"] == "" || !strings.Contains(raw, "${setting:"+key+"}") {
|
||||
t.Errorf("setting %s: %v", key, s)
|
||||
}
|
||||
}
|
||||
// A machine's or a person's name, as a word: "novox/hq" names the record, not the machine.
|
||||
if hit := regexp.MustCompile(`\b(g14|novox|ace|shanks|jochen|recalbox)\b|/home/|/storage`).FindString(strings.ReplaceAll(raw, "novox/hq", "")); hit != "" {
|
||||
t.Errorf("the manifest says %q", hit)
|
||||
}
|
||||
if strings.Contains(raw, "/etc/fstab") {
|
||||
t.Error("the manifest places nothing in /etc/fstab: the operator's lines are taken over only by adopt")
|
||||
}
|
||||
}
|
||||
|
||||
// Every long-running resource says how it is healthy (ADR 0240): apply by its unit, watch by mounts_health,
|
||||
// both as root; the config file the process reads is the one the manifest places.
|
||||
func TestTheProcessesAreRootAndJudgedByTheirUnitAndTheHealthTool(t *testing.T) {
|
||||
m, raw := readManifest(t)
|
||||
byID := map[string]map[string]any{}
|
||||
for _, r := range m.Resources {
|
||||
byID[r["id"].(string)] = r
|
||||
if r["type"] == "process" || r["type"] == "service" || r["type"] == "container" {
|
||||
if r["health"] == nil {
|
||||
t.Errorf("%s runs and does not say how it is healthy", r["id"])
|
||||
}
|
||||
}
|
||||
}
|
||||
apply, watch := byID["apply"], byID["watch"]
|
||||
if h, _ := apply["health"].(map[string]any); apply["user"] != nil || h["kind"] != "unit" {
|
||||
t.Errorf("apply runs as root and is judged by its unit: %v", apply)
|
||||
}
|
||||
if h, _ := watch["health"].(map[string]any); watch["user"] != nil || h["kind"] != "tool" || h["tool"] != "mounts_health" {
|
||||
t.Errorf("watch runs as root and is judged by mounts_health: %v", watch)
|
||||
}
|
||||
for id, mode := range map[string]string{"apply": "apply", "watch": "watch"} {
|
||||
if run, _ := byID[id]["run"].([]any); len(run) != 2 || run[1] != mode {
|
||||
t.Errorf("%s runs the bundle's %s: %v", id, mode, byID[id]["run"])
|
||||
}
|
||||
}
|
||||
if byID["config"]["path"] != ConfigFile {
|
||||
t.Errorf("the config file is %v, the code reads %s", byID["config"]["path"], ConfigFile)
|
||||
}
|
||||
content, _ := byID["config"]["content"].(string)
|
||||
cfg := ParseConfig(content)
|
||||
if len(cfg.Problems) != 0 && !strings.Contains(strings.Join(cfg.Problems, " "), "${") {
|
||||
t.Errorf("the config's template reads: %v", cfg.Problems)
|
||||
}
|
||||
for _, want := range []string{"server=${bound:nfs-share:from}", "at=${bound:nfs-share:at}", "secret=${dir:state}/smb-credentials.secret"} {
|
||||
if !strings.Contains(content, want) {
|
||||
t.Errorf("the config lacks %s", want)
|
||||
}
|
||||
}
|
||||
if byID["bus-dir"]["path"] != BusDir || byID["bus-dir"]["owner"] != "${machine:account}" {
|
||||
t.Errorf("the bundle, as the operator account, writes the bus's answer: %v", byID["bus-dir"])
|
||||
}
|
||||
if strings.Contains(raw, "password") {
|
||||
t.Error("the manifest holds no password")
|
||||
}
|
||||
}
|
||||
@@ -6,9 +6,10 @@ import (
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
// Tools is the module's tools over one machine. Every one only reads.
|
||||
// Tools is the module's tools over one machine: the two that read what the machine has, the seat's verbs
|
||||
// and the health check (verbs.go).
|
||||
func Tools(m *Machine) []stdio.Tool {
|
||||
return []stdio.Tool{
|
||||
return append([]stdio.Tool{
|
||||
{
|
||||
Name: "mounts_list",
|
||||
Description: "This machine's mounts as fstab and the service manager know them, joined by mount point: each /etc/fstab line " +
|
||||
@@ -36,7 +37,7 @@ func Tools(m *Machine) []stdio.Tool {
|
||||
return m.Exports(), nil
|
||||
},
|
||||
},
|
||||
}
|
||||
}, Verbs(m)...)
|
||||
}
|
||||
|
||||
func text(args map[string]any, key string) string {
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
package main
|
||||
|
||||
// The unit pair for one share (novox/hq ADR 0263 rule 4, to-be 52 §2): a `.mount` that is never enabled
|
||||
// and an `.automount` that is. Nothing is mounted at boot and nothing can fail a boot: the mount is made
|
||||
// at first use and released after ten idle minutes, and a mount point is always an autofs mount, so
|
||||
// nothing writes into the empty directory under it.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Marker opens every unit file this module writes. A unit file without it is not the module's, and the
|
||||
// module never writes over, stops or removes one.
|
||||
const Marker = "# Written by the mesh (module mounts, novox/hq ADR 0263)."
|
||||
|
||||
// Timing, in seconds.
|
||||
const (
|
||||
IdleSeconds = 600 // released after ten idle minutes
|
||||
MountSeconds = 30 // a share of a node
|
||||
OccasionalSeconds = 10 // a source that comes and goes
|
||||
)
|
||||
|
||||
// EscapePath is `systemd-escape --path`: the name systemd gives the units for a mount point.
|
||||
func EscapePath(p string) string {
|
||||
p = strings.Trim(p, "/")
|
||||
if p == "" {
|
||||
return "-"
|
||||
}
|
||||
var b strings.Builder
|
||||
for i := 0; i < len(p); i++ {
|
||||
c := p[i]
|
||||
switch {
|
||||
case c == '/':
|
||||
b.WriteByte('-')
|
||||
case c == '.' && i == 0:
|
||||
fmt.Fprintf(&b, `\x%02x`, c)
|
||||
case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9', c == ':', c == '_', c == '.':
|
||||
b.WriteByte(c)
|
||||
default:
|
||||
fmt.Fprintf(&b, `\x%02x`, c)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// Pair is one share's two units, as written.
|
||||
type Pair struct {
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"` // nfs, smb or disk
|
||||
Occasional bool `json:"occasional"`
|
||||
Mountpoint string `json:"mountpoint"`
|
||||
What string `json:"what"`
|
||||
// Probe is what is tried to tell whether the source is there: host:port, or a device path.
|
||||
Probe string `json:"probe"`
|
||||
MountUnit string `json:"mount_unit"`
|
||||
AutoUnit string `json:"automount_unit"`
|
||||
Mount string `json:"-"`
|
||||
Automount string `json:"-"`
|
||||
// Credentials is the path of the source's credentials file (SMB), written beside the units.
|
||||
Credentials string `json:"credentials,omitempty"`
|
||||
}
|
||||
|
||||
// ServerShare is one share as the sharing machine says it exports it (its `node-nfs-server.exports`).
|
||||
type ServerShare struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
Access string `json:"access"`
|
||||
UID int `json:"uid"`
|
||||
GID int `json:"gid"`
|
||||
Clients string `json:"clients,omitempty"`
|
||||
Live bool `json:"live"`
|
||||
}
|
||||
|
||||
func header(name, kind string) string {
|
||||
return Marker + "\n# The " + kind + " " + name + ". Replaced whenever the module's settings or the sharing machine's\n" +
|
||||
"# answer change, and removed when nothing asks for it; edit nothing here: change the module's settings.\n"
|
||||
}
|
||||
|
||||
// NFSPair is the pair for a share of a node: NFS version 4.2 from the sharing machine's address on the
|
||||
// private network. Read-write is mounted hard, so a write is never dropped while the server is away;
|
||||
// read-only, soft, so a reader gets an error rather than waiting.
|
||||
func NFSPair(w Wanted, at string, e ServerShare) Pair {
|
||||
ro := w.ReadOnly || e.Access == "ro"
|
||||
opts := "vers=4.2,hard,timeo=150,retrans=3,_netdev"
|
||||
if ro {
|
||||
opts = "ro,vers=4.2,soft,timeo=150,retrans=3,_netdev"
|
||||
}
|
||||
p := Pair{Name: w.Name, Kind: "nfs", Mountpoint: w.Mountpoint, What: at + ":" + e.Path, Probe: hostPort(at, 2049)}
|
||||
p.render("share", "nfs4", opts, MountSeconds, "remote-fs.target", "")
|
||||
return p
|
||||
}
|
||||
|
||||
// SMBPair is the pair for an occasional SMB source. Its login is in a file only root reads.
|
||||
func SMBPair(s Source, credentials string, uid, gid int) Pair {
|
||||
opts := "credentials=" + credentials + ",iocharset=utf8,_netdev,nofail"
|
||||
if uid >= 0 {
|
||||
opts += ",uid=" + strconv.Itoa(uid) + ",gid=" + strconv.Itoa(gid)
|
||||
}
|
||||
if s.ReadOnly {
|
||||
opts = "ro," + opts
|
||||
}
|
||||
p := Pair{Name: s.Name, Kind: "smb", Occasional: true, Mountpoint: s.Mountpoint,
|
||||
What: "//" + s.Host + "/" + s.Share, Probe: hostPort(s.Host, 445), Credentials: credentials}
|
||||
p.render("source", "cifs", opts, OccasionalSeconds, "remote-fs.target", "")
|
||||
return p
|
||||
}
|
||||
|
||||
// DiskPair is the pair for a disk that is plugged in now and then: bound to its device, so the mount
|
||||
// goes when the disk does.
|
||||
func DiskPair(s Source) Pair {
|
||||
dev := "/dev/disk/by-uuid/" + s.UUID
|
||||
opts := "nofail"
|
||||
if s.ReadOnly {
|
||||
opts = "ro,nofail"
|
||||
}
|
||||
p := Pair{Name: s.Name, Kind: "disk", Occasional: true, Mountpoint: s.Mountpoint, What: dev, Probe: dev}
|
||||
p.render("source", "", opts, OccasionalSeconds, "local-fs.target", EscapePath(dev)+".device")
|
||||
return p
|
||||
}
|
||||
|
||||
func hostPort(host string, port int) string {
|
||||
if strings.Contains(host, ":") && !strings.HasPrefix(host, "[") {
|
||||
return "[" + host + "]:" + strconv.Itoa(port)
|
||||
}
|
||||
return host + ":" + strconv.Itoa(port)
|
||||
}
|
||||
|
||||
func (p *Pair) render(kind, fstype, opts string, timeout int, target, device string) {
|
||||
base := EscapePath(p.Mountpoint)
|
||||
p.MountUnit, p.AutoUnit = base+".mount", base+".automount"
|
||||
var m strings.Builder
|
||||
m.WriteString(header(p.Name, kind))
|
||||
m.WriteString("[Unit]\nDescription=" + p.Name + " (" + p.What + "), mounted by the mesh\n")
|
||||
if device != "" {
|
||||
m.WriteString("BindsTo=" + device + "\nAfter=" + device + "\n")
|
||||
} else {
|
||||
m.WriteString("Wants=network-online.target\nAfter=network-online.target\n")
|
||||
}
|
||||
m.WriteString("\n[Mount]\nWhat=" + p.What + "\nWhere=" + p.Mountpoint + "\n")
|
||||
if fstype != "" {
|
||||
m.WriteString("Type=" + fstype + "\n")
|
||||
}
|
||||
m.WriteString("Options=" + opts + "\nTimeoutSec=" + strconv.Itoa(timeout) + "\n")
|
||||
p.Mount = m.String()
|
||||
|
||||
var a strings.Builder
|
||||
a.WriteString(header(p.Name, kind))
|
||||
// The automount is armed whether or not the source is there; only the mount waits on it, so an
|
||||
// absent disk leaves an armed automount, never a failed unit.
|
||||
a.WriteString("[Unit]\nDescription=" + p.Name + ", mounted by the mesh at first use\n")
|
||||
a.WriteString("\n[Automount]\nWhere=" + p.Mountpoint + "\nTimeoutIdleSec=" + strconv.Itoa(IdleSeconds) + "\n")
|
||||
a.WriteString("\n[Install]\nWantedBy=" + target + "\n")
|
||||
p.Automount = a.String()
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
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), since when and why. 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
|
||||
}
|
||||
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): 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 missing login, 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. (r)",
|
||||
Run: func(map[string]any) (any, error) { return m.Health(time.Now()), nil },
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,560 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The secret every test that has one uses; it must never appear in anything the module says.
|
||||
const testPassword = "s3cr3t-Pa55"
|
||||
|
||||
// fakeSystem is a machine under a temporary root with a recorded service manager.
|
||||
type fakeSystem struct {
|
||||
t *testing.T
|
||||
root string
|
||||
calls []string
|
||||
// units are the service manager's answers: ActiveState and UnitFileState by unit.
|
||||
units map[string]map[string]string
|
||||
// reachable are the host:port pairs that answer.
|
||||
reachable map[string]bool
|
||||
}
|
||||
|
||||
func newSystem(t *testing.T) *fakeSystem {
|
||||
f := &fakeSystem{t: t, root: t.TempDir(), units: map[string]map[string]string{}, reachable: map[string]bool{}}
|
||||
for _, d := range []string{UnitDir, RunDir, BusDir, "/etc/mesh-mounts", "/proc/self", "/var/lib/mesh/mounts"} {
|
||||
if err := os.MkdirAll(filepath.Join(f.root, d), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
f.write("/proc/self/mountinfo", "")
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *fakeSystem) write(p, content string) {
|
||||
f.t.Helper()
|
||||
full := filepath.Join(f.root, p)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeSystem) read(p string) string {
|
||||
b, _ := os.ReadFile(filepath.Join(f.root, p))
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func (f *fakeSystem) run(_ context.Context, name string, args ...string) (string, error) {
|
||||
f.calls = append(f.calls, name+" "+strings.Join(args, " "))
|
||||
if name != "systemctl" || len(args) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
switch args[0] {
|
||||
case "show":
|
||||
if len(args) >= 5 && args[3] == "--value" {
|
||||
return f.units[args[4]]["ActiveState"] + "\n", nil
|
||||
}
|
||||
var blocks []string
|
||||
for _, u := range args {
|
||||
if !strings.HasSuffix(u, ".mount") && !strings.HasSuffix(u, ".automount") {
|
||||
continue
|
||||
}
|
||||
s := f.units[u]
|
||||
blocks = append(blocks, "Id="+u+"\nActiveState="+orDefault(s["ActiveState"], "inactive")+"\nSubState=dead\nResult="+
|
||||
orDefault(s["Result"], "success")+"\nUnitFileState="+orDefault(s["UnitFileState"], "disabled"))
|
||||
}
|
||||
return strings.Join(blocks, "\n\n") + "\n", nil
|
||||
case "enable":
|
||||
u := args[len(args)-1]
|
||||
f.units[u] = map[string]string{"ActiveState": "active", "UnitFileState": "enabled"}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (f *fakeSystem) machine() *Machine {
|
||||
return &Machine{Root: f.root, Run: f.run, Escalate: f.run, LookPath: func(string) bool { return true }}
|
||||
}
|
||||
|
||||
func (f *fakeSystem) applier(now time.Time) *Applier {
|
||||
return &Applier{M: f.machine(), Act: true, Judge: true,
|
||||
Dial: func(addr string, _ time.Duration) error {
|
||||
if f.reachable[addr] {
|
||||
return nil
|
||||
}
|
||||
return errors.New("dial tcp " + addr + ": connect: no route to host")
|
||||
},
|
||||
Now: func() time.Time { return now },
|
||||
Lookup: func(string) (int, int, error) { return 1000, 1000, nil },
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeSystem) called(prefix string) bool {
|
||||
for _, c := range f.calls {
|
||||
if strings.HasPrefix(c, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// everything is every file under the root, for a search that a secret appears nowhere it should not.
|
||||
func (f *fakeSystem) everything(except ...string) string {
|
||||
var b strings.Builder
|
||||
filepath.Walk(f.root, func(p string, info os.FileInfo, err error) error {
|
||||
if err != nil || info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
for _, e := range except {
|
||||
if strings.HasSuffix(p, e) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
raw, _ := os.ReadFile(p)
|
||||
b.Write(raw)
|
||||
return nil
|
||||
})
|
||||
return b.String()
|
||||
}
|
||||
|
||||
const config = "shares=library=/srv/library downloads=/srv/downloads:ro\n" +
|
||||
"sources=games=smb://192.0.2.76/Share@/mnt/games usb=disk:1234-ABCD@/mnt/usb\n" +
|
||||
"server=homeserver\nat=192.0.2.10\naccount=operator\n" +
|
||||
"secret=/var/lib/mesh/mounts/smb-credentials.secret\nstate=/var/lib/mesh/mounts\n"
|
||||
|
||||
func exportsAnswer(t *testing.T, shares ...ServerShare) string {
|
||||
raw, err := json.Marshal(ExportsAnswer{Server: "homeserver", At: "192.0.2.10", Shares: shares})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
var (
|
||||
library = ServerShare{Name: "library", Path: "/tank/media", Access: "rw", UID: 1001, GID: 2000, Live: true}
|
||||
downloads = ServerShare{Name: "downloads", Path: "/tank/downloads", Access: "rw", UID: 1001, GID: 2000, Live: true}
|
||||
)
|
||||
|
||||
func TestTheSettingsAreReadAndWhatCannotBeIsSaidPlainly(t *testing.T) {
|
||||
shares, problems := ParseShares("library=/srv/library downloads=/srv/downloads:ro,bad Upper=/x other=relative library=/srv/again root=/")
|
||||
if len(shares) != 2 || shares[0] != (Wanted{"library", "/srv/library", false}) || shares[1] != (Wanted{"downloads", "/srv/downloads", true}) {
|
||||
t.Fatalf("shares %+v", shares)
|
||||
}
|
||||
want := []string{`"bad" is not name=mountpoint`, `"Upper" is not lower-case`, `"relative" is not an absolute path`, "named twice", `"/" is the system's own directory`}
|
||||
for i, w := range want {
|
||||
if i >= len(problems) || !strings.Contains(problems[i], w) {
|
||||
t.Errorf("problem %d: want %q in %q", i, w, problems)
|
||||
}
|
||||
}
|
||||
if s, p := ParseShares("none"); len(s) != 0 || len(p) != 0 {
|
||||
t.Errorf("none is nothing: %v %v", s, p)
|
||||
}
|
||||
sources, problems := ParseSources("games=smb://192.0.2.76/Share@/mnt/games:ro usb=disk:1234-ABCD@/mnt/usb ftp=ftp://x/y@/mnt/x nomount=smb://h/s")
|
||||
if len(sources) != 2 || sources[0].Kind != "smb" || sources[0].Host != "192.0.2.76" || sources[0].Share != "Share" || !sources[0].ReadOnly ||
|
||||
sources[1].Kind != "disk" || sources[1].UUID != "1234-ABCD" {
|
||||
t.Fatalf("sources %+v", sources)
|
||||
}
|
||||
if len(problems) != 2 || !strings.Contains(problems[0], "neither smb://") || !strings.Contains(problems[1], "names no mount point") {
|
||||
t.Errorf("problems %q", problems)
|
||||
}
|
||||
if err := CheckMountpoint("/etc/x"); err == nil {
|
||||
t.Error("a mount point under /etc is the system's")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAMountPointIsNamedAsSystemdEscapeNamesIt(t *testing.T) {
|
||||
for in, want := range map[string]string{
|
||||
"/srv/library": "srv-library",
|
||||
"/storage/media": "storage-media",
|
||||
"/mnt/my-disk": `mnt-my\x2ddisk`,
|
||||
"/mnt/.hidden": `mnt-.hidden`,
|
||||
"/.x": `\x2ex`,
|
||||
"/srv/a b": `srv-a\x20b`,
|
||||
"/dev/disk/by-uuid/1234-ABCD": `dev-disk-by\x2duuid-1234\x2dABCD`,
|
||||
} {
|
||||
if got := EscapePath(in); got != want {
|
||||
t.Errorf("EscapePath(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestANodesShareIsMountedHardWhenWritableAndSoftWhenNot(t *testing.T) {
|
||||
rw := NFSPair(Wanted{Name: "library", Mountpoint: "/srv/library"}, "192.0.2.10", library)
|
||||
for _, want := range []string{"What=192.0.2.10:/tank/media", "Where=/srv/library", "Type=nfs4",
|
||||
"Options=vers=4.2,hard,timeo=150,retrans=3,_netdev", "TimeoutSec=30", "After=network-online.target"} {
|
||||
if !strings.Contains(rw.Mount, want) {
|
||||
t.Errorf("the read-write mount lacks %q:\n%s", want, rw.Mount)
|
||||
}
|
||||
}
|
||||
if !strings.HasPrefix(rw.Mount, Marker) || !strings.HasPrefix(rw.Automount, Marker) {
|
||||
t.Error("both units open with the module's marker")
|
||||
}
|
||||
if strings.Contains(rw.Mount, "[Install]") {
|
||||
t.Error("the mount is never enabled: only the automount is")
|
||||
}
|
||||
for _, want := range []string{"[Automount]\nWhere=/srv/library\nTimeoutIdleSec=600", "WantedBy=remote-fs.target"} {
|
||||
if !strings.Contains(rw.Automount, want) {
|
||||
t.Errorf("the automount lacks %q:\n%s", want, rw.Automount)
|
||||
}
|
||||
}
|
||||
if rw.MountUnit != "srv-library.mount" || rw.AutoUnit != "srv-library.automount" || rw.Probe != "192.0.2.10:2049" {
|
||||
t.Errorf("names %+v", rw)
|
||||
}
|
||||
ro := NFSPair(Wanted{Name: "downloads", Mountpoint: "/srv/downloads", ReadOnly: true}, "192.0.2.10", downloads)
|
||||
if !strings.Contains(ro.Mount, "Options=ro,vers=4.2,soft,") {
|
||||
t.Errorf("a read-only share is mounted ro and soft:\n%s", ro.Mount)
|
||||
}
|
||||
served := library
|
||||
served.Access = "ro"
|
||||
if p := NFSPair(Wanted{Name: "library", Mountpoint: "/srv/library"}, "192.0.2.10", served); !strings.Contains(p.Mount, "Options=ro,") {
|
||||
t.Error("a share the server exports read-only is mounted read-only")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnOccasionalSourceWaitsTenSecondsAndNeverFailsABoot(t *testing.T) {
|
||||
smb := SMBPair(Source{Name: "games", Kind: "smb", Host: "192.0.2.76", Share: "Share", Mountpoint: "/mnt/games"}, "/var/lib/mesh/mounts/smb-games.credentials", 1000, 1000)
|
||||
for _, want := range []string{"What=//192.0.2.76/Share", "Type=cifs", "credentials=/var/lib/mesh/mounts/smb-games.credentials", "nofail", "uid=1000,gid=1000", "TimeoutSec=10"} {
|
||||
if !strings.Contains(smb.Mount, want) {
|
||||
t.Errorf("the SMB mount lacks %q:\n%s", want, smb.Mount)
|
||||
}
|
||||
}
|
||||
if !smb.Occasional || smb.Probe != "192.0.2.76:445" || strings.Contains(smb.Mount, "password") {
|
||||
t.Errorf("smb %+v", smb)
|
||||
}
|
||||
disk := DiskPair(Source{Name: "usb", Kind: "disk", UUID: "1234-ABCD", Mountpoint: "/mnt/usb"})
|
||||
dev := `dev-disk-by\x2duuid-1234\x2dABCD.device`
|
||||
if !strings.Contains(disk.Mount, "BindsTo="+dev) || !strings.Contains(disk.Mount, "What=/dev/disk/by-uuid/1234-ABCD") {
|
||||
t.Errorf("the disk's mount goes with the disk:\n%s", disk.Mount)
|
||||
}
|
||||
if strings.Contains(disk.Automount, "BindsTo") || !strings.Contains(disk.Automount, "WantedBy=local-fs.target") {
|
||||
t.Errorf("the disk's automount stays armed when the disk is away:\n%s", disk.Automount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestALookWritesArmsAndRemovesOnlyTheModulesOwnUnits(t *testing.T) {
|
||||
f := newSystem(t)
|
||||
f.write(ConfigFile, config)
|
||||
f.write(ExportsFile, exportsAnswer(t, library, downloads))
|
||||
f.write("/var/lib/mesh/mounts/smb-credentials.secret", "[games]\nusername=root\npassword="+testPassword+"\n")
|
||||
// The operator's own unit for the downloads' mount point: never written over.
|
||||
f.write(filepath.Join(UnitDir, "srv-downloads.mount"), "[Mount]\nWhat=elsewhere\n")
|
||||
// A pair of the module's whose mount point nothing names any more.
|
||||
f.write(filepath.Join(UnitDir, "srv-old.mount"), Marker+"\n[Mount]\n")
|
||||
f.write(filepath.Join(UnitDir, "srv-old.automount"), Marker+"\n[Automount]\n")
|
||||
f.reachable["192.0.2.10:2049"] = true
|
||||
now := time.Date(2026, 10, 8, 19, 21, 0, 0, time.UTC)
|
||||
|
||||
st := f.applier(now).Look(context.Background())
|
||||
|
||||
if got := f.read(filepath.Join(UnitDir, "srv-library.mount")); !strings.Contains(got, "What=192.0.2.10:/tank/media") {
|
||||
t.Errorf("the library's mount: %q", got)
|
||||
}
|
||||
if got := f.read(filepath.Join(UnitDir, "srv-downloads.mount")); got != "[Mount]\nWhat=elsewhere\n" {
|
||||
t.Errorf("a unit file that is not the module's is left byte for byte: %q", got)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(f.root, UnitDir, "srv-old.mount")); !os.IsNotExist(err) {
|
||||
t.Error("the module's pair nothing names any more is removed")
|
||||
}
|
||||
for _, c := range []string{"systemctl disable --now srv-old.automount", "systemctl stop srv-old.mount", "systemctl daemon-reload",
|
||||
"systemctl enable --now srv-library.automount", "systemctl enable --now mnt-games.automount", "systemctl enable --now mnt-usb.automount"} {
|
||||
if !f.called(c) {
|
||||
t.Errorf("not called: %s (calls %q)", c, f.calls)
|
||||
}
|
||||
}
|
||||
if f.called("systemctl enable --now srv-downloads.automount") {
|
||||
t.Error("the refused share is not armed")
|
||||
}
|
||||
if info, err := os.Stat(filepath.Join(f.root, "/srv/library")); err != nil || !info.IsDir() {
|
||||
t.Error("a missing mount point is made")
|
||||
}
|
||||
creds := filepath.Join(f.root, "/var/lib/mesh/mounts/smb-games.credentials")
|
||||
if info, err := os.Stat(creds); err != nil || info.Mode().Perm() != 0o600 {
|
||||
t.Fatalf("the source's login is a file only root reads: %v", err)
|
||||
}
|
||||
if b, _ := os.ReadFile(creds); string(b) != "username=root\npassword="+testPassword+"\n" {
|
||||
t.Errorf("credentials file %q", b)
|
||||
}
|
||||
|
||||
states := map[string]ShareStatus{}
|
||||
for _, s := range st.Shares {
|
||||
states[s.Name] = s
|
||||
}
|
||||
if states["library"].State != StateArmed || states["games"].State != StateAbsent || states["usb"].State != StateAbsent {
|
||||
t.Errorf("states %+v", states)
|
||||
}
|
||||
if d := states["downloads"]; d.State != StateFailed || !strings.Contains(d.Why, "not the module's") {
|
||||
t.Errorf("downloads %+v", d)
|
||||
}
|
||||
if !strings.Contains(states["games"].Why, "not present") || states["games"].Since != now.Format(time.RFC3339) {
|
||||
t.Errorf("games %+v", states["games"])
|
||||
}
|
||||
// The password is in the secret and in the credentials file, and nowhere else.
|
||||
if strings.Contains(f.everything("smb-credentials.secret", "smb-games.credentials"), testPassword) {
|
||||
t.Error("the password leaked into a file the module wrote")
|
||||
}
|
||||
raw, _ := json.Marshal(f.machine().Health(now))
|
||||
if strings.Contains(string(raw), testPassword) {
|
||||
t.Error("the password leaked into the health")
|
||||
}
|
||||
|
||||
// A second look a minute later changes nothing and keeps since.
|
||||
f.calls = nil
|
||||
later := now.Add(time.Minute)
|
||||
st = f.applier(later).Look(context.Background())
|
||||
if f.called("systemctl daemon-reload") || f.called("systemctl enable") {
|
||||
t.Errorf("a look with nothing to change changes nothing: %q", f.calls)
|
||||
}
|
||||
for _, s := range st.Shares {
|
||||
if s.Since != now.Format(time.RFC3339) {
|
||||
t.Errorf("%s since %s, want the first look's", s.Name, s.Since)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestASilentServerRemovesNoMount(t *testing.T) {
|
||||
f := newSystem(t)
|
||||
f.write(ConfigFile, "shares=library=/srv/library\nserver=homeserver\nat=192.0.2.10\n")
|
||||
f.write(filepath.Join(UnitDir, "srv-library.mount"), Marker+"\nkept\n")
|
||||
f.write(filepath.Join(UnitDir, "srv-library.automount"), Marker+"\nkept\n")
|
||||
st := f.applier(time.Now()).Look(context.Background())
|
||||
if f.read(filepath.Join(UnitDir, "srv-library.mount")) != Marker+"\nkept\n" {
|
||||
t.Error("with no answer from the server the pair stays as it is")
|
||||
}
|
||||
if len(st.Shares) != 1 || st.Shares[0].State != StateUnreachable || !strings.Contains(st.Shares[0].Why, "has not been read from the bus yet") {
|
||||
t.Errorf("shares %+v", st.Shares)
|
||||
}
|
||||
// And a missing config file writes and removes nothing.
|
||||
g := newSystem(t)
|
||||
g.write(filepath.Join(UnitDir, "srv-x.mount"), Marker+"\n")
|
||||
st = g.applier(time.Now()).Look(context.Background())
|
||||
if _, err := os.Stat(filepath.Join(g.root, UnitDir, "srv-x.mount")); err != nil || len(st.Problems) == 0 {
|
||||
t.Errorf("without its config the process removes nothing and says why: %v %v", err, st.Problems)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAMissingLoginIsSaidWithItsRemedyAndTheSourceIsNotMounted(t *testing.T) {
|
||||
f := newSystem(t)
|
||||
f.write(ConfigFile, "sources=games=smb://192.0.2.76/Share@/mnt/games\nsecret=/var/lib/mesh/mounts/smb-credentials.secret\nstate=/var/lib/mesh/mounts\n")
|
||||
f.write("/var/lib/mesh/mounts/smb-credentials.secret", "[other]\nusername=x\npassword="+testPassword+"\n")
|
||||
now := time.Now()
|
||||
st := f.applier(now).Look(context.Background())
|
||||
if len(st.Shares) != 1 || st.Shares[0].State != StateFailed ||
|
||||
!strings.Contains(st.Shares[0].Why, "secret accept <node> mounts smb-credentials --from <file>") {
|
||||
t.Fatalf("shares %+v", st.Shares)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(f.root, UnitDir, "mnt-games.mount")); !os.IsNotExist(err) {
|
||||
t.Error("a source without its login gets no units")
|
||||
}
|
||||
h := f.machine().Health(now)
|
||||
if h.Healthy || !strings.Contains(h.Why, "login is not given") || strings.Contains(h.Why, testPassword) {
|
||||
t.Errorf("health %+v", h)
|
||||
}
|
||||
creds, problems := ParseSMBCredentials("[a]\nusername=u\npassword=" + testPassword + "\nbogus=1\n[b]\npassword=p\n")
|
||||
if creds["a"].Password != testPassword || len(problems) != 2 || strings.Contains(strings.Join(problems, " "), testPassword) {
|
||||
t.Errorf("creds %v problems %q", len(creds), problems)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheHealthGivesAShareFiveMinutesAndAnAbsentSourceNone(t *testing.T) {
|
||||
now := time.Date(2026, 10, 8, 19, 30, 0, 0, time.Local)
|
||||
at := func(d time.Duration) string { return now.Add(-d).Format(time.RFC3339) }
|
||||
st := Status{Looked: at(10 * time.Second), Server: "homeserver", Shares: []ShareStatus{
|
||||
{Name: "library", Kind: "nfs", Mountpoint: "/srv/library", State: StateUnreachable, Since: at(4 * time.Minute), Why: "192.0.2.10:2049 does not answer"},
|
||||
{Name: "games", Kind: "smb", Occasional: true, Mountpoint: "/mnt/games", State: StateAbsent, Since: at(3 * time.Hour)},
|
||||
}}
|
||||
if h := Judge(st, nil, now); !h.Healthy || !strings.Contains(strings.Join(h.Said, " "), "games at /mnt/games: not present since 16:30") {
|
||||
t.Errorf("under five minutes, and an absent source, are healthy: %+v", h)
|
||||
}
|
||||
st.Shares[0].Since = at(9 * time.Minute)
|
||||
h := Judge(st, nil, now)
|
||||
if h.Healthy || !strings.Contains(h.Why, "The share library of homeserver is not reachable from this machine since 19:21") {
|
||||
t.Errorf("five minutes away raises: %+v", h)
|
||||
}
|
||||
st.Shares = []ShareStatus{{Name: "library", Kind: "nfs", Mountpoint: "/srv/library", State: StateFailed, Since: at(time.Minute),
|
||||
Why: "its automount srv-library.automount is not armed (inactive)"}}
|
||||
if h := Judge(st, nil, now); h.Healthy {
|
||||
t.Error("an automount not armed is raised at once")
|
||||
}
|
||||
st.Looked = at(3 * time.Minute)
|
||||
st.Shares = nil
|
||||
if h := Judge(st, nil, now); h.Healthy || !strings.Contains(h.Why, "last looked") {
|
||||
t.Errorf("a stale look is raised: %+v", h)
|
||||
}
|
||||
if h := Judge(Status{}, errors.New("not present"), now); h.Healthy {
|
||||
t.Error("no status is unhealthy")
|
||||
}
|
||||
if h := Judge(Status{Looked: at(time.Second)}, nil, now); !h.Healthy || h.Why != "This machine is asked to mount nothing." {
|
||||
t.Errorf("nothing asked: %+v", h)
|
||||
}
|
||||
}
|
||||
|
||||
// The workstation's two lines as they were found (research 038), in documentation addresses.
|
||||
const operatorsFstab = "UUID=ad8b / ext4 rw,relatime 0 1\n" +
|
||||
"192.0.2.10:/tank/media /srv/library nfs defaults,_netdev 0 0\n" +
|
||||
"//192.0.2.76/Share /mnt/games cifs username=root,password=" + testPassword + ",iocharset=utf8,sec=ntlmssp 0 0\n"
|
||||
|
||||
func TestAnFstabLineIsTakenOverOnlyOnceItsReplacementIsArmed(t *testing.T) {
|
||||
f := newSystem(t)
|
||||
f.write("/etc/fstab", operatorsFstab)
|
||||
m := f.machine()
|
||||
now := time.Date(2026, 10, 8, 20, 0, 0, 0, time.UTC)
|
||||
ctx := context.Background()
|
||||
|
||||
a, err := m.Adopt(ctx, "/mnt/games", false, "", now)
|
||||
if err != nil || a.Done || !strings.HasPrefix(a.Said, "Would be refused now: the module has no unit pair") {
|
||||
t.Fatalf("no pair yet: %+v %v", a, err)
|
||||
}
|
||||
if strings.Contains(a.Was, testPassword) || !strings.Contains(a.Was, "password=(redacted)") {
|
||||
t.Errorf("the dry run says the line without its password: %q", a.Was)
|
||||
}
|
||||
if _, err := m.AdoptAsRoot(ctx, "/mnt/games", now); err == nil {
|
||||
t.Fatal("the root half refuses too")
|
||||
}
|
||||
if f.read("/etc/fstab") != operatorsFstab {
|
||||
t.Fatal("a refusal changes nothing")
|
||||
}
|
||||
|
||||
f.write(filepath.Join(UnitDir, "mnt-games.mount"), Marker+"\n")
|
||||
f.write(filepath.Join(UnitDir, "mnt-games.automount"), Marker+"\n")
|
||||
f.units["mnt-games.automount"] = map[string]string{"ActiveState": "inactive"}
|
||||
if _, err := m.AdoptAsRoot(ctx, "/mnt/games", now); err == nil || !strings.Contains(err.Error(), "not armed") {
|
||||
t.Fatalf("an automount not armed refuses: %v", err)
|
||||
}
|
||||
f.units["mnt-games.automount"] = map[string]string{"ActiveState": "active"}
|
||||
if a, err := m.Adopt(ctx, "/mnt/games", false, "", now); err != nil || !strings.HasPrefix(a.Said, "A dry run") || a.Line != 3 {
|
||||
t.Fatalf("dry run: %+v %v", a, err)
|
||||
}
|
||||
if _, err := m.Adopt(ctx, "/mnt/games", true, "", now); err == nil {
|
||||
t.Error("confirm without why is refused")
|
||||
}
|
||||
|
||||
a, err = m.AdoptAsRoot(ctx, "/mnt/games", now)
|
||||
if err != nil || !a.Done || a.Line != 3 || a.Copy != "/etc/fstab.mesh-adopted-20261008-200000" {
|
||||
t.Fatalf("adopt: %+v %v", a, err)
|
||||
}
|
||||
got := f.read("/etc/fstab")
|
||||
lines := strings.Split(got, "\n")
|
||||
if lines[0] != "UUID=ad8b / ext4 rw,relatime 0 1" || lines[1] != "192.0.2.10:/tank/media /srv/library nfs defaults,_netdev 0 0" {
|
||||
t.Errorf("every other line is kept byte for byte:\n%s", got)
|
||||
}
|
||||
if !strings.HasPrefix(lines[2], AdoptedPrefix+"2026-10-08: //192.0.2.76/Share") || strings.Contains(got, testPassword) {
|
||||
t.Errorf("the line is commented out with its password redacted:\n%s", got)
|
||||
}
|
||||
info, err := os.Stat(filepath.Join(f.root, a.Copy))
|
||||
if err != nil || info.Mode().Perm() != 0o600 || f.read(a.Copy) != operatorsFstab {
|
||||
t.Errorf("the file as it was is kept, root only: %v", err)
|
||||
}
|
||||
if !f.called("systemctl daemon-reload") {
|
||||
t.Error("the service manager reloads")
|
||||
}
|
||||
raw, _ := json.Marshal(a)
|
||||
if strings.Contains(string(raw), testPassword) {
|
||||
t.Error("the answer holds the password")
|
||||
}
|
||||
if _, err := m.AdoptAsRoot(ctx, "/mnt/games", now); err == nil || !strings.Contains(err.Error(), "no line for /mnt/games") {
|
||||
t.Errorf("a line taken over is not taken twice: %v", err)
|
||||
}
|
||||
if _, _, err := PlanAdoption("a /x nfs d 0 0\nb /x nfs d 0 0\n", "/x", now); err == nil || !strings.Contains(err.Error(), "more than one line") {
|
||||
t.Errorf("two lines for one mount point are left to a person: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAFailedAskKeepsTheLastAnswer(t *testing.T) {
|
||||
cfg := ParseConfig("shares=library=/srv/library\nserver=homeserver\nat=192.0.2.10\n")
|
||||
now := time.Now()
|
||||
var asked string
|
||||
ok := func(key string) (json.RawMessage, error) {
|
||||
asked = key
|
||||
return json.RawMessage(`{"shares":[{"name":"library","path":"/tank/media","access":"rw","uid":1001,"gid":2000,"live":true}]}`), nil
|
||||
}
|
||||
first := AskExports(cfg, ExportsAnswer{}, ok, now)
|
||||
if asked != "homeserver" || len(first.Shares) != 1 || first.Error != "" {
|
||||
t.Fatalf("first %+v asked %q", first, asked)
|
||||
}
|
||||
fail := func(string) (json.RawMessage, error) { return nil, errors.New("no responders") }
|
||||
second := AskExports(cfg, first, fail, now.Add(time.Minute))
|
||||
empty := AskExports(cfg, first, func(string) (json.RawMessage, error) { return nil, nil }, now)
|
||||
if !strings.Contains(empty.Error, "keeps nothing") || len(empty.Shares) != 1 {
|
||||
t.Errorf("a machine with nothing kept yet: %+v", empty)
|
||||
}
|
||||
if second.Error != "no responders" || len(second.Shares) != 1 || second.Answered != first.Answered {
|
||||
t.Errorf("second %+v", second)
|
||||
}
|
||||
if got := ParseConfig("at=homeserver.mesh.example\n").ServerNode(); got != "homeserver" {
|
||||
t.Errorf("without from, the first label of at: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheBundlePutsWhatTheProcessFoundOnTheBus(t *testing.T) {
|
||||
f := newSystem(t)
|
||||
f.write(ConfigFile, "shares=library=/srv/library\nserver=homeserver\nat=192.0.2.10\n")
|
||||
f.write(StatusFile, `{"looked":"2026-10-08T19:21:00Z","shares":[{"name":"library","state":"armed"}]}`)
|
||||
var put []string
|
||||
last := ""
|
||||
ask := func(string) (json.RawMessage, error) { return json.RawMessage(`{"shares":[]}`), nil }
|
||||
said := f.machine().Share("workstation", ask, func(key string, _ any) error { put = append(put, key); return nil }, &last, time.Now())
|
||||
if len(said) != 0 || len(put) != 1 || put[0] != "workstation" || !strings.Contains(f.read(ExportsFile), `"server": "homeserver"`) {
|
||||
t.Errorf("said %q put %q exports %q", said, put, f.read(ExportsFile))
|
||||
}
|
||||
f.machine().Share("workstation", ask, func(key string, _ any) error { put = append(put, key); return nil }, &last, time.Now())
|
||||
if len(put) != 1 {
|
||||
t.Error("an unchanged status is not put again")
|
||||
}
|
||||
}
|
||||
|
||||
// In production the acting process and the judging one are two: apply writes and arms and says nothing
|
||||
// of how the shares are; watch writes and arms nothing and says how they are.
|
||||
func TestApplyActsAndWatchJudgesAndNeitherDoesTheOthersWork(t *testing.T) {
|
||||
f := newSystem(t)
|
||||
f.write(ConfigFile, config)
|
||||
f.write(ExportsFile, exportsAnswer(t, library, downloads))
|
||||
f.write("/var/lib/mesh/mounts/smb-credentials.secret", "[other]\nusername=x\npassword="+testPassword+"\n")
|
||||
f.write(filepath.Join(UnitDir, "srv-downloads.mount"), "[Mount]\nWhat=elsewhere\n")
|
||||
f.reachable["192.0.2.10:2049"] = true
|
||||
now := time.Now()
|
||||
|
||||
apply := f.applier(now)
|
||||
apply.Judge = false
|
||||
apply.Look(context.Background())
|
||||
if _, err := os.Stat(filepath.Join(f.root, StatusFile)); !os.IsNotExist(err) {
|
||||
t.Error("apply leaves no status: that is watch's")
|
||||
}
|
||||
if !f.called("systemctl enable --now srv-library.automount") || f.read(filepath.Join(UnitDir, "srv-library.mount")) == "" {
|
||||
t.Fatal("apply writes and arms")
|
||||
}
|
||||
applied, err := readJSON[Applied](f.machine(), AppliedFile)
|
||||
if err != nil || len(applied.NotSet) != 2 {
|
||||
t.Fatalf("apply says what it could not set up: %+v %v", applied, err)
|
||||
}
|
||||
|
||||
f.calls = nil
|
||||
watch := f.applier(now)
|
||||
watch.Act = false
|
||||
f.write(filepath.Join(UnitDir, "srv-gone.mount"), Marker+"\n")
|
||||
st := watch.Look(context.Background())
|
||||
for _, c := range f.calls {
|
||||
if !strings.HasPrefix(c, "systemctl show") {
|
||||
t.Errorf("watch only reads the service manager: %s", c)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(f.root, UnitDir, "srv-gone.mount")); err != nil {
|
||||
t.Error("watch removes nothing")
|
||||
}
|
||||
states := map[string]string{}
|
||||
for _, s := range st.Shares {
|
||||
states[s.Name] = s.State
|
||||
}
|
||||
if states["library"] != StateArmed || states["downloads"] != StateFailed || states["games"] != StateFailed || states["usb"] != StateAbsent {
|
||||
t.Errorf("watch says how each is: %v", states)
|
||||
}
|
||||
if strings.Contains(f.everything("smb-credentials.secret"), testPassword) {
|
||||
t.Error("the password leaked")
|
||||
}
|
||||
}
|
||||
@@ -2,4 +2,4 @@ module mounts
|
||||
|
||||
go 1.22
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.6
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.7
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
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=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||
|
||||
+125
-1
@@ -1,9 +1,133 @@
|
||||
{
|
||||
"module": "mounts",
|
||||
"version": "1",
|
||||
"requires": [
|
||||
"nfs-share"
|
||||
],
|
||||
"reads": [
|
||||
"nfs-server.exports"
|
||||
],
|
||||
"capabilities": [
|
||||
"package-manager",
|
||||
"service-manager"
|
||||
],
|
||||
"claims": [
|
||||
{
|
||||
"name": "node-mounts",
|
||||
"scope": "node",
|
||||
"serves": [
|
||||
"list",
|
||||
"test",
|
||||
"mount",
|
||||
"unmount",
|
||||
"adopt"
|
||||
]
|
||||
}
|
||||
],
|
||||
"tools": [
|
||||
"mounts_list",
|
||||
"mounts_exports"
|
||||
"mounts_exports",
|
||||
"mounts_health"
|
||||
],
|
||||
"state": [
|
||||
{
|
||||
"name": "shares",
|
||||
"per-machine": true
|
||||
}
|
||||
],
|
||||
"settings": {
|
||||
"shares": {
|
||||
"kind": "preference",
|
||||
"default": "none",
|
||||
"why": "a machine mounts no share of another until its assignment names one: space-separated name=mountpoint, each optionally :ro (hq ADR 0263 rule 5)"
|
||||
},
|
||||
"sources": {
|
||||
"kind": "preference",
|
||||
"default": "none",
|
||||
"why": "a machine has no occasional source until its assignment names one: space-separated name=smb://<host>/<share>@<mountpoint> or name=disk:<uuid>@<mountpoint>, each optionally :ro (hq ADR 0263 rule 6)"
|
||||
}
|
||||
},
|
||||
"own-secrets": {
|
||||
"smb-credentials": {
|
||||
"path": "${dir:state}/smb-credentials.secret",
|
||||
"taken": "at-start",
|
||||
"issued-by": "outside"
|
||||
}
|
||||
},
|
||||
"resources": [
|
||||
{
|
||||
"id": "nfs-utils",
|
||||
"type": "package",
|
||||
"package": "nfs-utils"
|
||||
},
|
||||
{
|
||||
"id": "cifs-utils",
|
||||
"type": "package",
|
||||
"package": "cifs-utils"
|
||||
},
|
||||
{
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
"mode": "0700",
|
||||
"place": "."
|
||||
},
|
||||
{
|
||||
"id": "config-dir",
|
||||
"type": "directory",
|
||||
"path": "/etc/mesh-mounts",
|
||||
"mode": "0755"
|
||||
},
|
||||
{
|
||||
"id": "config",
|
||||
"type": "file",
|
||||
"path": "/etc/mesh-mounts/mounts.conf",
|
||||
"mode": "0644",
|
||||
"content": "# Written by the mesh (module mounts, novox/hq ADR 0263) from this machine's assignment: the settings\n# shares and sources, and the binding of nfs-share. Replaced on every push; change the settings, not this.\n# The module's process reads it as root every 30 seconds and writes one .mount and .automount per entry.\nshares=${setting:shares}\nsources=${setting:sources}\nserver=${bound:nfs-share:from}\nat=${bound:nfs-share:at}\naccount=${machine:account}\nsecret=${dir:state}/smb-credentials.secret\nstate=${dir:state}\n"
|
||||
},
|
||||
{
|
||||
"id": "bus-dir-parent",
|
||||
"type": "directory",
|
||||
"path": "/var/lib/mesh-mounts",
|
||||
"mode": "0755"
|
||||
},
|
||||
{
|
||||
"id": "bus-dir",
|
||||
"type": "directory",
|
||||
"path": "/var/lib/mesh-mounts/from-bus",
|
||||
"mode": "0755",
|
||||
"owner": "${machine:account}"
|
||||
},
|
||||
{
|
||||
"id": "apply",
|
||||
"type": "process",
|
||||
"name": "mesh-mounts-apply",
|
||||
"artifact": "tools-go",
|
||||
"run": [
|
||||
"./mounts",
|
||||
"apply"
|
||||
],
|
||||
"health": {
|
||||
"kind": "unit"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "watch",
|
||||
"type": "process",
|
||||
"name": "mesh-mounts-watch",
|
||||
"artifact": "tools-go",
|
||||
"run": [
|
||||
"./mounts",
|
||||
"watch"
|
||||
],
|
||||
"health": {
|
||||
"kind": "tool",
|
||||
"tool": "mounts_health",
|
||||
"interval": "60s",
|
||||
"timeout": "10s",
|
||||
"looks": 2,
|
||||
"grace": "90s"
|
||||
}
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
"artifacts": [
|
||||
|
||||
Reference in New Issue
Block a user