Make nfs-server reconcile the kernel's exports with its file, so a failed reload or a withdrawn grant is not left exported while the module says it is healthy; warn on a stale or unknown backup list

This commit is contained in:
jochen
2026-10-09 14:43:32 +02:00
parent 72a6d062dc
commit 7320d99a11
9 changed files with 418 additions and 23 deletions
+80 -9
View File
@@ -4,7 +4,9 @@ import (
"context"
"encoding/json"
"fmt"
"net/netip"
"os"
"path"
"strings"
"time"
)
@@ -26,6 +28,9 @@ type Status struct {
// Said is what does not match between the grants and the wants: a node wanting a share it is not
// granted, a grant nobody wants, a share exported to no node.
Said []string `json:"said,omitempty"`
// Warnings make the module no less healthy but are said in its health: what it could not learn, and
// how old what it relies on is.
Warnings []string `json:"warnings,omitempty"`
// Reloaded is when the process last had the kernel read the export files again, and why.
Reloaded *time.Time `json:"reloaded,omitempty"`
ReloadWhy string `json:"reload_why,omitempty"`
@@ -54,7 +59,7 @@ func (m *Machine) Pass(ctx context.Context, last *Status) Status {
s.Said = append(s.Said, note)
}
if _, note := m.Backup(); note != "" {
s.Said = append(s.Said, note)
s.Warnings = append(s.Warnings, note)
}
m.setWalk(ctx)
exports, refused, said := m.Judge(c, roster, wants)
@@ -78,6 +83,14 @@ func (m *Machine) Pass(ctx context.Context, last *Status) Status {
}
}
live, note := m.Live()
foreign, _, _ := m.Foreign()
mine := ParseExports(want, ExportFile)
if reload == "" && last == nil {
reload = "the process started, and the kernel's table is made to match the file"
}
if reload == "" && last != nil && last.ReloadError != "" {
reload = "the last reload failed: " + last.ReloadError
}
if reload == "" {
for _, e := range exports {
if !SharedLive(live, e) {
@@ -86,6 +99,11 @@ func (m *Machine) Pass(ctx context.Context, last *Status) Status {
}
}
}
if reload == "" {
if stray := Stray(live, mine, foreign, c.Range); len(stray) > 0 {
reload = fmt.Sprintf("the kernel exports %s to %s, which the export file no longer carries", stray[0].Path, stray[0].Client)
}
}
if reload != "" {
if _, err := m.AsRootWithin(ctx, "exportfs", "-ra"); err != nil {
s.ReloadError = err.Error()
@@ -98,6 +116,10 @@ func (m *Machine) Pass(ctx context.Context, last *Status) Status {
if note != "" && len(exports) > 0 {
s.Problems = append(s.Problems, note)
}
for _, e := range Stray(live, mine, foreign, c.Range) {
s.Problems = append(s.Problems, fmt.Sprintf("the kernel still exports %s to %s, which the module's export file "+
"no longer carries: a reload has to take it away (exportfs -ra as root)", e.Path, e.Client))
}
for i := range exports {
exports[i].Live = SharedLive(live, exports[i])
}
@@ -109,6 +131,9 @@ func (m *Machine) Pass(ctx context.Context, last *Status) Status {
return s
}
// ProcessUnit is the service the node-engine runs this process as: the manifest's process name, `.service`.
const ProcessUnit = "nfs-server-exports.service"
// StopTimeout bounds Stopped and the commands it runs: well under the service manager's stop timeout (90 s,
// then SIGKILL), so the export file is gone before the process could be killed (mesh-host #62).
var StopTimeout = 10 * time.Second
@@ -186,17 +211,15 @@ func SharedLive(live []Entry, e Exported) bool {
return true
}
// Stopped is the process ending. When the module was unassigned — the node-engine removes the module's
// files in reverse declaration order, so the config file, declared after this process, is already gone
// when the process is stopped (mesh-host internal/apply: orphans removed in reverse order) — the
// module's export file goes and the kernel lets the shares go. A plain restart, a push or the machine
// shutting down leaves the config file, and the exports stay.
// Stopped is the process ending. When the module was unassigned (see unassigned) the module's export file
// goes and the kernel lets the shares go. A plain restart, a push or the machine shutting down leaves the
// config file and the unit enabled, and the exports stay.
func (m *Machine) Stopped() {
if _, err := m.Read(ConfigFile); err == nil || !os.IsNotExist(err) {
return
}
ctx, cancel := context.WithTimeout(context.Background(), StopTimeout)
defer cancel()
if !m.unassigned(ctx) {
return
}
// The file first: removing it needs no command, so a hanging sudo or exportfs cannot keep it.
if err := m.unexport(); err != nil {
loud("the module was unassigned and its export file is still there: " + err.Error() +
@@ -212,8 +235,56 @@ func (m *Machine) Stopped() {
fmt.Fprintln(os.Stderr, "nfs-server: unassigned: the kernel let the shares go (exportfs -ra)")
}
// unassigned says whether this stop is the module being taken away: its config file is gone (the node-engine
// removes resources in reverse of their record, and the config is recorded after this process), or this
// process's own unit is disabled. The node-engine stops a process it removes with `systemctl disable
// --now`, which disables the unit before it stops it, while a restart, a push or a shutdown leaves it
// enabled — so the second sign holds even when a failed first apply recorded the process after its config
// (mesh-host #62) and the config is still there.
func (m *Machine) unassigned(ctx context.Context) bool {
if _, err := m.Read(ConfigFile); os.IsNotExist(err) {
return true
}
r := m.Run(ctx, "systemctl", "is-enabled", ProcessUnit)
return strings.TrimSpace(r.Out) == "disabled"
}
// loud says, on the process's log where the service manager's journal keeps it, what an unassignment left
// undone, so a person finds it rather than an export that outlived its module.
func loud(what string) {
fmt.Fprintln(os.Stderr, "nfs-server: UNASSIGNED, NOT CLEAN: "+what)
}
// Stray is what the kernel exports that is the module's and that its file no longer carries: an entry to a
// client inside the private network's range — the only clients the module ever exports to — that neither
// the module's file nor any other export file (/etc/exports, ZFS's own) has. What another file exports is
// not the module's, whatever its client.
func Stray(live, mine, foreign []Entry, rng string) []Entry {
prefix, err := netip.ParsePrefix(rng)
if err != nil {
return nil
}
has := map[string]bool{}
for _, e := range append(append([]Entry{}, mine...), foreign...) {
has[path.Clean(e.Path)+" "+e.Client] = true
}
var out []Entry
for _, e := range live {
if has[path.Clean(e.Path)+" "+e.Client] || !insideRange(e.Client, prefix) {
continue
}
out = append(out, e)
}
return out
}
// insideRange says whether a client — one address, or a network — lies wholly inside a range.
func insideRange(client string, prefix netip.Prefix) bool {
if a, err := netip.ParseAddr(client); err == nil {
return prefix.Contains(a)
}
if p, err := netip.ParsePrefix(client); err == nil {
return p.Bits() >= prefix.Bits() && prefix.Contains(p.Addr())
}
return false
}