diff --git a/Dockerfile b/Dockerfile index b1851bb..12dd1a5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,14 +28,6 @@ RUN apk add --no-cache e2fsprogs e2fsprogs-extra xfsprogs xfsprogs-extra blkid u # Create symbolic link for chroot.sh WORKDIR / -RUN mkdir /csibin -COPY chroot/chroot.sh /csibin -RUN chmod 777 /csibin/chroot.sh \ - && ln -s /csibin/chroot.sh /csibin/iscsiadm \ - && ln -s /csibin/chroot.sh /csibin/multipath \ - && ln -s /csibin/chroot.sh /csibin/multipathd - -ENV PATH="/csibin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" # Copy and run CSI driver COPY --from=builder /go/src/synok8scsiplugin/bin/synology-csi-driver synology-csi-driver diff --git a/chroot/chroot.sh b/chroot/chroot.sh deleted file mode 100755 index fc43a8b..0000000 --- a/chroot/chroot.sh +++ /dev/null @@ -1,12 +0,0 @@ -#!/usr/bin/env bash -# This script is only used in the container, see Dockerfile. - -DIR="/host" # csi-node mount / of the node to /host in the container -BIN="$(basename "$0")" - -if [ -d "$DIR" ]; then - exec chroot $DIR /usr/bin/env -i PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" "$BIN" "$@" -fi - -echo -n "Couldn't find hostPath: $DIR in the CSI container" -exit 1 \ No newline at end of file diff --git a/main.go b/main.go index da129d9..089dcd4 100644 --- a/main.go +++ b/main.go @@ -8,13 +8,15 @@ import ( "os" "os/signal" "syscall" - "github.com/spf13/cobra" + log "github.com/sirupsen/logrus" + "github.com/spf13/cobra" "github.com/SynologyOpenSource/synology-csi/pkg/driver" "github.com/SynologyOpenSource/synology-csi/pkg/dsm/common" "github.com/SynologyOpenSource/synology-csi/pkg/dsm/service" "github.com/SynologyOpenSource/synology-csi/pkg/logger" + "github.com/SynologyOpenSource/synology-csi/pkg/utils/hostexec" ) var ( @@ -23,16 +25,21 @@ var ( csiEndpoint = "unix:///var/lib/kubelet/plugins/" + driver.DriverName + "/csi.sock" csiClientInfoPath = "/etc/synology/client-info.yml" // Logging - logLevel = "info" - webapiDebug = false + logLevel = "info" + webapiDebug = false multipathForUC = true + // Locations is tools and directories + chrootDir = "/host" + iscsiadmPath = "" + multipathPath = "" + multipathdPath = "" ) var rootCmd = &cobra.Command{ - Use: "synology-csi-driver", - Short: "Synology CSI Driver", + Use: "synology-csi-driver", + Short: "Synology CSI Driver", SilenceUsage: true, - RunE: func(cmd *cobra.Command, args []string) error { + RunE: func(_ *cobra.Command, _ []string) error { if webapiDebug { logger.WebapiDebug = true logLevel = "debug" @@ -72,8 +79,21 @@ func driverStart() error { } defer dsmService.RemoveAllDsms() - // 2. Create and Run the Driver - drv, err := driver.NewControllerAndNodeDriver(csiNodeID, csiEndpoint, dsmService) + // 2. Create command executor + cmdMap := map[string]string{ + "iscsiadm": iscsiadmPath, + "multipath": multipathPath, + "multipathd": multipathdPath, + } + cmdExecutor, err := hostexec.New(cmdMap, chrootDir) + if err != nil { + log.Errorf("Failed to create command executor: %v", err) + return err + } + tools := driver.NewTools(cmdExecutor) + + // 3. Create and Run the Driver + drv, err := driver.NewControllerAndNodeDriver(csiNodeID, csiEndpoint, dsmService, tools) if err != nil { log.Errorf("Failed to create driver: %v", err) return err @@ -105,6 +125,10 @@ func addFlags(cmd *cobra.Command) { cmd.PersistentFlags().StringVar(&logLevel, "log-level", logLevel, "Log level (debug, info, warn, error, fatal)") cmd.PersistentFlags().BoolVarP(&webapiDebug, "debug", "d", webapiDebug, "Enable webapi debugging logs") cmd.PersistentFlags().BoolVar(&multipathForUC, "multipath", multipathForUC, "Set to 'false' to disable multipath for UC") + cmd.PersistentFlags().StringVar(&chrootDir, "chroot-dir", chrootDir, "Host directory to chroot into (empty disables chroot)") + cmd.PersistentFlags().StringVar(&iscsiadmPath, "iscsiadm-path", iscsiadmPath, "Full path of iscsiadm executable") + cmd.PersistentFlags().StringVar(&multipathPath, "multipath-path", multipathPath, "Full path of multipath executable") + cmd.PersistentFlags().StringVar(&multipathdPath, "multipathd-path", multipathdPath, "Full path of multipathd executable") cmd.MarkFlagRequired("endpoint") cmd.MarkFlagRequired("client-info") diff --git a/pkg/driver/driver.go b/pkg/driver/driver.go index df22b9c..93f59d2 100644 --- a/pkg/driver/driver.go +++ b/pkg/driver/driver.go @@ -17,19 +17,19 @@ limitations under the License. package driver import ( - "github.com/container-storage-interface/spec/lib/go/csi" - log "github.com/sirupsen/logrus" "github.com/SynologyOpenSource/synology-csi/pkg/interfaces" "github.com/SynologyOpenSource/synology-csi/pkg/utils" + "github.com/container-storage-interface/spec/lib/go/csi" + log "github.com/sirupsen/logrus" ) const ( - DriverName = "csi.san.synology.com" // CSI dirver name + DriverName = "csi.san.synology.com" // CSI dirver name DriverVersion = "1.2.0" ) var ( - MultipathEnabled = true + MultipathEnabled = true supportedProtocolList = []string{utils.ProtocolIscsi, utils.ProtocolSmb, utils.ProtocolNfs} allowedNfsVersionList = []string{"3", "4", "4.0", "4.1"} ) @@ -44,13 +44,14 @@ type Driver struct { nodeID string version string endpoint string + tools tools csCap []*csi.ControllerServiceCapability vCap []*csi.VolumeCapability_AccessMode nsCap []*csi.NodeServiceCapability DsmService interfaces.IDsmService } -func NewControllerAndNodeDriver(nodeID string, endpoint string, dsmService interfaces.IDsmService) (*Driver, error) { +func NewControllerAndNodeDriver(nodeID string, endpoint string, dsmService interfaces.IDsmService, tools tools) (*Driver, error) { log.Debugf("NewControllerAndNodeDriver: DriverName: %v, DriverVersion: %v", DriverName, DriverVersion) // TODO version format and validation @@ -60,6 +61,7 @@ func NewControllerAndNodeDriver(nodeID string, endpoint string, dsmService inter nodeID: nodeID, endpoint: endpoint, DsmService: dsmService, + tools: tools, } d.addControllerServiceCapabilities([]csi.ControllerServiceCapability_RPC_Type{ diff --git a/pkg/driver/initiator.go b/pkg/driver/initiator.go index 2bbd147..b8c5699 100644 --- a/pkg/driver/initiator.go +++ b/pkg/driver/initiator.go @@ -24,6 +24,7 @@ import ( "strconv" "strings" + "github.com/SynologyOpenSource/synology-csi/pkg/utils/hostexec" log "github.com/sirupsen/logrus" utilexec "k8s.io/utils/exec" ) @@ -31,6 +32,7 @@ import ( type initiatorDriver struct { chapUser string chapPassword string + tools tools } type iscsiSession struct { @@ -45,10 +47,19 @@ const ( ISCSIPort = 3260 ) -func iscsiadm(cmdArgs ...string) utilexec.Cmd { - executor := utilexec.New() +type tools struct { + executor hostexec.Executor +} - return executor.Command("iscsiadm", cmdArgs...) +// NewTools creates a new tools wrapper for calling utilities with given executor +func NewTools(executor hostexec.Executor) tools { + return tools{ + executor: executor, + } +} + +func (t *tools) iscsiadm(cmdArgs ...string) utilexec.Cmd { + return t.executor.Command("iscsiadm", cmdArgs...) } // parseSession takes the raw stdout from the `iscsiadm -m session` command and encodes it into an iSCSI session type @@ -81,8 +92,8 @@ func parseSessions(lines string) []iscsiSession { return sessions } -func iscsiadm_session() []iscsiSession { - cmd := iscsiadm("-m", "session") +func (t *tools) iscsiadm_session() []iscsiSession { + cmd := t.iscsiadm("-m", "session") out, err := cmd.CombinedOutput() if err != nil { exitErr, ok := err.(utilexec.ExitError) @@ -97,8 +108,8 @@ func iscsiadm_session() []iscsiSession { return parseSessions(string(out)) } -func iscsiadm_discovery(portal string) error { - cmd := iscsiadm( +func (t *tools) iscsiadm_discovery(portal string) error { + cmd := t.iscsiadm( "-m", "discoverydb", "--type", "sendtargets", "--portal", portal, @@ -110,8 +121,8 @@ func iscsiadm_discovery(portal string) error { return nil } -func iscsiadm_login(iqn, portal string) error { - cmd := iscsiadm( +func (t *tools) iscsiadm_login(iqn, portal string) error { + cmd := t.iscsiadm( "-m", "node", "--targetname", iqn, "--portal", portal, @@ -123,8 +134,8 @@ func iscsiadm_login(iqn, portal string) error { return nil } -func iscsiadm_update_node_startup(iqn, portal string) error { - cmd := iscsiadm( +func (t *tools) iscsiadm_update_node_startup(iqn, portal string) error { + cmd := t.iscsiadm( "-m", "node", "--targetname", iqn, "--portal", portal, @@ -138,8 +149,8 @@ func iscsiadm_update_node_startup(iqn, portal string) error { return nil } -func iscsiadm_logout(iqn string) error { - cmd := iscsiadm( +func (t *tools) iscsiadm_logout(iqn string) error { + cmd := t.iscsiadm( "-m", "node", "--targetname", iqn, "--logout") @@ -149,8 +160,8 @@ func iscsiadm_logout(iqn string) error { return nil } -func iscsiadm_rescan(iqn string) error { - cmd := iscsiadm( +func (t *tools) iscsiadm_rescan(iqn string) error { + cmd := t.iscsiadm( "-m", "node", "--targetname", iqn, "-R") @@ -160,8 +171,8 @@ func iscsiadm_rescan(iqn string) error { return nil } -func hasSession(targetIqn string, portal string) bool { - sessions := iscsiadm_session() +func (t *tools) hasSession(targetIqn string, portal string) bool { + sessions := t.iscsiadm_session() for _, s := range sessions { if targetIqn == s.Iqn && (portal == s.Portal || portal == "") { @@ -172,8 +183,8 @@ func hasSession(targetIqn string, portal string) bool { return false } -func listSessionsByIqn(targetIqn string) (matchedSessions []iscsiSession) { - sessions := iscsiadm_session() +func (t *tools) listSessionsByIqn(targetIqn string) (matchedSessions []iscsiSession) { + sessions := t.iscsiadm_session() for _, s := range sessions { if targetIqn == s.Iqn { @@ -185,22 +196,22 @@ func listSessionsByIqn(targetIqn string) (matchedSessions []iscsiSession) { } func (d *initiatorDriver) login(targetIqn string, portal string) error { - if hasSession(targetIqn, portal) { + if d.tools.hasSession(targetIqn, portal) { log.Infof("Session[%s] already exists.", targetIqn) return nil } - if err := iscsiadm_discovery(portal); err != nil { + if err := d.tools.iscsiadm_discovery(portal); err != nil { log.Errorf("Failed in discovery of the target: %v", err) return err } - if err := iscsiadm_login(targetIqn, portal); err != nil { + if err := d.tools.iscsiadm_login(targetIqn, portal); err != nil { log.Errorf("Failed in login of the target: %v", err) return err } - if err := iscsiadm_update_node_startup(targetIqn, portal); err != nil { + if err := d.tools.iscsiadm_update_node_startup(targetIqn, portal); err != nil { log.Warnf("Failed to update target node.startup to manual: %v", err) } @@ -210,13 +221,13 @@ func (d *initiatorDriver) login(targetIqn string, portal string) error { } func (d *initiatorDriver) logout(targetIqn string, ip string) error { - if !hasSession(targetIqn, "") { + if !d.tools.hasSession(targetIqn, "") { log.Infof("Session[%s] doesn't exist.", targetIqn) return nil } portal := fmt.Sprintf("%s:%d", ip, ISCSIPort) - if err := iscsiadm_logout(targetIqn); err != nil { + if err := d.tools.iscsiadm_logout(targetIqn); err != nil { log.Errorf("Failed in logout of the target.\nTarget [%s], Portal [%s], Err[%v]", targetIqn, portal, err) return err @@ -228,13 +239,13 @@ func (d *initiatorDriver) logout(targetIqn string, ip string) error { } func (d *initiatorDriver) rescan(targetIqn string) error { - if !hasSession(targetIqn, "") { + if !d.tools.hasSession(targetIqn, "") { msg := fmt.Sprintf("Session[%s] doesn't exist.", targetIqn) log.Error(msg) return errors.New(msg) } - if err := iscsiadm_rescan(targetIqn); err != nil { + if err := d.tools.iscsiadm_rescan(targetIqn); err != nil { log.Errorf("Failed in rescan of the target.\nTarget [%s], Err[%v]", targetIqn, err) return err diff --git a/pkg/driver/multipath.go b/pkg/driver/multipath.go index fab3638..14af554 100644 --- a/pkg/driver/multipath.go +++ b/pkg/driver/multipath.go @@ -21,21 +21,23 @@ package driver import ( "context" "fmt" - log "github.com/sirupsen/logrus" "os" "regexp" "strings" "time" + + log "github.com/sirupsen/logrus" utilexec "k8s.io/utils/exec" ) -func IsMultipathEnabled() bool { - return MultipathEnabled && multipathd_is_running() +// IsMultipathEnabled returns true if multipath is enabled + +func (t *tools) IsMultipathEnabled() bool { + return MultipathEnabled && t.multipathd_is_running() } -func multipathd_is_running() bool { - executor := utilexec.New() - cmd := executor.Command("multipathd", "show", "daemon") +func (t *tools) multipathd_is_running() bool { + cmd := t.executor.Command("multipathd", "show", "daemon") out, err := cmd.CombinedOutput() if err == nil { matched, _ := regexp.MatchString(`pid \d+ (running|idle)`, string(out)) @@ -49,15 +51,14 @@ func multipathd_is_running() bool { } // execute a command with a timeout and returns an error if timeout is exceeded -func execWithTimeout(command string, args []string, timeout time.Duration) ([]byte, error) { +func (t *tools) execWithTimeout(command string, args []string, timeout time.Duration) ([]byte, error) { log.Infof("Executing command '%v' with args: '%v'.", command, args) // Create a new context and add a timeout to it ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() - executor := utilexec.New() - cmd := executor.CommandContext(ctx, command, args...) + cmd := t.executor.CommandContext(ctx, command, args...) out, err := cmd.Output() log.Debug(err) @@ -81,9 +82,8 @@ func execWithTimeout(command string, args []string, timeout time.Duration) ([]by } // resize a multipath device based on its underlying devices -func multipath_resize(devName string) error { - executor := utilexec.New() - cmd := executor.Command("multipathd", "resize", "map", devName) // use devName not devPath, or it'll fail +func (t *tools) multipath_resize(devName string) error { + cmd := t.executor.Command("multipathd", "resize", "map", devName) // use devName not devPath, or it'll fail out, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("%s (%v)", string(out), err) @@ -92,9 +92,9 @@ func multipath_resize(devName string) error { } // flushes a multipath device dm-x with command multipath -f /dev/dm-x -func multipath_flush(devPath string) error { +func (t *tools) multipath_flush(devPath string) error { timeout := 5 * time.Second - out, err := execWithTimeout("multipath", []string{"-f", devPath}, timeout) + out, err := t.execWithTimeout("multipath", []string{"-f", devPath}, timeout) if err != nil { if _, e := os.Stat(devPath); os.IsNotExist(e) { log.Debugf("Multipath device %v has been removed.", devPath) diff --git a/pkg/driver/nodeserver.go b/pkg/driver/nodeserver.go index 0d805db..4e374e6 100644 --- a/pkg/driver/nodeserver.go +++ b/pkg/driver/nodeserver.go @@ -28,12 +28,12 @@ import ( "github.com/cenkalti/backoff/v4" "github.com/container-storage-interface/spec/lib/go/csi" log "github.com/sirupsen/logrus" + "golang.org/x/sys/unix" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" - "golang.org/x/sys/unix" - "k8s.io/mount-utils" - clientset "k8s.io/client-go/kubernetes" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + clientset "k8s.io/client-go/kubernetes" + "k8s.io/mount-utils" "github.com/SynologyOpenSource/synology-csi/pkg/dsm/webapi" "github.com/SynologyOpenSource/synology-csi/pkg/interfaces" @@ -47,6 +47,7 @@ type nodeServer struct { dsmService interfaces.IDsmService Initiator *initiatorDriver Client clientset.Interface + tools tools } func waitForDevicePathToExist(path string) error { @@ -73,10 +74,10 @@ func waitForDevicePathToExist(path string) error { } // for unstage, resize volume -func getExistedVolumeMountPath(targetIqn string, mappingIndex int) string { +func (t *tools) getExistedVolumeMountPath(targetIqn string, mappingIndex int) string { paths := []string{} - sessions := listSessionsByIqn(targetIqn) + sessions := t.listSessionsByIqn(targetIqn) for _, session := range sessions { paths = append(paths, fmt.Sprintf("%sip-%s-iscsi-%s-lun-%d", "/dev/disk/by-path/", session.Portal, targetIqn, mappingIndex)) } @@ -175,7 +176,7 @@ func (ns *nodeServer) getPortals(dsmIp string) []string { portals = append(portals, fmt.Sprintf("%s:%d", ips[0], ISCSIPort)) //get the first ip } - if dsm.IsUC() && IsMultipathEnabled() { + if dsm.IsUC() && ns.tools.IsMultipathEnabled() { dsm2, err := dsm.GetAnotherController() if err != nil { log.Errorf("[%s] UC failed to get another controller: %v", err) @@ -228,10 +229,10 @@ func (ns *nodeServer) logoutTarget(volumeId string) { // Assume target and lun 1-1 mapping mappingIndex := k8sVolume.Target.MappedLuns[0].MappingIndex - volumeMountPath := getExistedVolumeMountPath(k8sVolume.Target.Iqn, mappingIndex) + volumeMountPath := ns.tools.getExistedVolumeMountPath(k8sVolume.Target.Iqn, mappingIndex) - if strings.Contains(volumeMountPath, "/dev/mapper") && IsMultipathEnabled() { - if err := multipath_flush(volumeMountPath); err != nil { + if strings.Contains(volumeMountPath, "/dev/mapper") && ns.tools.IsMultipathEnabled() { + if err := ns.tools.multipath_flush(volumeMountPath); err != nil { log.Errorf("Failed to remove multipath device in path %s. err: %v", volumeMountPath, err) } } @@ -567,7 +568,7 @@ func (ns *nodeServer) NodePublishVolume(ctx context.Context, req *csi.NodePublis if req.VolumeContext["protocol"] == utils.ProtocolNfs { options = append(options, req.GetVolumeCapability().GetMount().GetMountFlags()...) - var server, baseDir string //NFSTODO: subDir + var server, baseDir string //NFSTODO: subDir var mountPermissionsUint uint64 = 0750 // default for k, v := range req.GetVolumeContext() { switch k { @@ -804,13 +805,13 @@ func (ns *nodeServer) NodeExpandVolume(ctx context.Context, req *csi.NodeExpandV // Assume target and lun 1-1 mapping mappingIndex := k8sVolume.Target.MappedLuns[0].MappingIndex - volumeMountPath := getExistedVolumeMountPath(k8sVolume.Target.Iqn, mappingIndex) + volumeMountPath := ns.tools.getExistedVolumeMountPath(k8sVolume.Target.Iqn, mappingIndex) if volumeMountPath == "" { return nil, status.Error(codes.Internal, "Can't get volume mount path") } - if strings.Contains(volumeMountPath, "/dev/mapper") && IsMultipathEnabled() { - if err := multipath_resize(filepath.Base(volumeMountPath)); err != nil { + if strings.Contains(volumeMountPath, "/dev/mapper") && ns.tools.IsMultipathEnabled() { + if err := ns.tools.multipath_resize(filepath.Base(volumeMountPath)); err != nil { return nil, status.Error(codes.Internal, fmt.Sprintf("Failed to resize multipath device in %s. err: %v", volumeMountPath, err)) } } diff --git a/pkg/driver/utils.go b/pkg/driver/utils.go index 7cac6ba..90ee99c 100644 --- a/pkg/driver/utils.go +++ b/pkg/driver/utils.go @@ -24,13 +24,13 @@ import ( "strings" "github.com/container-storage-interface/spec/lib/go/csi" - log "github.com/sirupsen/logrus" "github.com/kubernetes-csi/csi-lib-utils/protosanitizer" + log "github.com/sirupsen/logrus" "google.golang.org/grpc" clientset "k8s.io/client-go/kubernetes" "k8s.io/client-go/rest" - "k8s.io/utils/exec" "k8s.io/mount-utils" + "k8s.io/utils/exec" ) func ParseEndpoint(ep string) (string, string, error) { @@ -45,7 +45,7 @@ func ParseEndpoint(ep string) (string, string, error) { func NewControllerServer(d *Driver) *controllerServer { return &controllerServer{ - Driver: d, + Driver: d, dsmService: d.DsmService, } } @@ -65,17 +65,19 @@ func getK8sClient() clientset.Interface { func NewNodeServer(d *Driver) *nodeServer { return &nodeServer{ - Driver: d, + Driver: d, dsmService: d.DsmService, Mounter: &mount.SafeFormatAndMount{ Interface: mount.New(""), - Exec: exec.New(), + Exec: exec.New(), }, Initiator: &initiatorDriver{ - chapUser: "", + chapUser: "", chapPassword: "", + tools: d.tools, }, Client: getK8sClient(), + tools: d.tools, } } diff --git a/pkg/utils/hostexec/hostexec.go b/pkg/utils/hostexec/hostexec.go new file mode 100644 index 0000000..d961ab1 --- /dev/null +++ b/pkg/utils/hostexec/hostexec.go @@ -0,0 +1,99 @@ +// Package hostexec automatically wraps commands executed with kubernetes hostexec into +// chrooted commands +package hostexec + +import ( + "context" + "errors" + "fmt" + "os" + "strings" + + "k8s.io/utils/exec" +) + +// defaultSearchPath for running commands without absolute paths +var defaultSearchPath = []string{ + "/usr/local/sbin", + "/usr/local/bin", + "/usr/sbin", + "/usr/bin", + "/sbin", + "/bin", +} + +// Executor is mostly k8s.io/utils/exec compatible interface for the portions +// that synology-csi uses. +type Executor interface { + Command(string, ...string) exec.Cmd + CommandContext(context.Context, string, ...string) exec.Cmd +} + +type hostexec struct { + Executor + commandMap map[string]string + chrootDir string +} + +// New creates an instance of hostexec to execute commands in the given environment +func New(cmdMap map[string]string, chrootDir string) (Executor, error) { + // If chroot directory is defined, check that directory exists or return an error + if chrootDir != "" { + fileinfo, err := os.Stat(chrootDir) + if err != nil || !fileinfo.IsDir() { + return nil, errors.New("chroot directory does not exist or is not a directory") + } + } + + return &hostexec{exec.New(), cmdMap, chrootDir}, nil +} + +func (h *hostexec) resolveCmd(cmd string, args ...string) (string, []string) { + c, ok := h.commandMap[cmd] + if !ok || c == "" { + return cmd, args + } + + return c, args +} + +func (h *hostexec) wrapEnv(cmd string, args ...string) (string, []string) { + if strings.ContainsAny(cmd, "/") { + return cmd, args + } + + sp := fmt.Sprintf("PATH=%s", strings.Join(defaultSearchPath, ":")) + args = append([]string{"-i", sp, cmd}, args...) + cmd = "/usr/bin/env" + + return cmd, args +} + +func (h *hostexec) wrapChroot(cmd string, args ...string) (string, []string) { + if h.chrootDir == "" { + return cmd, args + } + + args = append([]string{h.chrootDir, cmd}, args...) + cmd = "/usr/sbin/chroot" + + return cmd, args +} + +func (h *hostexec) wrap(cmd string, args ...string) (string, []string) { + cmd, args = h.resolveCmd(cmd, args...) + cmd, args = h.wrapEnv(cmd, args...) + cmd, args = h.wrapChroot(cmd, args...) + + return cmd, args +} + +func (h *hostexec) Command(cmd string, args ...string) exec.Cmd { + cmd, args = h.wrap(cmd, args...) + return h.Executor.Command(cmd, args...) +} + +func (h *hostexec) CommandContext(ctx context.Context, cmd string, args ...string) exec.Cmd { + cmd, args = h.wrap(cmd, args...) + return h.Executor.CommandContext(ctx, cmd, args...) +} diff --git a/pkg/utils/hostexec/hostexec_test.go b/pkg/utils/hostexec/hostexec_test.go new file mode 100644 index 0000000..82fa17f --- /dev/null +++ b/pkg/utils/hostexec/hostexec_test.go @@ -0,0 +1,393 @@ +package hostexec + +import ( + "context" + "io" + "os" + "reflect" + "testing" + + "k8s.io/utils/exec" +) + +func TestNew(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "chroot_test") + if err != nil { + t.Fatalf("Temporary directory creation failed: %v", err) + } + defer os.RemoveAll(tmpDir) + + tests := []struct { + name string + cmdMap map[string]string + chrootDir string + wantErr bool + }{ + { + name: "valid chroot directory", + cmdMap: nil, + chrootDir: tmpDir, + wantErr: false, + }, + { + name: "invalid chroot directory", + cmdMap: nil, + chrootDir: "/invalid/path", + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := New(tt.cmdMap, tt.chrootDir) + if (err != nil) != tt.wantErr { + t.Errorf("New() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestHostexec_wrapEnv(t *testing.T) { + tests := []struct { + name string + cmd string + args []string + wantCmd string + wantArgs []string + }{ + { + name: "empty args", + cmd: "echo", + args: nil, + wantCmd: "/usr/bin/env", + wantArgs: []string{"-i", "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", "echo"}, + }, + { + name: "non-empty args", + cmd: "echo", + args: []string{"hello", "world"}, + wantCmd: "/usr/bin/env", + wantArgs: []string{"-i", "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", "echo", "hello", "world"}, + }, + { + name: "fully qualified cmd without args", + cmd: "/bin/echo", + args: nil, + wantCmd: "/bin/echo", + wantArgs: nil, + }, + { + name: "fully qualified cmd with args", + cmd: "/bin/echo", + args: []string{"hello", "world"}, + wantCmd: "/bin/echo", + wantArgs: []string{"hello", "world"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + h := &hostexec{} + + c, a := h.wrapEnv(tt.cmd, tt.args...) + if c != tt.wantCmd { + t.Errorf("wrapEnv() c = %v, want %v", c, tt.wantCmd) + } + if !reflect.DeepEqual(a, tt.wantArgs) { + t.Errorf("wrapEnv() a = %v, want %v", a, tt.wantArgs) + } + }) + } +} + +func TestHostexec_resolveCmd(t *testing.T) { + tests := []struct { + name string + cmd string + cmdArgs []string + cmdMap map[string]string + wantCmd string + wantCmdArgs []string + }{ + { + name: "empty cmd map", + cmd: "echo", + cmdArgs: []string{"hello", "world"}, + cmdMap: nil, + wantCmd: "echo", + wantCmdArgs: []string{"hello", "world"}, + }, + { + name: "non-empty cmd map", + cmd: "echo", + cmdArgs: []string{"hello", "world"}, + cmdMap: map[string]string{ + "echo": "/bin/echo", + }, + wantCmd: "/bin/echo", + wantCmdArgs: []string{"hello", "world"}, + }, + { + name: "non-empty cmd map without matching command", + cmd: "echo", + cmdArgs: []string{"hello", "world"}, + cmdMap: map[string]string{ + "cat": "/dummy/cat", + }, + wantCmd: "echo", + wantCmdArgs: []string{"hello", "world"}, + }, + { + name: "empty string mapping", + cmd: "echo", + cmdArgs: []string{"hello", "world"}, + cmdMap: map[string]string{ + "echo": "", + }, + wantCmd: "echo", + wantCmdArgs: []string{"hello", "world"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + h := &hostexec{commandMap: tt.cmdMap} + + c, a := h.resolveCmd(tt.cmd, tt.cmdArgs...) + if c != tt.wantCmd { + t.Errorf("resolveCmd() c = %v, want %v", c, tt.wantCmd) + } + if !reflect.DeepEqual(a, tt.wantCmdArgs) { + t.Errorf("resolveCmd() a = %v, want %v", a, tt.wantCmdArgs) + } + }) + } +} + +func TestHostexec_wrapChroot(t *testing.T) { + tests := []struct { + name string + chroot string + cmd string + args []string + wantCmd string + wantArgs []string + }{ + { + name: "empty chroot", + chroot: "", + cmd: "echo", + args: []string{"hello", "world"}, + wantCmd: "echo", + wantArgs: []string{"hello", "world"}, + }, + { + name: "non-empty chroot", + chroot: "/tmp", + cmd: "echo", + args: []string{"hello", "world"}, + wantCmd: "/usr/sbin/chroot", + wantArgs: []string{"/tmp", "echo", "hello", "world"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + h := &hostexec{chrootDir: tt.chroot} + + c, a := h.wrapChroot(tt.cmd, tt.args...) + if c != tt.wantCmd { + t.Errorf("wrapChroot() c = %v, want %v", c, tt.wantCmd) + } + if !reflect.DeepEqual(a, tt.wantArgs) { + t.Errorf("wrapChroot() a = %v, want %v", a, tt.wantArgs) + } + }) + } +} + +func TestHostexec_wrap(t *testing.T) { + tests := []struct { + name string + cmd string + args []string + chroot string + cmdMap map[string]string + wantCmd string + wantArgs []string + }{ + { + name: "no wrappers", + cmd: "/bin/echo", + args: []string{ + "hello", + "world", + }, + chroot: "", + cmdMap: nil, + wantCmd: "/bin/echo", + wantArgs: []string{ + "hello", + "world", + }, + }, + { + name: "env wrapper", + cmd: "echo", + args: []string{ + "hello", + "world", + }, + chroot: "", + cmdMap: nil, + wantCmd: "/usr/bin/env", + wantArgs: []string{ + "-i", "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", "echo", "hello", + "world", + }, + }, + { + name: "chroot wrapper", + cmd: "/bin/echo", + args: []string{"hello", "world"}, + chroot: "/tmp", + cmdMap: nil, + wantCmd: "/usr/sbin/chroot", + wantArgs: []string{ + "/tmp", + "/bin/echo", + "hello", + "world", + }, + }, + { + name: "cmd path wrapper", + cmd: "echo", + args: []string{ + "hello", + "world", + }, + chroot: "", + cmdMap: map[string]string{ + "echo": "/bin/echo", + }, + wantCmd: "/bin/echo", + wantArgs: []string{ + "hello", "world", + }, + }, + { + name: "chain wrappers", + cmd: "echo", + args: []string{"hello", "world"}, + chroot: "/tmp", + cmdMap: map[string]string{"echo": "/bin/echo"}, + wantCmd: "/usr/sbin/chroot", + wantArgs: []string{ + "/tmp", + "/bin/echo", + "hello", "world", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + h := &hostexec{ + commandMap: tt.cmdMap, + chrootDir: tt.chroot, + } + + c, a := h.wrap(tt.cmd, tt.args...) + if c != tt.wantCmd { + t.Errorf("wrap() c = %v, want %v", c, tt.wantCmd) + } + if !reflect.DeepEqual(a, tt.wantArgs) { + t.Errorf("wrap() a = %v, want %v", a, tt.wantArgs) + } + }) + } +} + +// dummyCmd statisfies the interface for exec.Cmd for testing purposes +type dummyCmd struct{} + +func (dummyCmd) Run() error { return nil } +func (dummyCmd) CombinedOutput() ([]byte, error) { return nil, nil } +func (dummyCmd) Output() ([]byte, error) { return nil, nil } +func (dummyCmd) SetDir(string) {} +func (dummyCmd) SetStdin(io.Reader) {} +func (dummyCmd) SetStdout(io.Writer) {} +func (dummyCmd) SetStderr(io.Writer) {} +func (dummyCmd) SetEnv([]string) {} +func (dummyCmd) StdoutPipe() (io.ReadCloser, error) { return nil, nil } +func (dummyCmd) StderrPipe() (io.ReadCloser, error) { return nil, nil } +func (dummyCmd) Start() error { return nil } +func (dummyCmd) Wait() error { return nil } +func (dummyCmd) Stop() {} + +// dummyInterface satisfies the interface for exec.Interface for testing purposes +type dummyInterface struct { + commandFunc func(string, ...string) exec.Cmd + commandContextFunc func(context.Context, string, ...string) exec.Cmd + lookPathFunc func(string) (string, error) +} + +func (d *dummyInterface) Command(cmd string, args ...string) exec.Cmd { + return d.commandFunc(cmd, args...) +} +func (d *dummyInterface) CommandContext(ctx context.Context, cmd string, args ...string) exec.Cmd { + return d.commandContextFunc(ctx, cmd, args...) +} +func (d *dummyInterface) LookPath(path string) (string, error) { return d.lookPathFunc(path) } + +func TestHostexec_Command(t *testing.T) { + successCmd := dummyCmd{} + + i := &dummyInterface{ + commandFunc: func(cmd string, args ...string) exec.Cmd { + if cmd != "/bin/echo" { + t.Errorf("Command() cmd = %v, want %v", cmd, "echo") + } + if !reflect.DeepEqual(args, []string{"hello", "world"}) { + t.Errorf("Command() args = %v, want %v", args, []string{"hello", "world"}) + } + return successCmd + }, + } + + h := &hostexec{ + Executor: i, + commandMap: map[string]string{ + "echo": "/bin/echo", + }, + } + + c := h.Command("echo", "hello", "world") + if c != successCmd { + t.Errorf("Command() c = %v, want %v", c, successCmd) + } +} + +func TestHostexec_CommandContext(t *testing.T) { + successCmd := dummyCmd{} + + i := &dummyInterface{ + commandContextFunc: func(_ context.Context, cmd string, args ...string) exec.Cmd { + if cmd != "/bin/echo" { + t.Errorf("Command() cmd = %v, want %v", cmd, "echo") + } + if !reflect.DeepEqual(args, []string{"hello", "world"}) { + t.Errorf("Command() args = %v, want %v", args, []string{"hello", "world"}) + } + return successCmd + }, + } + + h := &hostexec{ + Executor: i, + commandMap: map[string]string{ + "echo": "/bin/echo", + }, + } + + c := h.CommandContext(context.Background(), "echo", "hello", "world") + if c != successCmd { + t.Errorf("Command() c = %v, want %v", c, successCmd) + } +} diff --git a/test/sanity/sanity_test.go b/test/sanity/sanity_test.go index b9b7952..abb8c79 100644 --- a/test/sanity/sanity_test.go +++ b/test/sanity/sanity_test.go @@ -11,10 +11,11 @@ import ( "github.com/SynologyOpenSource/synology-csi/pkg/driver" "github.com/SynologyOpenSource/synology-csi/pkg/dsm/common" "github.com/SynologyOpenSource/synology-csi/pkg/dsm/service" + "github.com/SynologyOpenSource/synology-csi/pkg/utils/hostexec" ) const ( - ConfigPath = "./../../config/client-info.yml" + ConfigPath = "./../../config/client-info.yml" SecretsFilePath = "./sanity-test-secret-file.yaml" ) @@ -58,8 +59,14 @@ func TestSanity(t *testing.T) { } defer dsmService.RemoveAllDsms() + cmdExecutor, err := hostexec.New(nil, "") + if err != nil { + t.Fatal(fmt.Sprintf("Failed to create command executor: %v\n", err)) + } + tools := driver.NewTools(cmdExecutor) + endpoint := "unix://" + endpointFile.Name() - drv, err := driver.NewControllerAndNodeDriver(nodeID, endpoint, dsmService) + drv, err := driver.NewControllerAndNodeDriver(nodeID, endpoint, dsmService, tools) if err != nil { t.Fatal(fmt.Sprintf("Failed to create driver: %v\n", err)) }