diff --git a/route/v1.go b/route/v1.go index 6f32bbb..e3f6fd4 100644 --- a/route/v1.go +++ b/route/v1.go @@ -81,6 +81,7 @@ func InitV1Router() http.Handler { v1DisksGroup.GET("/zfs", v1.GetZFS) v1DisksGroup.GET("/zfs/snapshots", v1.GetZFSSnapshots) v1DisksGroup.GET("/zfs/io", v1.GetZFSIO) + v1DisksGroup.GET("/unreadable", v1.GetUnreadableMounts) } v1StorageGroup := v1Group.Group("/storage") diff --git a/route/v1/unreadable_mounts.go b/route/v1/unreadable_mounts.go new file mode 100644 index 0000000..9bbdc04 --- /dev/null +++ b/route/v1/unreadable_mounts.go @@ -0,0 +1,20 @@ +package v1 + +import ( + "net/http" + + "github.com/ReCasaOS/CasaOS-Common/model" + "github.com/ReCasaOS/CasaOS-Common/utils/common_err" + "github.com/labstack/echo/v4" + + "github.com/ReCasaOS/CasaOS-LocalStorage/service" +) + +// GetUnreadableMounts lists the mounts df cannot read (a dead NFS share, a FUSE mount whose program died), for "Needs attention". +func GetUnreadableMounts(ctx echo.Context) error { + mounts, err := service.UnreadableMounts() + if err != nil { + return ctx.JSON(http.StatusInternalServerError, model.Result{Success: common_err.SERVICE_ERROR, Message: err.Error()}) + } + return ctx.JSON(common_err.SUCCESS, model.Result{Success: common_err.SUCCESS, Message: common_err.GetMsg(common_err.SUCCESS), Data: mounts}) +} diff --git a/service/unreadable_mounts.go b/service/unreadable_mounts.go new file mode 100644 index 0000000..b614a93 --- /dev/null +++ b/service/unreadable_mounts.go @@ -0,0 +1,81 @@ +package service + +import ( + "context" + "errors" + "os" + "os/exec" + "slices" + "strings" + "time" +) + +// The mounts df cannot read: a dead NFS share ("Stale file handle"), a FUSE mount whose program died +// ("Transport endpoint is not connected"). Nothing said so: the storage list left them out, and until +// LocalStorage#30 it stopped answering altogether. The dashboard puts them in "Needs attention". + +// UnreadableMount is a mount df complained about, with its complaint in English. An empty MountPoint is +// df itself not answering, which a hard NFS mount whose server is gone does. +type UnreadableMount struct { + MountPoint string `json:"mount_point"` + Error string `json:"error"` +} + +const dfTimeout = 15 * time.Second + +// what root is refused on another user's FUSE mount (a desktop's /run/user/1000/doc): not a dead mount +var dfRefusals = []string{"Permission denied", "Operation not permitted"} + +// dfComplaints runs df for its complaints alone; a var for the tests. +var dfComplaints = func(ctx context.Context) (string, error) { + cmd := exec.CommandContext(ctx, "df", "-P") + cmd.Env = append(os.Environ(), "LC_ALL=C") // the complaints in English whatever the language of the box + cmd.WaitDelay = 2 * time.Second + stderr := &cappedBuffer{max: 16384} + cmd.Stderr = stderr + err := cmd.Run() + if ctx.Err() != nil { + return "", ctx.Err() + } + var exit *exec.ExitError + if err != nil && !errors.As(err, &exit) { // exit 1 is what df does when it complains + return "", err + } + return stderr.String(), nil +} + +// UnreadableMounts lists the mounts df cannot read, none on a healthy box. +func UnreadableMounts() ([]UnreadableMount, error) { + ctx, cancel := context.WithTimeout(context.Background(), dfTimeout) + defer cancel() + stderr, err := dfComplaints(ctx) + if errors.Is(err, context.DeadlineExceeded) { + return []UnreadableMount{{Error: "df did not answer within 15 s"}}, nil + } + if err != nil { + return nil, err + } + return parseDfComplaints(stderr), nil +} + +// parseDfComplaints reads lines like "df: /media/fotos: Stale file handle"; df quotes a path that has +// spaces or quotes in it, shell style. The path is cut at the last ": ", the complaints having none. +func parseDfComplaints(stderr string) []UnreadableMount { + mounts := []UnreadableMount{} + for _, line := range strings.Split(stderr, "\n") { + rest, ok := strings.CutPrefix(strings.TrimSpace(line), "df: ") + cut := strings.LastIndex(rest, ": ") + if !ok || cut <= 0 { + continue + } + path, complaint := rest[:cut], rest[cut+2:] + if slices.Contains(dfRefusals, complaint) { + continue + } + if len(path) >= 2 && path[0] == '\'' && path[len(path)-1] == '\'' { + path = strings.ReplaceAll(path[1:len(path)-1], `'\''`, `'`) + } + mounts = append(mounts, UnreadableMount{MountPoint: path, Error: complaint}) + } + return mounts +} diff --git a/service/unreadable_mounts_test.go b/service/unreadable_mounts_test.go new file mode 100644 index 0000000..5f78908 --- /dev/null +++ b/service/unreadable_mounts_test.go @@ -0,0 +1,35 @@ +package service + +import ( + "context" + "testing" + + "gotest.tools/v3/assert" +) + +func TestParseDfComplaintsKeepsTheDeadMountsOnly(t *testing.T) { + stderr := "df: /media/ser/fotos: Stale file handle\n" + + "df: '/mnt/my share': Transport endpoint is not connected\n" + + "df: '/mnt/it'\\''s': Input/output error\n" + + "df: /run/user/1000/doc: Operation not permitted\n" + + "df: /run/user/1000/gvfs: Permission denied\n" + + "something else\n" + + assert.DeepEqual(t, parseDfComplaints(stderr), []UnreadableMount{ + {MountPoint: "/media/ser/fotos", Error: "Stale file handle"}, + {MountPoint: "/mnt/my share", Error: "Transport endpoint is not connected"}, + {MountPoint: "/mnt/it's", Error: "Input/output error"}, + }) + assert.DeepEqual(t, parseDfComplaints(""), []UnreadableMount{}) +} + +func TestUnreadableMountsSaysWhenDfHangs(t *testing.T) { + saved := dfComplaints + t.Cleanup(func() { dfComplaints = saved }) + dfComplaints = func(ctx context.Context) (string, error) { return "", context.DeadlineExceeded } + + mounts, err := UnreadableMounts() + + assert.NilError(t, err) + assert.DeepEqual(t, mounts, []UnreadableMount{{Error: "df did not answer within 15 s"}}) +}