From 377a570f804b3512afc96a5b6c118cbecef175b4 Mon Sep 17 00:00:00 2001 From: David Trudgian Date: Mon, 18 Apr 2022 09:55:57 -0500 Subject: [PATCH] feat: Add experimental umount command Counterpart to mount, will unmount a squashfuse mounted filesystem via fusermount. Fixes sylabs/sif#205 Signed-off-by: Edita Kizinevic --- internal/app/siftool/unmount.go | 24 +++ internal/pkg/exp/unmount.go | 97 ++++++++++++ internal/pkg/exp/unmount_test.go | 143 ++++++++++++++++++ pkg/siftool/siftool.go | 1 + .../Test_command_getUnmount/err.golden | 0 .../Test_command_getUnmount/out.golden | 0 pkg/siftool/unmount.go | 31 ++++ pkg/siftool/unmount_test.go | 46 ++++++ 8 files changed, 342 insertions(+) create mode 100644 internal/app/siftool/unmount.go create mode 100644 internal/pkg/exp/unmount.go create mode 100644 internal/pkg/exp/unmount_test.go create mode 100644 pkg/siftool/testdata/Test_command_getUnmount/err.golden create mode 100644 pkg/siftool/testdata/Test_command_getUnmount/out.golden create mode 100644 pkg/siftool/unmount.go create mode 100644 pkg/siftool/unmount_test.go diff --git a/internal/app/siftool/unmount.go b/internal/app/siftool/unmount.go new file mode 100644 index 00000000..6986280b --- /dev/null +++ b/internal/app/siftool/unmount.go @@ -0,0 +1,24 @@ +// Copyright (c) Contributors to the Apptainer project, established as +// Apptainer a Series of LF Projects LLC. +// For website terms of use, trademark policy, privacy policy and other +// project policies see https://lfprojects.org/policies +// 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/apptainer/sif/v2/internal/pkg/exp" +) + +// Unmounts 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), + ) +} diff --git a/internal/pkg/exp/unmount.go b/internal/pkg/exp/unmount.go new file mode 100644 index 00000000..a586bbe7 --- /dev/null +++ b/internal/pkg/exp/unmount.go @@ -0,0 +1,97 @@ +// Copyright (c) Contributors to the Apptainer project, established as +// Apptainer a Series of LF Projects LLC. +// For website terms of use, trademark policy, privacy policy and other +// project policies see https://lfprojects.org/policies +// 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" + "fmt" + "io" + "os/exec" + "path/filepath" +) + +// unmountSquashFS unmounts the filesystem at mountPath. +func unmountSquashFS(ctx context.Context, mountPath string, uo unmountOpts) error { + 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 unmount options. +type unmountOpts struct { + stdout io.Writer + stderr io.Writer + fusermountPath string +} + +// UnmountOpt are used to specify unmount 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 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) +} diff --git a/internal/pkg/exp/unmount_test.go b/internal/pkg/exp/unmount_test.go new file mode 100644 index 00000000..9123df24 --- /dev/null +++ b/internal/pkg/exp/unmount_test.go @@ -0,0 +1,143 @@ +// Copyright (c) Contributors to the Apptainer project, established as +// Apptainer a Series of LF Projects LLC. +// For website terms of use, trademark policy, privacy policy and other +// project policies see https://lfprojects.org/policies +// 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" + "os" + "os/exec" + "path/filepath" + "strings" + "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 bool + wantUnmounted bool + }{ + { + name: "Mounted", + mountSIF: filepath.Join(corpus, "one-group.sif"), + mountPath: path, + wantErr: false, + wantUnmounted: true, + }, + { + name: "NotMounted", + mountSIF: "", + mountPath: path, + wantErr: true, + }, + { + name: "NotSquashfuse", + mountSIF: "", + mountPath: "/dev", + wantErr: true, + }, + { + name: "FusermountBare", + mountSIF: "", + mountPath: path, + opts: []UnmountOpt{OptUnmountFusermountPath("fusermount")}, + wantErr: true, + }, + { + name: "FusermountValid", + mountSIF: filepath.Join(corpus, "one-group.sif"), + mountPath: path, + opts: []UnmountOpt{OptUnmountFusermountPath(fusermountPath)}, + wantErr: false, + 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 err != nil && !tt.wantErr { + t.Errorf("Unexpected error: %s", err) + } + if err == nil && tt.wantErr { + t.Error("Unexpected success") + } + + mounted, err := isMounted(tt.mountPath) + if err != nil { + t.Fatal(err) + } + if tt.wantUnmounted && mounted { + t.Errorf("Expected %s to be unmounted, but it is mounted", tt.mountPath) + } + }) + } +} + +var errBadMountInfo = errors.New("bad mount info") + +func isMounted(mountPath string) (bool, error) { + mountPath, err := filepath.Abs(mountPath) + if err != nil { + return false, err + } + + mi, err := os.Open("/proc/self/mountinfo") + if err != nil { + return false, fmt.Errorf("failed to open /proc/self/mountinfo: %w", err) + } + defer mi.Close() + + scanner := bufio.NewScanner(mi) + for scanner.Scan() { + fields := strings.Split(scanner.Text(), " ") + if len(fields) < 5 { + return false, fmt.Errorf("not enough mountinfo fields: %w", 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] + if mntTarget == mountPath { + return true, nil + } + } + return false, nil +} diff --git a/pkg/siftool/siftool.go b/pkg/siftool/siftool.go index 50ac8f20..464875cd 100644 --- a/pkg/siftool/siftool.go +++ b/pkg/siftool/siftool.go @@ -82,6 +82,7 @@ func AddCommands(cmd *cobra.Command, opts ...CommandOpt) error { if c.opts.experimental { cmd.AddCommand(c.getMount()) + cmd.AddCommand(c.getUnmount()) } return nil diff --git a/pkg/siftool/testdata/Test_command_getUnmount/err.golden b/pkg/siftool/testdata/Test_command_getUnmount/err.golden new file mode 100644 index 00000000..e69de29b diff --git a/pkg/siftool/testdata/Test_command_getUnmount/out.golden b/pkg/siftool/testdata/Test_command_getUnmount/out.golden new file mode 100644 index 00000000..e69de29b diff --git a/pkg/siftool/unmount.go b/pkg/siftool/unmount.go new file mode 100644 index 00000000..fdb7c259 --- /dev/null +++ b/pkg/siftool/unmount.go @@ -0,0 +1,31 @@ +// Copyright (c) Contributors to the Apptainer project, established as +// Apptainer a Series of LF Projects LLC. +// For website terms of use, trademark policy, privacy policy and other +// project policies see https://lfprojects.org/policies +// 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 ", + 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 + } +} diff --git a/pkg/siftool/unmount_test.go b/pkg/siftool/unmount_test.go new file mode 100644 index 00000000..eef54b8b --- /dev/null +++ b/pkg/siftool/unmount_test.go @@ -0,0 +1,46 @@ +// Copyright (c) Contributors to the Apptainer project, established as +// Apptainer a Series of LF Projects LLC. +// For website terms of use, trademark policy, privacy policy and other +// project policies see https://lfprojects.org/policies +// 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/apptainer/sif/v2/internal/pkg/exp" +) + +func Test_command_getUnmount(t *testing.T) { + if _, err := exec.LookPath("squashfuse"); err != nil { + t.Skip(" not found, skipping unmount tests") + } + if _, err := exec.LookPath("fusermount"); err != nil { + t.Skip(" not found, skipping unmount tests") + } + + path, err := os.MkdirTemp("", "siftool-unmount-*") + 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) +}