From 5e2c73f054c7d8876ec742d73615c9a0e2d98f03 Mon Sep 17 00:00:00 2001 From: Nikolay Mitrofanov Date: Thu, 9 Apr 2026 18:46:35 +0300 Subject: [PATCH 1/4] some fixes from dhctl Signed-off-by: Nikolay Mitrofanov --- pkg/ssh/clissh/cmd/ssh.go | 1 + pkg/ssh/testssh/common_test.go | 136 +++++++++++++- pkg/ssh/testssh/file_test.go | 325 ++++++++++++++++++--------------- pkg/tests/ssh_container.go | 15 ++ pkg/tests/test.go | 5 + 5 files changed, 328 insertions(+), 154 deletions(-) diff --git a/pkg/ssh/clissh/cmd/ssh.go b/pkg/ssh/clissh/cmd/ssh.go index 24bd734..720b044 100644 --- a/pkg/ssh/clissh/cmd/ssh.go +++ b/pkg/ssh/clissh/cmd/ssh.go @@ -90,6 +90,7 @@ func (s *SSH) Cmd(ctx context.Context) *exec.Cmd { "-o", "ServerAliveInterval=1", "-o", "ServerAliveCountMax=3600", "-o", "ConnectTimeout=15", + "-o", "LogLevel=ERROR", "-o", "PasswordAuthentication=no", } diff --git a/pkg/ssh/testssh/common_test.go b/pkg/ssh/testssh/common_test.go index 587e727..8ccfbab 100644 --- a/pkg/ssh/testssh/common_test.go +++ b/pkg/ssh/testssh/common_test.go @@ -20,13 +20,14 @@ import ( "fmt" "os" "path/filepath" + "strings" "testing" "time" "github.com/deckhouse/lib-dhctl/pkg/retry" "github.com/stretchr/testify/require" - "github.com/deckhouse/lib-connection/pkg" + connection "github.com/deckhouse/lib-connection/pkg" "github.com/deckhouse/lib-connection/pkg/settings" "github.com/deckhouse/lib-connection/pkg/ssh/clissh" "github.com/deckhouse/lib-connection/pkg/ssh/gossh" @@ -36,7 +37,7 @@ import ( const expectedFileContent = "Some test data" -func registerStopClient(t *testing.T, sshClient pkg.SSHClient) { +func registerStopClient(t *testing.T, sshClient connection.SSHClient) { t.Cleanup(func() { sshClient.Stop() }) @@ -51,7 +52,7 @@ func newSessionTestLoopParams() gossh.ClientLoopsParams { } } -func initBothClients(t *testing.T, ctx context.Context, setting settings.Settings, sess *session.Session, keys []session.AgentPrivateKey) (pkg.SSHClient, error) { +func initBothClients(t *testing.T, ctx context.Context, setting settings.Settings, sess *session.Session, keys []session.AgentPrivateKey) (connection.SSHClient, error) { goSSHClient := gossh.NewClient(ctx, setting, sess, keys). WithLoopsParams(newSessionTestLoopParams()) err := goSSHClient.Start() @@ -77,6 +78,16 @@ func initContexts(dur time.Duration) (context.Context, context.Context, context. return ctx, ctx2, cancel, cancel2 } +func assertFilesOut(t *testing.T, sshClient connection.SSHClient, container *tests.TestContainerWrapper, expectedOutput string, cmd ...string) { + out, err := container.Container.ExecToContainerWithOut("get content", cmd...) + require.NoError(t, err, "%v should exec", cmd) + require.Equal(t, expectedOutput, string(out), "contents should equality with docker") + + outSSH, _, err := sshClient.Command(cmd[0], cmd[1:]...).Output(context.TODO()) + require.NoError(t, err, "%v should exec via client") + require.Equal(t, expectedOutput, string(outSSH), "contents should equality with client") +} + // todo mount local directory to container and assert via local exec func assertFilesViaRemoteRun(t *testing.T, sshClient *gossh.Client, cmd string, expectedOutput string) { s, err := sshClient.NewSSHSession() @@ -88,7 +99,120 @@ func assertFilesViaRemoteRun(t *testing.T, sshClient *gossh.Client, cmd string, require.Equal(t, expectedOutput, string(out)) } -func startTwoContainersWithClients(t *testing.T, test *tests.Test, createDeckhouseDirs bool) (pkg.SSHClient, pkg.SSHClient, pkg.SSHClient, error) { +func startTargetOnlyForSSH(t *testing.T, test *tests.Test, baseName string) *tests.TestContainerWrapper { + targetName := baseName + "_target" + target := tests.NewTestContainerWrapper( + t, + test, + tests.WithContainerName(targetName), + ) + + return target +} + +func startTargetWithBastionForSSH(t *testing.T, test *tests.Test, baseName string) (*tests.TestContainerWrapper, *tests.TestContainerWrapper) { + target := startTargetOnlyForSSH(t, test, baseName) + bastionName := baseName + "_bastion" + bastion := tests.NewTestContainerWrapper( + t, + test, + tests.WithContainerName(bastionName), + tests.WithConnectToContainerNetwork(target), + ) + + return bastion, target +} + +func startSSHClient(t *testing.T, test *tests.Test, rt runTest, target *tests.TestContainerWrapper, bastion ...*tests.TestContainerWrapper) connection.SSHClient { + keys := target.AgentPrivateKeys() + var sess *session.Session + + if len(bastion) > 0 { + b := bastion[0] + sess = tests.SessionWithBastion(target, b) + keys = append(keys, b.AgentPrivateKeys()...) + } else { + sess = tests.Session(target) + } + + defaultLoop := retry.NewEmptyParams( + retry.WithWait(2*time.Second), + retry.WithAttempts(7), + ) + + sshSettings := test.Settings() + ctx := context.TODO() + + var sshClient connection.SSHClient + + if rt.mode.ForceModern { + sshClient = gossh.NewClient(ctx, sshSettings, sess, keys).WithLoopsParams(gossh.ClientLoopsParams{ + ConnectToBastion: defaultLoop.Clone(), + ConnectToHostViaBastion: defaultLoop.Clone(), + ConnectToHostDirectly: defaultLoop.Clone(), + NewSession: defaultLoop.Clone(), + CheckReverseTunnel: defaultLoop.Clone(), + }) + registerStopClient(t, sshClient) + } else { + sshClient = clissh.NewClient(sshSettings, sess, keys, true) + prepareScp(t) + + func(t *testing.T) { + // check connection + waitClient := gossh.NewClient(ctx, sshSettings, sess, keys). + WithLoopsParams(newSessionTestLoopParams()) + defer waitClient.Stop() + err := waitClient.Start() + require.NoError(t, err, "sshd should start") + }(t) + } + + err := sshClient.Start() + // expecting no error on client start + require.NoError(t, err) + + return sshClient +} + +type sshTestClientProvider struct { + name string + provider func(t *testing.T, test *tests.Test, rt runTest) (connection.SSHClient, *tests.TestContainerWrapper) +} + +var ( + onlyTargetSSHClientProvider = sshTestClientProvider{ + name: "only target", + provider: func(t *testing.T, test *tests.Test, rt runTest) (connection.SSHClient, *tests.TestContainerWrapper) { + baseName := fmt.Sprintf( + "%s_%s_only_target", + strings.ToLower(test.FullNameForContainer()), + strings.ToLower(rt.name), + ) + container := startTargetOnlyForSSH(t, test, baseName) + client := startSSHClient(t, test, rt, container) + + return client, container + }, + } + + viaBastionSSHClientProvider = sshTestClientProvider{ + name: "via bastion", + provider: func(t *testing.T, test *tests.Test, rt runTest) (connection.SSHClient, *tests.TestContainerWrapper) { + baseName := fmt.Sprintf( + "%s_%s_via_bastion", + strings.ToLower(test.FullNameForContainer()), + strings.ToLower(rt.name), + ) + bastion, target := startTargetWithBastionForSSH(t, test, baseName) + client := startSSHClient(t, test, rt, target, bastion) + + return client, target + }, + } +) + +func startTwoContainersWithClients(t *testing.T, test *tests.Test, createDeckhouseDirs bool) (connection.SSHClient, connection.SSHClient, connection.SSHClient, error) { // first container for gossh client container := tests.NewTestContainerWrapper(t, test) ctx := context.Background() @@ -148,7 +272,7 @@ func prepareScp(t *testing.T) { }) } -func mustPrepareData(t *testing.T, sshClient pkg.SSHClient) { +func mustPrepareData(t *testing.T, sshClient connection.SSHClient) { err := sshClient.Command("mkdir -p /tmp/testdata").Run(context.Background()) require.NoError(t, err) err = sshClient.Command(fmt.Sprintf(`echo -n '%s' > /tmp/testdata/first`, expectedFileContent)).Run(context.Background()) @@ -161,7 +285,7 @@ func mustPrepareData(t *testing.T, sshClient pkg.SSHClient) { require.NoError(t, err) } -func chmodTmpDir(sshClient pkg.SSHClient, nodeTmpPath string) error { +func chmodTmpDir(sshClient connection.SSHClient, nodeTmpPath string) error { cmd := sshClient.Command("chmod", "700", nodeTmpPath) cmd.Sudo(context.Background()) return cmd.Run(context.Background()) diff --git a/pkg/ssh/testssh/file_test.go b/pkg/ssh/testssh/file_test.go index e72e1d8..bc6de18 100644 --- a/pkg/ssh/testssh/file_test.go +++ b/pkg/ssh/testssh/file_test.go @@ -25,179 +25,208 @@ import ( "github.com/stretchr/testify/require" + connection "github.com/deckhouse/lib-connection/pkg" + sshconfig "github.com/deckhouse/lib-connection/pkg/ssh/config" "github.com/deckhouse/lib-connection/pkg/ssh/gossh" "github.com/deckhouse/lib-connection/pkg/tests" ) func TestFileUpload(t *testing.T) { - test := tests.ShouldNewIntegrationTest(t, "TestFileUpload") - - const uploadDir = "upload_dir" - const testFileContent = "Hello World" - const notExec = false - - filePath := func(subPath ...string) []string { - require.NotEmpty(t, subPath, "subPath is empty for filePath") - return append([]string{uploadDir}, subPath...) + type uploadTest struct { + unaccessibleDir string + dir string + file string + symlink string + fileContent string } - testFile := test.MustCreateTmpFile(t, testFileContent, notExec, filePath("upload")...) - testDir := filepath.Dir(testFile) - test.MustCreateTmpFile(t, "second", notExec, filePath("second")...) - test.MustCreateTmpFile(t, "empty", notExec, filePath("second")...) - test.MustCreateTmpFile(t, "sub", notExec, filePath("sub", "third")...) + createTest := func(t *testing.T) (*tests.Test, uploadTest) { + test := tests.ShouldNewIntegrationTest(t, "TestFileUpload") - symlink := filepath.Join(test.TmpDir(), "symlink") - err := os.Symlink(testFile, symlink) - require.NoError(t, err) + const uploadDir = "upload_dir" + const testFileContent = "Hello World" + const notExec = false - const unaccessibleDirectoryName = "unaccessible" - test.MustCreateUnaccessibleDir(t, unaccessibleDirectoryName) - unaccessibleDirectoryPath := filepath.Join(test.TmpDir(), unaccessibleDirectoryName) + filePath := func(subPath ...string) []string { + require.NotEmpty(t, subPath, "subPath is empty for filePath") + return append([]string{uploadDir}, subPath...) + } - goSSHClient, cliSSHClient, goSSHClient2, err := startTwoContainersWithClients(t, test, false) - require.NoError(t, err) + testFile := test.MustCreateTmpFile(t, testFileContent, notExec, filePath("upload")...) + testDir := filepath.Dir(testFile) + test.MustCreateTmpFile(t, "second", notExec, filePath("second")...) + test.MustCreateTmpFile(t, "empty", notExec, filePath("second")...) + test.MustCreateTmpFile(t, "sub", notExec, filePath("sub", "third")...) - prepareScp(t) + symlink := filepath.Join(test.TmpDir(), "symlink") + err := os.Symlink(testFile, symlink) + require.NoError(t, err) - t.Run("Upload files and directories to container via existing ssh client", func(t *testing.T) { - cases := []struct { - title string - srcPath string - dstPath string - wantErr bool - err string - }{ - { - title: "Single file", - srcPath: testFile, - dstPath: ".", - wantErr: false, - }, - { - title: "Directory", - srcPath: testDir, - dstPath: "/tmp", - wantErr: false, - }, - { - title: "Nonexistent", - srcPath: "/path/to/nonexistent/flie", - dstPath: "/tmp", - wantErr: true, - }, - { - title: "File to root", - srcPath: testFile, - dstPath: "/any", - wantErr: true, - }, - { - title: "File to /var/lib", - srcPath: testFile, - dstPath: "/var/lib", - wantErr: true, - }, - { - title: "File to unaccessible file", - srcPath: testFile, - dstPath: "/path/what/not/exists.txt", - wantErr: true, - }, - { - title: "Directory to root", - srcPath: testDir, - dstPath: "/", - wantErr: true, - }, - { - title: "Symlink", - srcPath: symlink, - dstPath: ".", - wantErr: false, - }, - { - title: "Device", - srcPath: "/dev/zero", - dstPath: "/", - wantErr: true, - err: "is not a directory or file", - }, - { - title: "Unaccessible dir", - srcPath: unaccessibleDirectoryPath, - dstPath: ".", - wantErr: true, - }, - { - title: "Unaccessible file", - srcPath: "/etc/sudoers", - dstPath: ".", - wantErr: true, - }, - } + const unaccessibleDirectoryName = "unaccessible" + test.MustCreateUnaccessibleDir(t, unaccessibleDirectoryName) + unaccessibleDirectoryPath := filepath.Join(test.TmpDir(), unaccessibleDirectoryName) - for _, c := range cases { - t.Run(c.title, func(t *testing.T) { - f := goSSHClient.File() - f2 := cliSSHClient.File() - err = f.Upload(context.Background(), c.srcPath, c.dstPath) - err2 := f2.Upload(context.Background(), c.srcPath, c.dstPath) - if !c.wantErr { - require.NoError(t, err) - require.NoError(t, err2) - } else { - require.Error(t, err) - require.Contains(t, err.Error(), c.err) - require.Error(t, err2) - require.Contains(t, err2.Error(), c.err) - } - }) + return test, uploadTest{ + unaccessibleDir: unaccessibleDirectoryPath, + dir: testDir, + file: testFile, + symlink: symlink, + fileContent: testFileContent, } - }) + } - t.Run("Equality of uploaded and local file content", func(t *testing.T) { - f := goSSHClient.File() - err := f.Upload(context.Background(), testFile, "/tmp/testfile.txt") - // testFile contains "Hello world" string - require.NoError(t, err) + providers := []sshTestClientProvider{ + onlyTargetSSHClientProvider, + viaBastionSSHClientProvider, + } - assertFilesViaRemoteRun(t, goSSHClient.(*gossh.Client), "cat /tmp/testfile.txt", testFileContent) + runTests := []runTest{ + { + name: "Go", + mode: sshconfig.Mode{ + ForceModern: true, + }, + }, - // clissh check - f = cliSSHClient.File() - err = f.Upload(context.Background(), testFile, "/tmp/testfile.txt") - require.NoError(t, err) + { + name: "Cli", + mode: sshconfig.Mode{ + ForceLegacy: true, + }, + }, + } - err = goSSHClient2.Start() - require.NoError(t, err) - registerStopClient(t, goSSHClient2) + runUpload := func(t *testing.T, sshClient connection.SSHClient, ut uploadTest) { + t.Run("Upload files and directories to container via existing ssh client", func(t *testing.T) { + cases := []struct { + title string + srcPath string + dstPath string + wantErr bool + err string + }{ + { + title: "Single file", + srcPath: ut.file, + dstPath: ".", + wantErr: false, + }, + { + title: "Directory", + srcPath: ut.dir, + dstPath: "/tmp", + wantErr: false, + }, + { + title: "Nonexistent", + srcPath: "/path/to/nonexistent/flie", + dstPath: "/tmp", + wantErr: true, + }, + { + title: "File to root", + srcPath: ut.file, + dstPath: "/any", + wantErr: true, + }, + { + title: "File to /var/lib", + srcPath: ut.file, + dstPath: "/var/lib", + wantErr: true, + }, + { + title: "File to unaccessible file", + srcPath: ut.file, + dstPath: "/path/what/not/exists.txt", + wantErr: true, + }, + { + title: "Directory to root", + srcPath: ut.dir, + dstPath: "/", + wantErr: true, + }, + { + title: "Symlink", + srcPath: ut.symlink, + dstPath: ".", + wantErr: false, + }, + { + title: "Device", + srcPath: "/dev/zero", + dstPath: "/", + wantErr: true, + err: "is not a directory or file", + }, + { + title: "Unaccessible dir", + srcPath: ut.unaccessibleDir, + dstPath: ".", + wantErr: true, + }, + { + title: "Unaccessible file", + srcPath: "/etc/sudoers", + dstPath: ".", + wantErr: true, + }, + } + + for _, c := range cases { + t.Run(c.title, func(t *testing.T) { + f := sshClient.File() + err := f.Upload(context.Background(), c.srcPath, c.dstPath) + if c.wantErr { + require.Error(t, err) + require.Contains(t, err.Error(), c.err) + return + } - assertFilesViaRemoteRun(t, goSSHClient2.(*gossh.Client), "cat /tmp/testfile.txt", testFileContent) - }) + require.NoError(t, err) + }) + } + }) + } - t.Run("Equality of uploaded and local directory", func(t *testing.T) { - f := goSSHClient.File() - err := f.Upload(context.Background(), testDir, "/tmp/upload") - require.NoError(t, err) + runContentEquality := func(t *testing.T, sshClient connection.SSHClient, container *tests.TestContainerWrapper, ut uploadTest) { + t.Run("Equality of uploaded and local file content", func(t *testing.T) { + f := sshClient.File() + err := f.Upload(context.Background(), ut.file, "/tmp/testfile.txt") + require.NoError(t, err, "should upload file") - cmd := exec.Command("ls", testDir) - lsResult, err := cmd.Output() - require.NoError(t, err) + assertFilesOut(t, sshClient, container, ut.fileContent, "cat", "/tmp/testfile.txt") + }) + } - assertFilesViaRemoteRun(t, goSSHClient.(*gossh.Client), "ls /tmp/upload", string(lsResult)) + runDirEquality := func(t *testing.T, sshClient connection.SSHClient, container *tests.TestContainerWrapper, ut uploadTest) { + t.Run("Equality of uploaded and local directory", func(t *testing.T) { + f := sshClient.File() + err := f.Upload(context.Background(), ut.dir, "/tmp/upload") + require.NoError(t, err, "should upload dir") - // clissh - f = cliSSHClient.File() - err = f.Upload(context.Background(), testDir, "/tmp/upload") - require.NoError(t, err) + cmd := exec.Command("ls", ut.dir) + lsResult, err := cmd.Output() + require.NoError(t, err, "should exec local ls") - err = goSSHClient2.Start() - require.NoError(t, err) - registerStopClient(t, goSSHClient2) + assertFilesOut(t, sshClient, container, string(lsResult), "ls", "/tmp/upload") + }) + } - assertFilesViaRemoteRun(t, goSSHClient2.(*gossh.Client), "ls /tmp/upload", string(lsResult)) - }) + for _, rt := range runTests { + t.Run(rt.name, func(t *testing.T) { + for _, p := range providers { + t.Run(p.name, func(t *testing.T) { + test, ut := createTest(t) + client, container := p.provider(t, test, rt) + runUpload(t, client, ut) + runContentEquality(t, client, container, ut) + runDirEquality(t, client, container, ut) + }) + } + }) + } } func TestFileUploadBytes(t *testing.T) { diff --git a/pkg/tests/ssh_container.go b/pkg/tests/ssh_container.go index 316b8f9..fb682a8 100644 --- a/pkg/tests/ssh_container.go +++ b/pkg/tests/ssh_container.go @@ -278,6 +278,16 @@ func (c *SSHContainer) ExecToContainer(description string, command ...string) er return c.runDocker(description, args...) } +func (c *SSHContainer) ExecToContainerWithOut(description string, command ...string) (string, error) { + if err := c.isContainerStarted(description); err != nil { + return "", err + } + + args := append([]string{"exec", c.GetContainerId()}, command...) + + return c.runDockerWithOut(description, args...) +} + func (c *SSHContainer) CreateDeckhouseDirs() error { description := func(name string) string { d := "node tmp dir" @@ -622,6 +632,11 @@ func (c *SSHContainer) logDebug(format string, args ...any) { c.settings.Logger.DebugF(format, args...) } +func (c *SSHContainer) logInfo(format string, args ...any) { + format += fmt.Sprintf(" (%s)", c.settings.String()) + c.settings.Logger.InfoF(format, args...) +} + func (c *SSHContainer) runDockerNetworkConnect(isDisconnect bool) error { cmdName := "connect" if isDisconnect { diff --git a/pkg/tests/test.go b/pkg/tests/test.go index cd2c0db..c9e2364 100644 --- a/pkg/tests/test.go +++ b/pkg/tests/test.go @@ -263,6 +263,11 @@ func (s *Test) FullName() string { return res } +func (s *Test) FullNameForContainer() string { + f := s.FullName() + return strings.ReplaceAll(f, "/", "_") +} + func (s *Test) Name() string { return s.testName } From fceecdde34eb2d4159048fa783dd191fd5594cfb Mon Sep 17 00:00:00 2001 From: Nikolay Mitrofanov Date: Thu, 9 Apr 2026 18:49:06 +0300 Subject: [PATCH 2/4] ++ Signed-off-by: Nikolay Mitrofanov --- pkg/ssh/testssh/common_test.go | 2 +- pkg/tests/ssh_container.go | 5 ----- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/pkg/ssh/testssh/common_test.go b/pkg/ssh/testssh/common_test.go index 8ccfbab..f1ab271 100644 --- a/pkg/ssh/testssh/common_test.go +++ b/pkg/ssh/testssh/common_test.go @@ -81,7 +81,7 @@ func initContexts(dur time.Duration) (context.Context, context.Context, context. func assertFilesOut(t *testing.T, sshClient connection.SSHClient, container *tests.TestContainerWrapper, expectedOutput string, cmd ...string) { out, err := container.Container.ExecToContainerWithOut("get content", cmd...) require.NoError(t, err, "%v should exec", cmd) - require.Equal(t, expectedOutput, string(out), "contents should equality with docker") + require.Equal(t, expectedOutput, out, "contents should equality with docker") outSSH, _, err := sshClient.Command(cmd[0], cmd[1:]...).Output(context.TODO()) require.NoError(t, err, "%v should exec via client") diff --git a/pkg/tests/ssh_container.go b/pkg/tests/ssh_container.go index fb682a8..e010cf5 100644 --- a/pkg/tests/ssh_container.go +++ b/pkg/tests/ssh_container.go @@ -632,11 +632,6 @@ func (c *SSHContainer) logDebug(format string, args ...any) { c.settings.Logger.DebugF(format, args...) } -func (c *SSHContainer) logInfo(format string, args ...any) { - format += fmt.Sprintf(" (%s)", c.settings.String()) - c.settings.Logger.InfoF(format, args...) -} - func (c *SSHContainer) runDockerNetworkConnect(isDisconnect bool) error { cmdName := "connect" if isDisconnect { From ca2080039bc38b7d71db23f39c5f6b5a56c809c0 Mon Sep 17 00:00:00 2001 From: Nikolay Mitrofanov Date: Thu, 9 Apr 2026 20:35:32 +0300 Subject: [PATCH 3/4] ++ Signed-off-by: Nikolay Mitrofanov --- examples/cobra/README.md | 16 +++--- examples/cobra/cmd/kube_only.go | 44 +++++++++++++-- examples/cobra/cmd/with_ssh.go | 3 +- .../cobra/cmd/with_ssh_additional_init.go | 17 ++++-- pkg/provider/inititializer.go | 54 ++++++++++++++++++- pkg/provider/ssh.go | 7 ++- 6 files changed, 124 insertions(+), 17 deletions(-) diff --git a/examples/cobra/README.md b/examples/cobra/README.md index c34c2eb..967a38b 100644 --- a/examples/cobra/README.md +++ b/examples/cobra/README.md @@ -13,15 +13,19 @@ go build -o bin/cobra main.go ```bash bin/cobra kube-only --tmp-dir=/tmp/my-cobra --kubeconfig=~/my.kind.kubeconfig --kubeconfig-context=kind-my --print-warning -bin/cobra ssh --ssh-user=ubuntu --ssh-host=0.0.0.0 +bin/cobra ssh --tmp-dir=/tmp/my-cobra --ssh-user=ubuntu --ssh-host=0.0.0.0 -bin/cobra ssh --ssh-user=ubuntu --ssh-host=0.0.0.0 --use-standalone-kube --kubeconfig=~/my.kind.kubeconfig +COBRA_SSH_MODERN_MODE=true bin/cobra ssh --tmp-dir=/tmp/my-cobra --ssh-user=ubuntu --ssh-host=0.0.0.0 -bin/cobra ssh --ssh-user=ubuntu --ssh-host=0.0.0.0 --ssh-agent-private-keys=~/.ssh/id_rsa --ssh-agent-private-keys=~/.ssh/another +bin/cobra ssh --tmp-dir=/tmp/my-cobra --ssh-user=ubuntu --ssh-host=0.0.0.0 --use-agent-with-no-private-keys --force-no-private-keys -bin/cobra ssh-additional --ssh-user=ubuntu --kubeconfig=~/my.kind.kubeconfig +bin/cobra ssh --tmp-dir=/tmp/my-cobra --ssh-user=ubuntu --ssh-host=0.0.0.0 --use-standalone-kube --kubeconfig=~/my.kind.kubeconfig -SSH_HOST_CONNECT=0.0.0.0 bin/cobra ssh-additional --ssh-user=ubuntu +bin/cobra ssh --tmp-dir=/tmp/my-cobra --ssh-user=ubuntu --ssh-host=0.0.0.0 --ssh-agent-private-keys=~/.ssh/id_rsa --ssh-agent-private-keys=~/.ssh/another -SSH_HOST_CONNECT=0.0.0.0 bin/cobra ssh-additional --ssh-user=ubuntu --kubeconfig=~/kind.kubeconfig +bin/cobra ssh-additional --tmp-dir=/tmp/my-cobra --ssh-user=ubuntu --kubeconfig=~/my.kind.kubeconfig + +SSH_HOST_CONNECT=0.0.0.0 bin/cobra --tmp-dir=/tmp/my-cobra ssh-additional --ssh-user=ubuntu + +SSH_HOST_CONNECT=0.0.0.0 bin/cobra --tmp-dir=/tmp/my-cobra ssh-additional --ssh-user=ubuntu --kubeconfig=~/kind.kubeconfig ``` \ No newline at end of file diff --git a/examples/cobra/cmd/kube_only.go b/examples/cobra/cmd/kube_only.go index 8e9086f..ca1e47f 100644 --- a/examples/cobra/cmd/kube_only.go +++ b/examples/cobra/cmd/kube_only.go @@ -16,12 +16,13 @@ package cmd import ( "context" + "errors" "fmt" "time" "github.com/spf13/cobra" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - + connection "github.com/deckhouse/lib-connection/pkg" "github.com/deckhouse/lib-connection/pkg/kube" "github.com/deckhouse/lib-connection/pkg/provider" @@ -105,7 +106,7 @@ func runKube(params *runKubeParams) error { } // default initialization way - initializer := provider.NewErrorSSHProviderForKubeInitializer(fmt.Errorf("should not use over ssh")) + initializer := provider.NewErrorSSHProviderInitializer(fmt.Errorf("should not use over ssh")) runner, err := provider.GetRunnerInterface(ctx, conf, sett, initializer) kubeProvider := provider.NewDefaultKubeProvider(sett, conf, runner) @@ -121,19 +122,54 @@ func runKube(params *runKubeParams) error { logger.InfoF("kube provider cleaned up successfully") }() + logger := sett.Logger() + // example that additional flags also parsed if *params.printWarn { - sett.Logger().WarnF("WARNING: printing warnings flag set") + logger.WarnF("WARNING: printing warnings flag set") } if err != nil { - return fmt.Errorf("failed to setup kube client", err) + return fmt.Errorf("failed to setup kube client: %w", err) } if err := getNodes(ctx, sett, kubeProvider); err != nil { return fmt.Errorf("failed to get nodes: %w", err) } + useSSHKubeConfig := &kube.Config{} + + // example that returns error with SSHErrorProvider + runnerSSH, err := provider.GetRunnerInterface(ctx, useSSHKubeConfig, sett, initializer) + if err != nil { + return fmt.Errorf("cannot provide runner with ssh") + } + + kubeErrProvider := provider.NewDefaultKubeProvider(sett, useSSHKubeConfig, runnerSSH) + _, err = kubeErrProvider.Client(ctx) + if err != nil { + if errors.Is(err, provider.ErrSSHClientCannotProvided) { + logger.InfoF("Cannot provide kube client because %v", err) + } else { + logger.ErrorF("Cannot provide kube client with unknown %v", err) + } + } else { + logger.ErrorF("Kube client should not provided") + } + + // example fail fast with ProvideErrorSSHProviderInitializer + failFastInitializer := provider.NewProvideErrorSSHProviderInitializer(fmt.Errorf("should not use over ssh")) + _, err = provider.GetRunnerInterface(ctx, useSSHKubeConfig, sett, failFastInitializer) + if err != nil { + if errors.Is(err, provider.ErrCannotProvideSSHProvider) { + logger.InfoF("Cannot provide kube runner provider because %v", err) + } else { + logger.ErrorF("Cannot provide kube runner with unknown %v", err) + } + } else { + logger.ErrorF("Kube runner should not provided") + } + return nil } diff --git a/examples/cobra/cmd/with_ssh.go b/examples/cobra/cmd/with_ssh.go index e09a582..ead8315 100644 --- a/examples/cobra/cmd/with_ssh.go +++ b/examples/cobra/cmd/with_ssh.go @@ -18,6 +18,7 @@ import ( "context" "fmt" "os" + "strings" "github.com/spf13/cobra" @@ -193,7 +194,7 @@ func doSSHCommand(ctx context.Context, sshClient connection.SSHClient) error { return fmt.Errorf("failed to run echo command: %w", err) } - if string(strOut) != echoStr { + if !strings.Contains(string(strOut), echoStr) { return fmt.Errorf("failed to run echo command, got output: %s", string(strOut)) } diff --git a/examples/cobra/cmd/with_ssh_additional_init.go b/examples/cobra/cmd/with_ssh_additional_init.go index 30c38a2..1826c56 100644 --- a/examples/cobra/cmd/with_ssh_additional_init.go +++ b/examples/cobra/cmd/with_ssh_additional_init.go @@ -106,9 +106,15 @@ func runSSHAdditional(params *runSSHParams) error { type providersConsumer interface { provider.SSHProviderInitializerWithCleanup provider.KubeProviderInitializerWithCleanup + SetSSHHost(h string) } func doSSHAdditional(ctx context.Context, sett settings.Settings, consumer providersConsumer) error { + hostFromEnv := os.Getenv("SSH_HOST_CONNECT") + if hostFromEnv != "" { + consumer.SetSSHHost(hostFromEnv) + } + kubeProvider, err := consumer.GetKubeProvider(ctx) if err != nil { return fmt.Errorf("Cannot initialize kube providder") @@ -147,6 +153,8 @@ type additionalProvidersConsumer struct { sshProvider connection.SSHProvider kubeProvider connection.KubeProvider + + sshHost string } func newAdditionalProvidersConsumer(params *runSSHParams, kubeConfig *kube.Config) *additionalProvidersConsumer { @@ -186,13 +194,12 @@ func (i *additionalProvidersConsumer) GetSSHProvider(_ context.Context) (connect } if len(sshConfig.Hosts) == 0 { - hostFromEnv := os.Getenv("SSH_HOST_CONNECT") - if hostFromEnv == "" { + if i.sshHost == "" { return nil, errNotPassedSSHHost } sshConfig.Hosts = append(sshConfig.Hosts, sshconfig.Host{ - Host: hostFromEnv, + Host: i.sshHost, }) } @@ -226,3 +233,7 @@ func (i *additionalProvidersConsumer) Cleanup(ctx context.Context) error { return nil } + +func (i *additionalProvidersConsumer) SetSSHHost(h string) { + i.sshHost = h +} diff --git a/pkg/provider/inititializer.go b/pkg/provider/inititializer.go index b9fd88c..6fbe67a 100644 --- a/pkg/provider/inititializer.go +++ b/pkg/provider/inititializer.go @@ -16,6 +16,8 @@ package provider import ( "context" + "errors" + "fmt" "github.com/name212/govalue" "k8s.io/apimachinery/pkg/runtime/schema" @@ -23,6 +25,27 @@ import ( connection "github.com/deckhouse/lib-connection/pkg" ) +var ( + _ SSHProviderInitializer = &SimpleSSHProviderInitializer{} + _ SSHProviderInitializerWithCleanup = &SimpleSSHProviderInitializer{} + + _ SSHProviderInitializer = &ErrorSSHProviderInitializer{} + _ SSHProviderInitializerWithCleanup = &ErrorSSHProviderInitializer{} + + _ SSHProviderInitializer = &ProvideErrorSSHProviderInitializer{} + _ SSHProviderInitializerWithCleanup = &ProvideErrorSSHProviderInitializer{} + + _ KubeProviderInitializer = &SimpleKubeProviderInitializer{} + _ KubeProviderInitializerWithCleanup = &SimpleKubeProviderInitializer{} + + _ KubeProviderInitializer = &FakeKubeProviderInitializer{} + _ KubeProviderInitializerWithCleanup = &FakeKubeProviderInitializer{} +) + +var ( + ErrCannotProvideSSHProvider = errors.New("cannot provide ssh provider initializer") +) + type SSHProviderInitializer interface { GetSSHProvider(ctx context.Context) (connection.SSHProvider, error) } @@ -63,11 +86,15 @@ func (i *SimpleSSHProviderInitializer) Cleanup(ctx context.Context) error { return i.provider.Cleanup(ctx) } +// ErrorSSHProviderInitializer +// provide ErrorSSHProvider +// this provider returns error for every +// SSHProvider methods call type ErrorSSHProviderInitializer struct { *SimpleSSHProviderInitializer } -func NewErrorSSHProviderForKubeInitializer(err error) *ErrorSSHProviderInitializer { +func NewErrorSSHProviderInitializer(err error) *ErrorSSHProviderInitializer { return &ErrorSSHProviderInitializer{ SimpleSSHProviderInitializer: NewSimpleSSHProviderInitializer( NewErrorSSHProvider(err), @@ -75,6 +102,31 @@ func NewErrorSSHProviderForKubeInitializer(err error) *ErrorSSHProviderInitializ } } +// ProvideErrorSSHProviderInitializer +// this provider returns error for every GetSSHProvider call +// for fail fast in GetRunnerInterface func +type ProvideErrorSSHProviderInitializer struct { + err error +} + +func NewProvideErrorSSHProviderInitializer(err error) *ProvideErrorSSHProviderInitializer { + if err == nil { + err = errors.New("unknown error") + } + + return &ProvideErrorSSHProviderInitializer{ + err: err, + } +} + +func (i *ProvideErrorSSHProviderInitializer) GetSSHProvider(_ context.Context) (connection.SSHProvider, error) { + return nil, fmt.Errorf("%w: %w", ErrCannotProvideSSHProvider, i.err) +} + +func (i *ProvideErrorSSHProviderInitializer) Cleanup(_ context.Context) error { + return nil +} + type SimpleKubeProviderInitializer struct { provider connection.KubeProvider } diff --git a/pkg/provider/ssh.go b/pkg/provider/ssh.go index 9c4b7c7..25e6786 100644 --- a/pkg/provider/ssh.go +++ b/pkg/provider/ssh.go @@ -16,6 +16,7 @@ package provider import ( "context" + "errors" "fmt" mathrand "math/rand" "os" @@ -700,6 +701,8 @@ type ErrorSSHProvider struct { err error } +var ErrSSHClientCannotProvided = errors.New("cannot provide ssh client") + // NewErrorSSHProvider // Special provider that always return error for all operations // expected cleanup @@ -707,7 +710,7 @@ type ErrorSSHProvider struct { // you do not use KubeClient over SSH func NewErrorSSHProvider(err error) *ErrorSSHProvider { if err == nil { - err = fmt.Errorf("ErrorSSHProvider: error not provided") + err = fmt.Errorf("%w ErrorSSHProvider: error not provided", ErrSSHClientCannotProvided) } return &ErrorSSHProvider{err: err} } @@ -737,5 +740,5 @@ func (p *ErrorSSHProvider) Cleanup(context.Context) error { } func (p *ErrorSSHProvider) returnError(op string) error { - return fmt.Errorf("cannot provide ssh client with %s: %w", op, p.err) + return fmt.Errorf("%w with %s: %w", ErrSSHClientCannotProvided, op, p.err) } From 8e9f9b886321b2def5fe32bfe947d0e56af59384 Mon Sep 17 00:00:00 2001 From: Nikolay Mitrofanov Date: Thu, 9 Apr 2026 20:39:05 +0300 Subject: [PATCH 4/4] ++ Signed-off-by: Nikolay Mitrofanov --- pkg/provider/inititializer.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/provider/inititializer.go b/pkg/provider/inititializer.go index 6fbe67a..e2df3b2 100644 --- a/pkg/provider/inititializer.go +++ b/pkg/provider/inititializer.go @@ -87,7 +87,7 @@ func (i *SimpleSSHProviderInitializer) Cleanup(ctx context.Context) error { } // ErrorSSHProviderInitializer -// provide ErrorSSHProvider +// provide ErrorSSHProvider // this provider returns error for every // SSHProvider methods call type ErrorSSHProviderInitializer struct {