Skip to content

Commit

Permalink
feat: Add experimental umount command
Browse files Browse the repository at this point in the history
Counterpart to mount, will unmount a squashfuse mounted filesystem via
fusermount.

Fixes sylabs#205
  • Loading branch information
dtrudg committed Apr 19, 2022
1 parent 5314bc0 commit a7d512b
Show file tree
Hide file tree
Showing 8 changed files with 336 additions and 0 deletions.
20 changes: 20 additions & 0 deletions internal/app/siftool/unmount.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// Copyright (c) 2022, Sylabs Inc. All rights reserved.
// This software is licensed under a 3-clause BSD license. Please consult the
// LICENSE file distributed with the sources of this project regarding your
// rights to use or distribute this software.

package siftool

import (
"context"

"github.com/sylabs/sif/v2/internal/pkg/exp"
)

// Umounts the FUSE mounted filesystem at mountPath.
func (a *App) Unmount(ctx context.Context, mountPath string) error {
return exp.Unmount(ctx, mountPath,
exp.OptUnmountStdout(a.opts.out),
exp.OptUnmountStderr(a.opts.err),
)
}
148 changes: 148 additions & 0 deletions internal/pkg/exp/umount.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
// Copyright (c) 2022, Sylabs Inc. All rights reserved.
// This software is licensed under a 3-clause BSD license. Please consult the
// LICENSE file distributed with the sources of this project regarding your
// rights to use or distribute this software.

package exp

import (
"bufio"
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
)

const mountInfoPath = "/proc/self/mountinfo"

// ErrNotMounted is the error returned when attempting to unmount a path that
// has no mount associated with it.
var errNotMounted = errors.New("not mounted")

// ErrNotSquashfuse is the error returned when attempting to unmount a path that
// is not a squashfuse mount.
var errNotSquashfuse = errors.New("not a squashfuse mount")

// ErrBadMountInfo is the error returned if we cannot parse /proc/self/mountinfo.
var errBadMountInfo = errors.New("bad mountinfo")

// checkMounted verifies whether mountPath is a current squashfuse mount.
func checkMounted(mountPath string) error {
mountPath, err := filepath.Abs(mountPath)
if err != nil {
return err
}

mi, err := os.Open("/proc/self/mountinfo")
if err != nil {
return fmt.Errorf("failed to open %s: %w", mountInfoPath, err)
}
defer mi.Close()

scanner := bufio.NewScanner(mi)
for scanner.Scan() {
fields := strings.Split(scanner.Text(), " ")
if len(fields) < 10 {
return fmt.Errorf("%w: not enough fields", errBadMountInfo)
}
//nolint:lll
// 1348 63 0:77 / /tmp/siftool-mount-956028386 ro,nosuid,nodev,relatime shared:646 - fuse.squashfuse squashfuse ro,user_id=1000,group_id=100
mntTarget := fields[4]
// Number of fields is not fixed - so loop over field 7+
if mntTarget == mountPath {
for _, v := range fields[6:] {
if v == "squashfuse" {
return nil
}
}
return errNotSquashfuse
}
}
return errNotMounted
}

// unmountSquashFS unmounts the filesystem at mountPath.
func unmountSquashFS(ctx context.Context, mountPath string, uo unmountOpts) error {
if err := checkMounted(mountPath); err != nil {
return err
}

args := []string{
"-u",
filepath.Clean(mountPath),
}
cmd := exec.CommandContext(ctx, uo.fusermountPath, args...) //nolint:gosec
cmd.Stdout = uo.stdout
cmd.Stderr = uo.stderr

if err := cmd.Run(); err != nil {
return fmt.Errorf("failed to unmount: %w", err)
}

return nil
}

// unmountOpts accumulates mount options.
type unmountOpts struct {
stdout io.Writer
stderr io.Writer
fusermountPath string
}

// UnmountOpt are used to specify mount options.
type UnmountOpt func(*unmountOpts) error

// OptUnmountStdout writes standard output to w.
func OptUnmountStdout(w io.Writer) UnmountOpt {
return func(mo *unmountOpts) error {
mo.stdout = w
return nil
}
}

// OptUnmountStderr writes standard error to w.
func OptUnmountStderr(w io.Writer) UnmountOpt {
return func(mo *unmountOpts) error {
mo.stderr = w
return nil
}
}

var errFusermountPathInvalid = errors.New("fusermount path must be relative or absolute")

// OptUnmountFusermountPath sets the path to the fusermount binary.
func OptUnmountFusermountPath(path string) UnmountOpt {
return func(mo *unmountOpts) error {
if filepath.Base(path) == path {
return errFusermountPathInvalid
}
mo.fusermountPath = path
return nil
}
}

// Unmount unmounts the FUSE mounted filesystem at mountPath.
//
// Unmount may start one or more underlying processes. By default, stdout and stderr of these
// processes is discarded. To modify this behavior, consider using OptUnmountStdout and/or
// OptUnmountStderr.
//
// By default, Unmount searches for a fusermount binary in the directories named by the PATH
// environment variable. To override this behavior, consider using OptUnmountFusermountPath().
func Unmount(ctx context.Context, mountPath string, opts ...UnmountOpt) error {
uo := unmountOpts{
fusermountPath: "fusermount",
}

for _, opt := range opts {
if err := opt(&uo); err != nil {
return fmt.Errorf("%w", err)
}
}

return unmountSquashFS(ctx, mountPath, uo)
}
98 changes: 98 additions & 0 deletions internal/pkg/exp/umount_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
// Copyright (c) 2022, Sylabs Inc. All rights reserved.
// This software is licensed under a 3-clause BSD license. Please consult the
// LICENSE file distributed with the sources of this project regarding your
// rights to use or distribute this software.

package exp

import (
"context"
"errors"
"os"
"os/exec"
"path/filepath"
"testing"
)

var corpus = filepath.Join("..", "..", "..", "test", "images")

func Test_Unmount(t *testing.T) {
if _, err := exec.LookPath("squashfuse"); err != nil {
t.Skip(" not found, skipping mount tests")
}
fusermountPath, err := exec.LookPath("fusermount")
if err != nil {
t.Skip(" not found, skipping mount tests")
}

path, err := os.MkdirTemp("", "siftool-mount-*")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
os.RemoveAll(path)
})

tests := []struct {
name string
mountSIF string
mountPath string
opts []UnmountOpt
wantErr error
wantUnmounted bool
}{
{
name: "Mounted",
mountSIF: filepath.Join(corpus, "one-group.sif"),
mountPath: path,
wantUnmounted: true,
},
{
name: "NotMounted",
mountSIF: "",
mountPath: path,
wantErr: errNotMounted,
},
{
name: "NotSquashfuse",
mountSIF: "",
mountPath: "/dev",
wantErr: errNotSquashfuse,
},
{
name: "FusermountBare",
mountSIF: "",
mountPath: path,
opts: []UnmountOpt{OptUnmountFusermountPath("bare")},
wantErr: errFusermountPathInvalid,
},
{
name: "FusermountValid",
mountSIF: filepath.Join(corpus, "one-group.sif"),
mountPath: path,
opts: []UnmountOpt{OptUnmountFusermountPath(fusermountPath)},
wantUnmounted: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.mountSIF != "" {
err := Mount(context.Background(), tt.mountSIF, path)
if err != nil {
t.Fatal(err)
}
}

err := Unmount(context.Background(), tt.mountPath, tt.opts...)

if !errors.Is(err, tt.wantErr) {
t.Errorf("Expected err %s, but got %s", tt.wantErr, err)
}

err = checkMounted(tt.mountPath)
if tt.wantUnmounted && !errors.Is(err, errNotMounted) {
t.Errorf("Expected %s to be unmounted, but it is mounted", tt.mountPath)
}
})
}
}
1 change: 1 addition & 0 deletions pkg/siftool/siftool.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ func AddCommands(cmd *cobra.Command, opts ...CommandOpt) error {

if c.opts.experimental {
cmd.AddCommand(c.getMount())
cmd.AddCommand(c.getUnmount())
}

return nil
Expand Down
Empty file.
Empty file.
27 changes: 27 additions & 0 deletions pkg/siftool/unmount.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// Copyright (c) 2022, Sylabs Inc. All rights reserved.
// This software is licensed under a 3-clause BSD license. Please consult the
// LICENSE file distributed with the sources of this project regarding your
// rights to use or distribute this software.

package siftool

import (
"github.com/spf13/cobra"
)

// getUnmount returns a command that unmounts the primary system partition of a SIF image.
func (c *command) getUnmount() *cobra.Command {
return &cobra.Command{
Use: "unmount <mount_path>",
Short: "Unmount primary system partition",
Long: "Unmount a primary system partition of a SIF image",
Example: c.opts.rootPath + " unmount path/",
Args: cobra.ExactArgs(1),
PreRunE: c.initApp,
RunE: func(cmd *cobra.Command, args []string) error {
return c.app.Unmount(cmd.Context(), args[0])
},
DisableFlagsInUseLine: true,
Hidden: true, // hide while command is experimental
}
}
42 changes: 42 additions & 0 deletions pkg/siftool/unmount_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// Copyright (c) 2022, Sylabs Inc. All rights reserved.
// This software is licensed under a 3-clause BSD license. Please consult the
// LICENSE file distributed with the sources of this project regarding your
// rights to use or distribute this software.

package siftool

import (
"context"
"os"
"os/exec"
"path/filepath"
"testing"

"github.com/sylabs/sif/v2/internal/pkg/exp"
)

func Test_command_getUnmount(t *testing.T) {
if _, err := exec.LookPath("squashfuse"); err != nil {
t.Skip(" not found, skipping mount tests")
}
if _, err := exec.LookPath("fusermount"); err != nil {
t.Skip(" not found, skipping mount tests")
}

path, err := os.MkdirTemp("", "siftool-mount-*")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
os.RemoveAll(path)
})

testSIF := filepath.Join(corpus, "one-group.sif")
if err := exp.Mount(context.Background(), testSIF, path); err != nil {
t.Fatal(err)
}

c := &command{}
cmd := c.getUnmount()
runCommand(t, cmd, []string{path}, nil)
}

0 comments on commit a7d512b

Please sign in to comment.