diff --git a/cmd/siftool/siftool.go b/cmd/siftool/siftool.go index 41620d8e..61f0ba99 100644 --- a/cmd/siftool/siftool.go +++ b/cmd/siftool/siftool.go @@ -1,4 +1,4 @@ -// Copyright (c) 2018-2021, Sylabs Inc. All rights reserved. +// Copyright (c) 2018-2022, Sylabs Inc. All rights reserved. // Copyright (c) 2017, SingularityWare, LLC. All rights reserved. // Copyright (c) 2017, Yannick Cote All rights reserved. // This software is licensed under a 3-clause BSD license. Please consult the @@ -12,6 +12,7 @@ import ( "io" "os" "runtime" + "strconv" "text/tabwriter" "github.com/spf13/cobra" @@ -79,7 +80,16 @@ possible to modify a SIF file via this tool via the add/del commands.`, root.AddCommand(getVersion()) - if err := siftool.AddCommands(&root); err != nil { + var experimental bool + if val, ok := os.LookupEnv("SIFTOOL_EXPERIMENTAL"); ok { + b, err := strconv.ParseBool(val) + if err != nil { + fmt.Fprintln(os.Stderr, "Error: failed to parse SIFTOOL_EXPERIMENTAL environment variable:", err) + } + experimental = b + } + + if err := siftool.AddCommands(&root, siftool.OptWithExperimental(experimental)); err != nil { fmt.Fprintln(os.Stderr, "Error:", err) os.Exit(1) } diff --git a/internal/app/siftool/app.go b/internal/app/siftool/app.go index 1a7014ca..8cc63776 100644 --- a/internal/app/siftool/app.go +++ b/internal/app/siftool/app.go @@ -1,4 +1,4 @@ -// Copyright (c) 2021, Sylabs Inc. All rights reserved. +// Copyright (c) 2021-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. @@ -13,6 +13,7 @@ import ( // appOpts contains configured options. type appOpts struct { out io.Writer + err io.Writer } // AppOpt are used to configure optional behavior. @@ -31,11 +32,23 @@ func OptAppOutput(w io.Writer) AppOpt { } } +// OptAppError specifies that errors should be written to w. +func OptAppError(w io.Writer) AppOpt { + return func(o *appOpts) error { + o.err = w + return nil + } +} + // New creates a new App configured with opts. +// +// By default, application output and errors are written to os.Stdout and os.Stderr respectively. +// To modify this behavior, consider using OptAppOutput and/or OptAppError. func New(opts ...AppOpt) (*App, error) { a := App{ opts: appOpts{ out: os.Stdout, + err: os.Stderr, }, } diff --git a/internal/app/siftool/mount.go b/internal/app/siftool/mount.go new file mode 100644 index 00000000..edd83398 --- /dev/null +++ b/internal/app/siftool/mount.go @@ -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" +) + +// Mount mounts the primary system partition of the SIF file at path into mountPath. +func (a *App) Mount(ctx context.Context, path, mountPath string) error { + return exp.Mount(ctx, path, mountPath, + exp.OptMountStdout(a.opts.out), + exp.OptMountStderr(a.opts.err), + ) +} diff --git a/internal/pkg/exp/mount.go b/internal/pkg/exp/mount.go new file mode 100644 index 00000000..dfee8460 --- /dev/null +++ b/internal/pkg/exp/mount.go @@ -0,0 +1,104 @@ +// 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 contains experimental functionality that is not sufficiently mature to be exported +// as part of the module API. +package exp + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + + "github.com/sylabs/sif/v2/pkg/sif" +) + +// mountSquashFS mounts the SquashFS filesystem from path at offset into mountPath. +func mountSquashFS(ctx context.Context, offset int64, path, mountPath string, mo mountOpts) error { + args := []string{ + "-o", fmt.Sprintf("ro,offset=%d", offset), + filepath.Clean(path), + filepath.Clean(mountPath), + } + + cmd := exec.CommandContext(ctx, "squashfuse", args...) + cmd.Stdout = mo.stdout + cmd.Stderr = mo.stderr + + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to mount: %w", err) + } + + return nil +} + +// mountOpts accumulates mount options. +type mountOpts struct { + stdout io.Writer + stderr io.Writer +} + +// MountOpt are used to specify mount options. +type MountOpt func(*mountOpts) error + +// OptMountStdout writes standard output to w. +func OptMountStdout(w io.Writer) MountOpt { + return func(mo *mountOpts) error { + mo.stdout = w + return nil + } +} + +// OptMountStderr writes standard error to w. +func OptMountStderr(w io.Writer) MountOpt { + return func(mo *mountOpts) error { + mo.stderr = w + return nil + } +} + +var errUnsupportedFSType = errors.New("unrecognized filesystem type") + +// Mount mounts the primary system partition of the SIF file at path into mountPath. +// +// Mount may start one or more underlying processes. By default, stdout and stderr of these +// processes is discarded. To modify this behavior, consider using OptMountStdout and/or +// OptMountStderr. +func Mount(ctx context.Context, path, mountPath string, opts ...MountOpt) error { + mo := mountOpts{} + + for _, opt := range opts { + if err := opt(&mo); err != nil { + return fmt.Errorf("%w", err) + } + } + + f, err := sif.LoadContainerFromPath(path, sif.OptLoadWithFlag(os.O_RDONLY)) + if err != nil { + return fmt.Errorf("failed to load image: %w", err) + } + defer func() { _ = f.UnloadContainer() }() + + d, err := f.GetDescriptor(sif.WithPartitionType(sif.PartPrimSys)) + if err != nil { + return fmt.Errorf("failed to get partition descriptor: %w", err) + } + + fs, _, _, err := d.PartitionMetadata() + if err != nil { + return fmt.Errorf("failed to get partition metadata: %w", err) + } + + switch fs { + case sif.FsSquash: + return mountSquashFS(ctx, d.Offset(), path, mountPath, mo) + default: + return errUnsupportedFSType + } +} diff --git a/pkg/siftool/add_test.go b/pkg/siftool/add_test.go index 6d51a33a..ccbb5b34 100644 --- a/pkg/siftool/add_test.go +++ b/pkg/siftool/add_test.go @@ -1,4 +1,4 @@ -// Copyright (c) 2021, Sylabs Inc. All rights reserved. +// Copyright (c) 2021-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. @@ -46,7 +46,7 @@ func Test_command_getAdd(t *testing.T) { } args = append(args, tt.flags...) - runCommand(t, cmd, args) + runCommand(t, cmd, args, nil) }) } } diff --git a/pkg/siftool/del_test.go b/pkg/siftool/del_test.go index 13fad073..070d609d 100644 --- a/pkg/siftool/del_test.go +++ b/pkg/siftool/del_test.go @@ -1,4 +1,4 @@ -// Copyright (c) 2021, Sylabs Inc. All rights reserved. +// Copyright (c) 2021-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. @@ -24,7 +24,7 @@ func Test_command_getDel(t *testing.T) { cmd := c.getDel() - runCommand(t, cmd, []string{"1", makeTestSIF(t, true)}) + runCommand(t, cmd, []string{"1", makeTestSIF(t, true)}, nil) }) } } diff --git a/pkg/siftool/dump_test.go b/pkg/siftool/dump_test.go index 20362b80..fb13eb46 100644 --- a/pkg/siftool/dump_test.go +++ b/pkg/siftool/dump_test.go @@ -1,4 +1,4 @@ -// Copyright (c) 2021, Sylabs Inc. All rights reserved. +// Copyright (c) 2021-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. @@ -39,7 +39,7 @@ func Test_command_getDump(t *testing.T) { cmd := c.getDump() - runCommand(t, cmd, []string{tt.id, tt.path}) + runCommand(t, cmd, []string{tt.id, tt.path}, nil) }) } } diff --git a/pkg/siftool/header_test.go b/pkg/siftool/header_test.go index 5c194984..7aeb615d 100644 --- a/pkg/siftool/header_test.go +++ b/pkg/siftool/header_test.go @@ -1,4 +1,4 @@ -// Copyright (c) 2021, Sylabs Inc. All rights reserved. +// Copyright (c) 2021-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. @@ -68,7 +68,7 @@ func Test_command_getHeader(t *testing.T) { cmd := c.getHeader() - runCommand(t, cmd, []string{tt.path}) + runCommand(t, cmd, []string{tt.path}, nil) }) } } diff --git a/pkg/siftool/info_test.go b/pkg/siftool/info_test.go index 87f3a469..963b00a0 100644 --- a/pkg/siftool/info_test.go +++ b/pkg/siftool/info_test.go @@ -1,4 +1,4 @@ -// Copyright (c) 2021, Sylabs Inc. All rights reserved. +// Copyright (c) 2021-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. @@ -39,7 +39,7 @@ func Test_command_getInfo(t *testing.T) { cmd := c.getInfo() - runCommand(t, cmd, []string{tt.id, tt.path}) + runCommand(t, cmd, []string{tt.id, tt.path}, nil) }) } } diff --git a/pkg/siftool/list_test.go b/pkg/siftool/list_test.go index b7c1d327..5a4a47b0 100644 --- a/pkg/siftool/list_test.go +++ b/pkg/siftool/list_test.go @@ -1,4 +1,4 @@ -// Copyright (c) 2021, Sylabs Inc. All rights reserved. +// Copyright (c) 2021-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. @@ -68,7 +68,7 @@ func Test_command_getList(t *testing.T) { cmd := c.getList() - runCommand(t, cmd, []string{tt.path}) + runCommand(t, cmd, []string{tt.path}, nil) }) } } diff --git a/pkg/siftool/mount.go b/pkg/siftool/mount.go new file mode 100644 index 00000000..6b595572 --- /dev/null +++ b/pkg/siftool/mount.go @@ -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" +) + +// getMount returns a command that mounts the primary system partition of a SIF image. +func (c *command) getMount() *cobra.Command { + return &cobra.Command{ + Use: "mount ", + Short: "Mount primary system partition", + Long: "Mount the primary system partition of a SIF image", + Example: c.opts.rootPath + " mount image.sif path/", + Args: cobra.ExactArgs(2), + PreRunE: c.initApp, + RunE: func(cmd *cobra.Command, args []string) error { + return c.app.Mount(cmd.Context(), args[0], args[1]) + }, + DisableFlagsInUseLine: true, + Hidden: true, // hide while command is experimental + } +} diff --git a/pkg/siftool/mount_test.go b/pkg/siftool/mount_test.go new file mode 100644 index 00000000..3b2848ec --- /dev/null +++ b/pkg/siftool/mount_test.go @@ -0,0 +1,61 @@ +// 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 ( + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/sylabs/sif/v2/pkg/sif" +) + +func Test_command_getMount(t *testing.T) { + if _, err := exec.LookPath("squashfuse"); err != nil { + t.Skip("squashfuse not found, skipping mount tests") + } + + tests := []struct { + name string + opts commandOpts + path string + wantErr error + }{ + { + name: "Empty", + path: filepath.Join(corpus, "empty.sif"), + wantErr: sif.ErrNoObjects, + }, + { + name: "OneGroup", + path: filepath.Join(corpus, "one-group.sif"), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + path, err := os.MkdirTemp("", "siftool-mount-*") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + cmd := exec.Command("fusermount", "-u", path) + + if err := cmd.Run(); err != nil { + t.Log(err) + } + + os.RemoveAll(path) + }) + + c := &command{opts: tt.opts} + + cmd := c.getMount() + + runCommand(t, cmd, []string{tt.path, path}, tt.wantErr) + }) + } +} diff --git a/pkg/siftool/new_test.go b/pkg/siftool/new_test.go index af4cd757..81bcd587 100644 --- a/pkg/siftool/new_test.go +++ b/pkg/siftool/new_test.go @@ -1,4 +1,4 @@ -// Copyright (c) 2021, Sylabs Inc. All rights reserved. +// Copyright (c) 2021-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. @@ -32,7 +32,7 @@ func Test_command_getNew(t *testing.T) { cmd := c.getNew() - runCommand(t, cmd, []string{tf.Name()}) + runCommand(t, cmd, []string{tf.Name()}, nil) }) } } diff --git a/pkg/siftool/setprim_test.go b/pkg/siftool/setprim_test.go index e37b240a..13cd0e34 100644 --- a/pkg/siftool/setprim_test.go +++ b/pkg/siftool/setprim_test.go @@ -1,4 +1,4 @@ -// Copyright (c) 2021, Sylabs Inc. All rights reserved. +// Copyright (c) 2021-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. @@ -24,7 +24,7 @@ func Test_command_getSetPrim(t *testing.T) { cmd := c.getSetPrim() - runCommand(t, cmd, []string{"1", makeTestSIF(t, true)}) + runCommand(t, cmd, []string{"1", makeTestSIF(t, true)}, nil) }) } } diff --git a/pkg/siftool/siftool.go b/pkg/siftool/siftool.go index 4e624f1c..66fecf60 100644 --- a/pkg/siftool/siftool.go +++ b/pkg/siftool/siftool.go @@ -1,4 +1,4 @@ -// Copyright (c) 2018-2021, Sylabs Inc. All rights reserved. +// Copyright (c) 2018-2022, Sylabs Inc. All rights reserved. // Copyright (c) 2017, SingularityWare, LLC. All rights reserved. // Copyright (c) 2017, Yannick Cote All rights reserved. // This software is licensed under a 3-clause BSD license. Please consult the @@ -23,6 +23,7 @@ type command struct { func (c *command) initApp(cmd *cobra.Command, args []string) error { app, err := siftool.New( siftool.OptAppOutput(cmd.OutOrStdout()), + siftool.OptAppError(cmd.ErrOrStderr()), ) c.app = app @@ -31,12 +32,21 @@ func (c *command) initApp(cmd *cobra.Command, args []string) error { // commandOpts contains configured options. type commandOpts struct { - rootPath string + rootPath string + experimental bool } // CommandOpt are used to configure optional command behavior. type CommandOpt func(*commandOpts) error +// OptWithExperimental enables/disables experimental commands. +func OptWithExperimental(b bool) CommandOpt { + return func(co *commandOpts) error { + co.experimental = b + return nil + } +} + // AddCommands adds siftool commands to cmd according to opts. // // A set of commands are provided to display elements such as the SIF global @@ -66,5 +76,9 @@ func AddCommands(cmd *cobra.Command, opts ...CommandOpt) error { c.getSetPrim(), ) + if c.opts.experimental { + cmd.AddCommand(c.getMount()) + } + return nil } diff --git a/pkg/siftool/siftool_test.go b/pkg/siftool/siftool_test.go index 52362545..606d6eb3 100644 --- a/pkg/siftool/siftool_test.go +++ b/pkg/siftool/siftool_test.go @@ -1,4 +1,4 @@ -// Copyright (c) 2021, Sylabs Inc. All rights reserved. +// Copyright (c) 2021-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. @@ -6,6 +6,7 @@ package siftool import ( "bytes" + "errors" "os" "path/filepath" "testing" @@ -47,7 +48,7 @@ func makeTestSIF(t *testing.T, withDataObject bool) string { return tf.Name() } -func runCommand(t *testing.T, cmd *cobra.Command, args []string) { +func runCommand(t *testing.T, cmd *cobra.Command, args []string, wantErr error) { t.Helper() var out, err bytes.Buffer @@ -56,8 +57,8 @@ func runCommand(t *testing.T, cmd *cobra.Command, args []string) { cmd.SetArgs(args) - if err := cmd.Execute(); err != nil { - t.Fatal(err) + if got, want := cmd.Execute(), wantErr; !errors.Is(got, want) { + t.Fatalf("got error %v, want %v", got, want) } g := goldie.New(t, @@ -78,6 +79,11 @@ func TestAddCommands(t *testing.T) { name: "SifTool", args: []string{"help"}, }, + { + name: "SifToolExperimental", + opts: []CommandOpt{OptWithExperimental(true)}, + args: []string{"help"}, + }, { name: "Add", args: []string{"help", "add"}, @@ -110,6 +116,11 @@ func TestAddCommands(t *testing.T) { name: "SetPrim", args: []string{"help", "setprim"}, }, + { + name: "Mount", + opts: []CommandOpt{OptWithExperimental(true)}, + args: []string{"help", "mount"}, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -121,7 +132,7 @@ func TestAddCommands(t *testing.T) { t.Fatal(err) } - runCommand(t, cmd, tt.args) + runCommand(t, cmd, tt.args, nil) }) } } diff --git a/pkg/siftool/testdata/TestAddCommands/Mount/err.golden b/pkg/siftool/testdata/TestAddCommands/Mount/err.golden new file mode 100644 index 00000000..e69de29b diff --git a/pkg/siftool/testdata/TestAddCommands/Mount/out.golden b/pkg/siftool/testdata/TestAddCommands/Mount/out.golden new file mode 100644 index 00000000..3df015d5 --- /dev/null +++ b/pkg/siftool/testdata/TestAddCommands/Mount/out.golden @@ -0,0 +1,10 @@ +Mount the primary system partition of a SIF image + +Usage: + siftool mount + +Examples: +siftool mount image.sif path/ + +Flags: + -h, --help help for mount diff --git a/pkg/siftool/testdata/TestAddCommands/SifToolExperimental/err.golden b/pkg/siftool/testdata/TestAddCommands/SifToolExperimental/err.golden new file mode 100644 index 00000000..e69de29b diff --git a/pkg/siftool/testdata/TestAddCommands/SifToolExperimental/out.golden b/pkg/siftool/testdata/TestAddCommands/SifToolExperimental/out.golden new file mode 100644 index 00000000..2d8532f5 --- /dev/null +++ b/pkg/siftool/testdata/TestAddCommands/SifToolExperimental/out.golden @@ -0,0 +1,19 @@ +Usage: + siftool [command] + +Available Commands: + add Add data object + completion Generate the autocompletion script for the specified shell + del Delete data object + dump Dump data object + header Display global header + help Help about any command + info Display data object info + list List data objects + new Create SIF image + setprim Set primary system partition + +Flags: + -h, --help help for siftool + +Use "siftool [command] --help" for more information about a command. diff --git a/pkg/siftool/testdata/Test_command_getMount/Empty/err.golden b/pkg/siftool/testdata/Test_command_getMount/Empty/err.golden new file mode 100644 index 00000000..cd860b84 --- /dev/null +++ b/pkg/siftool/testdata/Test_command_getMount/Empty/err.golden @@ -0,0 +1 @@ +Error: failed to get partition descriptor: no objects in image diff --git a/pkg/siftool/testdata/Test_command_getMount/Empty/out.golden b/pkg/siftool/testdata/Test_command_getMount/Empty/out.golden new file mode 100644 index 00000000..f22522a8 --- /dev/null +++ b/pkg/siftool/testdata/Test_command_getMount/Empty/out.golden @@ -0,0 +1,9 @@ +Usage: + mount + +Examples: + mount image.sif path/ + +Flags: + -h, --help help for mount + diff --git a/pkg/siftool/testdata/Test_command_getMount/OneGroup/err.golden b/pkg/siftool/testdata/Test_command_getMount/OneGroup/err.golden new file mode 100644 index 00000000..e69de29b diff --git a/pkg/siftool/testdata/Test_command_getMount/OneGroup/out.golden b/pkg/siftool/testdata/Test_command_getMount/OneGroup/out.golden new file mode 100644 index 00000000..e69de29b