fix(mount): wait for helper mounts to appear before verifying
- bindfs and other FUSE helpers return before their mount is visible in /proc/mounts, so the single post-mount check raced them and aborted with "unable to mount" even though the mount succeeded, leaving configured services unstarted. - Post-mount verification now polls the mount table for up to 2 seconds before declaring failure. - Mount table entries are matched against both the symlink-resolved and literal target, so a target whose path cannot be resolved still matches a live mount instead of being mounted a second time. - Octal escapes in /proc/mounts are decoded, so mountpoints containing spaces are recognized as mounted. - Failure to read /proc/mounts is returned as an error and closes any LUKS volume opened for that entry, instead of exiting the process mid-run from a mount goroutine.
This commit is contained in:
parent
ac0ea56f98
commit
be8f6ecf55
1 changed files with 100 additions and 18 deletions
118
main.go
118
main.go
|
|
@ -8,19 +8,29 @@ import (
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
"golang.org/x/term"
|
"golang.org/x/term"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Mount helpers that background themselves can take a moment for their mount to
|
||||||
|
// become visible in the mount table, so verification polls instead of sampling
|
||||||
|
// once.
|
||||||
|
const (
|
||||||
|
mountSettleTimeout = 2 * time.Second
|
||||||
|
mountSettlePoll = 20 * time.Millisecond
|
||||||
|
)
|
||||||
|
|
||||||
// RaidMount holds mount point details parsed from the raid table.
|
// RaidMount holds mount point details parsed from the raid table.
|
||||||
type RaidMount struct {
|
type RaidMount struct {
|
||||||
Source string
|
Source string
|
||||||
Target string
|
Target string
|
||||||
FSType string
|
FSType string
|
||||||
Flags string
|
Flags string
|
||||||
CryptName string
|
CryptName string
|
||||||
Encrypted bool
|
Encrypted bool
|
||||||
Parallel bool
|
Parallel bool
|
||||||
|
|
@ -34,18 +44,48 @@ type App struct {
|
||||||
|
|
||||||
var app *App
|
var app *App
|
||||||
|
|
||||||
// isMounted checks /proc/mounts for a target mountpoint to determine if it is mounted.
|
// unescapeMountField decodes the octal escapes the kernel writes into
|
||||||
// Symlinks in the target path are resolved so the comparison matches the real
|
// /proc/mounts for characters that would otherwise break field splitting:
|
||||||
// paths recorded by the kernel.
|
// space, tab, newline, and backslash. Without this a mountpoint containing a
|
||||||
func isMounted(target string) bool {
|
// space never matches and is reported as unmounted.
|
||||||
|
func unescapeMountField(field string) string {
|
||||||
|
if !strings.Contains(field, `\`) {
|
||||||
|
return field
|
||||||
|
}
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
for i := 0; i < len(field); i++ {
|
||||||
|
// Escapes are always a backslash followed by exactly three octal digits.
|
||||||
|
if field[i] == '\\' && i+3 < len(field) {
|
||||||
|
if v, err := strconv.ParseUint(field[i+1:i+4], 8, 8); err == nil {
|
||||||
|
b.WriteByte(byte(v))
|
||||||
|
i += 3
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.WriteByte(field[i])
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// isMounted checks /proc/mounts for a target mountpoint to determine if it is
|
||||||
|
// mounted. A read failure is returned rather than treated as "not mounted",
|
||||||
|
// since the two are otherwise indistinguishable and the caller would go on to
|
||||||
|
// stack a second mount on the same target.
|
||||||
|
func isMounted(target string) (bool, error) {
|
||||||
|
// Compare against both the resolved and unresolved forms. The kernel records
|
||||||
|
// fully resolved paths, but EvalSymlinks fails when any component is
|
||||||
|
// temporarily unreachable, and falling back to only the unresolved path
|
||||||
|
// would silently report a live mount as missing.
|
||||||
cleanTarget := filepath.Clean(target)
|
cleanTarget := filepath.Clean(target)
|
||||||
|
candidates := map[string]bool{cleanTarget: true}
|
||||||
if resolved, err := filepath.EvalSymlinks(cleanTarget); err == nil {
|
if resolved, err := filepath.EvalSymlinks(cleanTarget); err == nil {
|
||||||
cleanTarget = resolved
|
candidates[resolved] = true
|
||||||
}
|
}
|
||||||
|
|
||||||
file, err := os.Open("/proc/mounts")
|
file, err := os.Open("/proc/mounts")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
return false, fmt.Errorf("read mount table: %w", err)
|
||||||
}
|
}
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
|
|
||||||
|
|
@ -55,11 +95,31 @@ func isMounted(target string) bool {
|
||||||
if len(args) < 3 {
|
if len(args) < 3 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if filepath.Clean(args[1]) == cleanTarget {
|
if candidates[filepath.Clean(unescapeMountField(args[1]))] {
|
||||||
return true
|
return true, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false
|
// A truncated scan looks identical to a target that is simply absent.
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
return false, fmt.Errorf("read mount table: %w", err)
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitMounted polls the mount table for target, giving a helper that has
|
||||||
|
// already exited a brief window for its mount to become visible.
|
||||||
|
func waitMounted(target string) (bool, error) {
|
||||||
|
deadline := time.Now().Add(mountSettleTimeout)
|
||||||
|
for {
|
||||||
|
mounted, err := isMounted(target)
|
||||||
|
if err != nil || mounted {
|
||||||
|
return mounted, err
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
time.Sleep(mountSettlePoll)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// closeLUKS attempts to close a LUKS volume by name, logging any failure.
|
// closeLUKS attempts to close a LUKS volume by name, logging any failure.
|
||||||
|
|
@ -83,7 +143,11 @@ func closeLUKS(cryptName string) {
|
||||||
// Either side of the "|" may be empty. If no "|" is present the entire Flags
|
// Either side of the "|" may be empty. If no "|" is present the entire Flags
|
||||||
// string is treated as bindfs native flags with no -o options.
|
// string is treated as bindfs native flags with no -o options.
|
||||||
func mountBindfs(mount RaidMount) error {
|
func mountBindfs(mount RaidMount) error {
|
||||||
if isMounted(mount.Target) {
|
mounted, err := isMounted(mount.Target)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if mounted {
|
||||||
fmt.Println(mount.Target, "is already mounted")
|
fmt.Println(mount.Target, "is already mounted")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -124,7 +188,13 @@ func mountBindfs(mount RaidMount) error {
|
||||||
return fmt.Errorf("bindfs %s: %w", mount.Target, err)
|
return fmt.Errorf("bindfs %s: %w", mount.Target, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !isMounted(mount.Target) {
|
// bindfs backgrounds itself once the mount is established, so poll rather
|
||||||
|
// than sampling the mount table a single time.
|
||||||
|
mounted, err = waitMounted(mount.Target)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !mounted {
|
||||||
return fmt.Errorf("unable to mount: %s", mount.Target)
|
return fmt.Errorf("unable to mount: %s", mount.Target)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -198,7 +268,14 @@ func mountDrive(mount RaidMount, encryptionPassword string) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we're already mounted on this mountpoint, skip.
|
// If we're already mounted on this mountpoint, skip.
|
||||||
if isMounted(mount.Target) {
|
mounted, err := isMounted(mount.Target)
|
||||||
|
if err != nil {
|
||||||
|
if mount.Encrypted && openedLUKS {
|
||||||
|
closeLUKS(mount.CryptName)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if mounted {
|
||||||
fmt.Println(mount.Target, "is already mounted")
|
fmt.Println(mount.Target, "is already mounted")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -222,12 +299,17 @@ func mountDrive(mount RaidMount, encryptionPassword string) error {
|
||||||
return fmt.Errorf("mount %s: %w", mount.Target, err)
|
return fmt.Errorf("mount %s: %w", mount.Target, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify that it actually mounted.
|
// Verify that it actually mounted. FUSE-backed filesystems such as mergerfs
|
||||||
if !isMounted(mount.Target) {
|
// background themselves, so allow the same settle window used for bindfs.
|
||||||
|
mounted, err = waitMounted(mount.Target)
|
||||||
|
if err == nil && !mounted {
|
||||||
|
err = fmt.Errorf("unable to mount: %s", mount.Target)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
if mount.Encrypted && openedLUKS {
|
if mount.Encrypted && openedLUKS {
|
||||||
closeLUKS(mount.CryptName)
|
closeLUKS(mount.CryptName)
|
||||||
}
|
}
|
||||||
return fmt.Errorf("unable to mount: %s", mount.Target)
|
return err
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue