Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions route/v1.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
20 changes: 20 additions & 0 deletions route/v1/unreadable_mounts.go
Original file line number Diff line number Diff line change
@@ -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})
}
81 changes: 81 additions & 0 deletions service/unreadable_mounts.go
Original file line number Diff line number Diff line change
@@ -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
}
35 changes: 35 additions & 0 deletions service/unreadable_mounts_test.go
Original file line number Diff line number Diff line change
@@ -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"}})
}
Loading