diff --git a/feature/pkg/connector/connector.go b/feature/pkg/connector/connector.go index cf6f8f796..cd6e8a524 100644 --- a/feature/pkg/connector/connector.go +++ b/feature/pkg/connector/connector.go @@ -24,8 +24,6 @@ import ( "os" "k8s.io/klog/v2" - "k8s.io/utils/exec" - "k8s.io/utils/ptr" _const "github.com/kubesphere/kubekey/v4/pkg/const" "github.com/kubesphere/kubekey/v4/pkg/variable" @@ -38,6 +36,8 @@ const ( connectedKubernetes = "kubernetes" ) +var localShell = commandShell() + // Connector is the interface for connecting to a remote host type Connector interface { // Init initializes the connection @@ -63,52 +63,11 @@ func NewConnector(host string, connectorVars map[string]any) (Connector, error) switch connectedType { case connectedLocal: - return &localConnector{Cmd: exec.New()}, nil + return newLocalConnector(connectorVars), nil case connectedSSH: - // get host in connector variable. if empty, set default host: host_name. - hostParam, err := variable.StringVar(nil, connectorVars, _const.VariableConnectorHost) - if err != nil { - klog.InfoS("get ssh port failed use default port 22", "error", err) - hostParam = host - } - // get port in connector variable. if empty, set default port: 22. - portParam, err := variable.IntVar(nil, connectorVars, _const.VariableConnectorPort) - if err != nil { - klog.V(4).Infof("connector port is empty use: %v", defaultSSHPort) - portParam = ptr.To(defaultSSHPort) - } - // get user in connector variable. if empty, set default user: root. - userParam, err := variable.StringVar(nil, connectorVars, _const.VariableConnectorUser) - if err != nil { - klog.V(4).Infof("connector user is empty use: %s", defaultSSHUser) - userParam = defaultSSHUser - } - // get password in connector variable. if empty, should connector by private key. - passwdParam, err := variable.StringVar(nil, connectorVars, _const.VariableConnectorPassword) - if err != nil { - klog.V(4).InfoS("connector password is empty use public key") - } - // get private key path in connector variable. if empty, set default path: /root/.ssh/id_rsa. - keyParam, err := variable.StringVar(nil, connectorVars, _const.VariableConnectorPrivateKey) - if err != nil { - klog.V(4).Infof("ssh public key is empty, use: %s", defaultSSHPrivateKey) - keyParam = defaultSSHPrivateKey - } - - return &sshConnector{ - Host: hostParam, - Port: *portParam, - User: userParam, - Password: passwdParam, - PrivateKey: keyParam, - }, nil + return newSSHConnector(host, connectorVars), nil case connectedKubernetes: - kubeconfig, err := variable.StringVar(nil, connectorVars, _const.VariableConnectorKubeconfig) - if err != nil && host != _const.VariableLocalHost { - return nil, err - } - - return &kubernetesConnector{Cmd: exec.New(), clusterName: host, kubeconfig: kubeconfig}, nil + return newKubernetesConnector(host, connectorVars) default: localHost, _ := os.Hostname() // get host in connector variable. if empty, set default host: host_name. @@ -118,39 +77,10 @@ func NewConnector(host string, connectorVars map[string]any) (Connector, error) hostParam = host } if host == _const.VariableLocalHost || host == localHost || isLocalIP(hostParam) { - return &localConnector{Cmd: exec.New()}, nil - } - // get port in connector variable. if empty, set default port: 22. - portParam, err := variable.IntVar(nil, connectorVars, _const.VariableConnectorPort) - if err != nil { - klog.V(4).Infof("connector port is empty use: %v", defaultSSHPort) - portParam = ptr.To(defaultSSHPort) - } - // get user in connector variable. if empty, set default user: root. - userParam, err := variable.StringVar(nil, connectorVars, _const.VariableConnectorUser) - if err != nil { - klog.V(4).Infof("connector user is empty use: %s", defaultSSHUser) - userParam = defaultSSHUser - } - // get password in connector variable. if empty, should connector by private key. - passwdParam, err := variable.StringVar(nil, connectorVars, _const.VariableConnectorPassword) - if err != nil { - klog.V(4).InfoS("connector password is empty use public key") - } - // get private key path in connector variable. if empty, set default path: /root/.ssh/id_rsa. - keyParam, err := variable.StringVar(nil, connectorVars, _const.VariableConnectorPrivateKey) - if err != nil { - klog.V(4).Infof("ssh public key is empty, use: %s", defaultSSHPrivateKey) - keyParam = defaultSSHPrivateKey + return newLocalConnector(connectorVars), nil } - return &sshConnector{ - Host: hostParam, - Port: *portParam, - User: userParam, - Password: passwdParam, - PrivateKey: keyParam, - }, nil + return newSSHConnector(host, connectorVars), nil } } @@ -188,3 +118,13 @@ func isLocalIP(ipAddr string) bool { return false } + +func commandShell() string { + // find command interpreter in env. default /bin/bash + sl, ok := os.LookupEnv("SHELL") + if !ok { + return "/bin/bash" + } + + return sl +} diff --git a/feature/pkg/connector/kubernetes_connector.go b/feature/pkg/connector/kubernetes_connector.go index 7213a64bf..cbb1ce42a 100644 --- a/feature/pkg/connector/kubernetes_connector.go +++ b/feature/pkg/connector/kubernetes_connector.go @@ -27,14 +27,28 @@ import ( "k8s.io/utils/exec" _const "github.com/kubesphere/kubekey/v4/pkg/const" + "github.com/kubesphere/kubekey/v4/pkg/variable" ) const kubeconfigRelPath = ".kube/config" var _ Connector = &kubernetesConnector{} +func newKubernetesConnector(host string, connectorVars map[string]any) (*kubernetesConnector, error) { + kubeconfig, err := variable.StringVar(nil, connectorVars, _const.VariableConnectorKubeconfig) + if err != nil && host != _const.VariableLocalHost { + return nil, err + } + + return &kubernetesConnector{ + Cmd: exec.New(), + ClusterName: host, + kubeconfig: kubeconfig, + }, nil +} + type kubernetesConnector struct { - clusterName string + ClusterName string kubeconfig string homeDir string Cmd exec.Interface @@ -42,16 +56,16 @@ type kubernetesConnector struct { // Init connector, create home dir in local for each kubernetes. func (c *kubernetesConnector) Init(_ context.Context) error { - if c.clusterName == _const.VariableLocalHost && c.kubeconfig == "" { + if c.ClusterName == _const.VariableLocalHost && c.kubeconfig == "" { klog.V(4).InfoS("kubeconfig is not set, using local kubeconfig") // use default kubeconfig. skip return nil } // set home dir for each kubernetes - c.homeDir = filepath.Join(_const.GetWorkDir(), _const.KubernetesDir, c.clusterName) + c.homeDir = filepath.Join(_const.GetWorkDir(), _const.KubernetesDir, c.ClusterName) if _, err := os.Stat(c.homeDir); err != nil && os.IsNotExist(err) { if err := os.MkdirAll(c.homeDir, os.ModePerm); err != nil { - klog.V(4).ErrorS(err, "Failed to create local dir", "cluster", c.clusterName) + klog.V(4).ErrorS(err, "Failed to create local dir", "cluster", c.ClusterName) // if dir is not exist, create it. return err } @@ -60,14 +74,14 @@ func (c *kubernetesConnector) Init(_ context.Context) error { kubeconfigPath := filepath.Join(c.homeDir, kubeconfigRelPath) if _, err := os.Stat(kubeconfigPath); err != nil && os.IsNotExist(err) { if err := os.MkdirAll(filepath.Dir(kubeconfigPath), os.ModePerm); err != nil { - klog.V(4).ErrorS(err, "Failed to create local dir", "cluster", c.clusterName) + klog.V(4).ErrorS(err, "Failed to create local dir", "cluster", c.ClusterName) return err } } // write kubeconfig to home dir if err := os.WriteFile(kubeconfigPath, []byte(c.kubeconfig), os.ModePerm); err != nil { - klog.V(4).ErrorS(err, "Failed to create kubeconfig file", "cluster", c.clusterName) + klog.V(4).ErrorS(err, "Failed to create kubeconfig file", "cluster", c.ClusterName) return err } @@ -100,7 +114,7 @@ func (c *kubernetesConnector) PutFile(_ context.Context, src []byte, dst string, func (c *kubernetesConnector) FetchFile(ctx context.Context, src string, dst io.Writer) error { // add "--kubeconfig" to src command klog.V(5).InfoS("exec local command", "cmd", src) - command := c.Cmd.CommandContext(ctx, "/bin/sh", "-c", src) + command := c.Cmd.CommandContext(ctx, localShell, "-c", src) command.SetDir(c.homeDir) command.SetEnv([]string{"KUBECONFIG=" + filepath.Join(c.homeDir, kubeconfigRelPath)}) command.SetStdout(dst) @@ -113,7 +127,7 @@ func (c *kubernetesConnector) FetchFile(ctx context.Context, src string, dst io. func (c *kubernetesConnector) ExecuteCommand(ctx context.Context, cmd string) ([]byte, error) { // add "--kubeconfig" to src command klog.V(5).InfoS("exec local command", "cmd", cmd) - command := c.Cmd.CommandContext(ctx, "/bin/sh", "-c", cmd) + command := c.Cmd.CommandContext(ctx, localShell, "-c", cmd) command.SetDir(c.homeDir) command.SetEnv([]string{"KUBECONFIG=" + filepath.Join(c.homeDir, kubeconfigRelPath)}) diff --git a/feature/pkg/connector/local_connector.go b/feature/pkg/connector/local_connector.go index ad62185fd..f5764278e 100644 --- a/feature/pkg/connector/local_connector.go +++ b/feature/pkg/connector/local_connector.go @@ -30,13 +30,24 @@ import ( "k8s.io/utils/exec" _const "github.com/kubesphere/kubekey/v4/pkg/const" + "github.com/kubesphere/kubekey/v4/pkg/variable" ) var _ Connector = &localConnector{} var _ GatherFacts = &localConnector{} +func newLocalConnector(connectorVars map[string]any) *localConnector { + sudo, err := variable.StringVar(nil, connectorVars, _const.VariableConnectorSudoPassword) + if err != nil { + klog.V(4).InfoS("get connector sudo password failed, execute command without sudo", "error", err) + } + + return &localConnector{Sudo: sudo, Cmd: exec.New()} +} + type localConnector struct { - Cmd exec.Interface + Sudo string + Cmd exec.Interface } // Init connector. do nothing @@ -84,8 +95,16 @@ func (c *localConnector) FetchFile(_ context.Context, src string, dst io.Writer) // ExecuteCommand in local host func (c *localConnector) ExecuteCommand(ctx context.Context, cmd string) ([]byte, error) { klog.V(5).InfoS("exec local command", "cmd", cmd) + // find command interpreter in env. default /bin/bash + + if c.Sudo != "" { + command := c.Cmd.CommandContext(ctx, "sudo", "-E", localShell, "-c", cmd) + command.SetStdin(bytes.NewBufferString(c.Sudo + "\n")) + + return command.CombinedOutput() + } - return c.Cmd.CommandContext(ctx, "/bin/sh", "-c", cmd).CombinedOutput() + return c.Cmd.CommandContext(ctx, localShell, "-c", cmd).CombinedOutput() } // HostInfo for GatherFacts diff --git a/feature/pkg/connector/local_connector_test.go b/feature/pkg/connector/local_connector_test.go index 2e9883b1a..ae1c75747 100644 --- a/feature/pkg/connector/local_connector_test.go +++ b/feature/pkg/connector/local_connector_test.go @@ -18,54 +18,25 @@ package connector import ( "context" - "errors" - "fmt" - "strings" "testing" "time" "github.com/stretchr/testify/assert" "k8s.io/utils/exec" - testingexec "k8s.io/utils/exec/testing" ) -func newFakeLocalConnector(runCmd string, output string) *localConnector { - return &localConnector{ - Cmd: &testingexec.FakeExec{CommandScript: []testingexec.FakeCommandAction{ - func(cmd string, args ...string) exec.Cmd { - if strings.TrimSpace(fmt.Sprintf("%s %s", cmd, strings.Join(args, " "))) == "/bin/sh -c "+runCmd { - return &testingexec.FakeCmd{ - CombinedOutputScript: []testingexec.FakeAction{func() ([]byte, []byte, error) { - return []byte(output), nil, nil - }}, - } - } - - return &testingexec.FakeCmd{ - CombinedOutputScript: []testingexec.FakeAction{func() ([]byte, []byte, error) { - return nil, nil, errors.New("error command") - }}, - } - }, - }}, - } -} - -func TestSshConnector_ExecuteCommand(t *testing.T) { +func TestLocalConnector_ExecuteCommand(t *testing.T) { testcases := []struct { - name string - cmd string - exceptedErr error + name string + cmd string + localConnector *localConnector + expectedStdout string }{ { - name: "execute command succeed", - cmd: "echo 'hello'", - exceptedErr: nil, - }, - { - name: "execute command failed", - cmd: "echo 'hello1'", - exceptedErr: errors.New("error command"), + name: "execute command succeed", + cmd: "echo 'hello'", + localConnector: &localConnector{Cmd: exec.New()}, + expectedStdout: "hello\n", }, } @@ -73,9 +44,8 @@ func TestSshConnector_ExecuteCommand(t *testing.T) { t.Run(tc.name, func(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) defer cancel() - lc := newFakeLocalConnector("echo 'hello'", "hello") - _, err := lc.ExecuteCommand(ctx, tc.cmd) - assert.Equal(t, tc.exceptedErr, err) + a, _ := tc.localConnector.ExecuteCommand(ctx, tc.cmd) + assert.Equal(t, tc.expectedStdout, string(a)) }) } } diff --git a/feature/pkg/connector/ssh_connector.go b/feature/pkg/connector/ssh_connector.go index b41b265c3..25f181e5f 100644 --- a/feature/pkg/connector/ssh_connector.go +++ b/feature/pkg/connector/ssh_connector.go @@ -26,18 +26,22 @@ import ( "os" "os/user" "path/filepath" + "strings" "time" "github.com/pkg/sftp" "golang.org/x/crypto/ssh" "k8s.io/klog/v2" + "k8s.io/utils/ptr" _const "github.com/kubesphere/kubekey/v4/pkg/const" + "github.com/kubesphere/kubekey/v4/pkg/variable" ) const ( - defaultSSHPort = 22 - defaultSSHUser = "root" + defaultSSHPort = 22 + defaultSSHUser = "root" + defaultSSHSHELL = "/bin/bash" ) var defaultSSHPrivateKey string @@ -53,13 +57,64 @@ func init() { var _ Connector = &sshConnector{} var _ GatherFacts = &sshConnector{} +func newSSHConnector(host string, connectorVars map[string]any) *sshConnector { + sudo, err := variable.StringVar(nil, connectorVars, _const.VariableConnectorSudoPassword) + if err != nil { + klog.V(4).InfoS("get connector sudo password failed, execute command without sudo", "error", err) + } + + // get host in connector variable. if empty, set default host: host_name. + hostParam, err := variable.StringVar(nil, connectorVars, _const.VariableConnectorHost) + if err != nil { + klog.V(4).InfoS("get connector host failed use current hostname", "error", err) + hostParam = host + } + // get port in connector variable. if empty, set default port: 22. + portParam, err := variable.IntVar(nil, connectorVars, _const.VariableConnectorPort) + if err != nil { + klog.V(4).Infof("connector port is empty use: %v", defaultSSHPort) + portParam = ptr.To(defaultSSHPort) + } + // get user in connector variable. if empty, set default user: root. + userParam, err := variable.StringVar(nil, connectorVars, _const.VariableConnectorUser) + if err != nil { + klog.V(4).Infof("connector user is empty use: %s", defaultSSHUser) + userParam = defaultSSHUser + } + // get password in connector variable. if empty, should connector by private key. + passwdParam, err := variable.StringVar(nil, connectorVars, _const.VariableConnectorPassword) + if err != nil { + klog.V(4).InfoS("connector password is empty use public key") + } + // get private key path in connector variable. if empty, set default path: /root/.ssh/id_rsa. + keyParam, err := variable.StringVar(nil, connectorVars, _const.VariableConnectorPrivateKey) + if err != nil { + klog.V(4).Infof("ssh public key is empty, use: %s", defaultSSHPrivateKey) + keyParam = defaultSSHPrivateKey + } + + return &sshConnector{ + Sudo: sudo, + Host: hostParam, + Port: *portParam, + User: userParam, + Password: passwdParam, + PrivateKey: keyParam, + shell: defaultSSHSHELL, + } +} + type sshConnector struct { + Sudo string Host string Port int User string Password string PrivateKey string - client *ssh.Client + + client *ssh.Client + // shell to execute command + shell string } // Init connector, get ssh.Client @@ -97,6 +152,22 @@ func (c *sshConnector) Init(context.Context) error { } c.client = sshClient + // get shell from env + session, err := sshClient.NewSession() + if err != nil { + return fmt.Errorf("create session error: %w", err) + } + defer session.Close() + + output, err := session.CombinedOutput("echo $SHELL") + if err != nil { + return fmt.Errorf("env command error: %w", err) + } + + if strings.TrimSuffix(string(output), "\n") != "" { + c.shell = strings.TrimSuffix(string(output), "\n") + } + return nil } @@ -181,7 +252,39 @@ func (c *sshConnector) ExecuteCommand(_ context.Context, cmd string) ([]byte, er } defer session.Close() - return session.CombinedOutput(cmd) + if c.Sudo != "" { + cmd = fmt.Sprintf("sudo -E %s -c \"%q\"", c.shell, cmd) + // get pipe from session + stdin, _ := session.StdinPipe() + stdout, _ := session.StdoutPipe() + stderr, _ := session.StderrPipe() + // Request a pseudo-terminal (required for sudo password input) + if err := session.RequestPty("xterm", 80, 40, ssh.TerminalModes{}); err != nil { + return nil, err + } + // Start the remote command + if err := session.Start(cmd); err != nil { + return nil, err + } + // Write sudo password to the standard input + if _, err := io.WriteString(stdin, c.Sudo+"\n"); err != nil { + return nil, err + } + // Read the command output + output := make([]byte, 0) + stdoutData, _ := io.ReadAll(stdout) + stderrData, _ := io.ReadAll(stderr) + output = append(output, stdoutData...) + output = append(output, stderrData...) + // Wait for the command to complete + if err := session.Wait(); err != nil { + return nil, err + } + + return output, nil + } + + return session.CombinedOutput(fmt.Sprintf("%s -c \"%q\"", c.shell, cmd)) } // HostInfo for GatherFacts diff --git a/feature/pkg/const/common.go b/feature/pkg/const/common.go index 3ea218d51..3a2eb2bb7 100644 --- a/feature/pkg/const/common.go +++ b/feature/pkg/const/common.go @@ -30,6 +30,8 @@ const ( // === From inventory === VariableConnector = "connector" // VariableConnectorType is connected type for VariableConnector. VariableConnectorType = "type" + // VariableConnectorSudoPassword is connected address for VariableConnector. + VariableConnectorSudoPassword = "sudo_password" // VariableConnectorHost is connected address for VariableConnector. VariableConnectorHost = "host" // VariableConnectorPort is connected address for VariableConnector. diff --git a/feature/pkg/executor/pipeline_executor.go b/feature/pkg/executor/pipeline_executor.go index 7338c8c20..dd7fdb98f 100644 --- a/feature/pkg/executor/pipeline_executor.go +++ b/feature/pkg/executor/pipeline_executor.go @@ -168,7 +168,7 @@ func (e pipelineExecutor) execBatchHosts(ctx context.Context, play kkprojectv1.P option: e.option, hosts: serials, ignoreErrors: play.IgnoreErrors, - blocks: play.Tasks, + blocks: play.PostTasks, tags: play.Taggable, }.Exec(ctx)); err != nil { return fmt.Errorf("execute post-tasks error: %w", err)