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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user