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:
jochen
2026-10-08 18:36:01 +02:00
parent 3251758340
commit 2b1b9b0235
16 changed files with 2583 additions and 18 deletions
+196
View File
@@ -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
}