Skip to content

Commit 3b2688f

Browse files
authored
fix(e2e-framework): return last error from SshRunCommand instead of (… (#54536)
…nil, nil) ## What does this PR do? Fixes an `err` variable-shadowing bug in `SshRunCommand` (`test/e2e-framework/testing/utils/ssh/ssh.go`) that made the retry helper return `(nil, nil)` — no output **and** no error — whenever a command failed on every retry. ```go var err error for range NR_SSH_COMMAND_RETRIES { sshSession, err := sshClient.NewSession() // := re-declares err in loop scope ... output, err := sshSession.CombinedOutput(command) // := again, loop-scoped if err == nil { return output, nil } } return nil, err // outer err is always nil → returns (nil, nil) on persistent failure ``` Both `:=` statements declare a loop-scoped `err`, so the outer `err` is never assigned. The helper's own doc comment ("Returns the last error if retries exceeded") was effectively a lie — it couldn't. The fix assigns to the outer `err` (`var` decls + `=`) so the last failure propagates, and closes the `ssh.Session` between retries (it was previously leaked on every attempt). ## Motivation Regression introduced in #48951 (commit `7b455c21`) when the SSH helper was moved into the shared infra package. It was surfaced by #49708, which added the OpenShift teardown dump. The only caller is the GCP dump path (`testing/provisioners/gcp/kubernetes/kubernetes_dump.go`): ```go sshOutput, err := sshutils.SshRunCommand(sshClient, "cat .kube/config", &out) if err != nil { ... return } // never taken — err is nil kubeConfig, err := clientcmd.Load(sshOutput) // fed nil/empty bytes instead ``` When `cat .kube/config` over SSH fails, the error guard is skipped and empty bytes flow into `clientcmd.Load`, so OpenShift teardown diagnostics emit misleading garbage (`Forbidden: cannot list ...`) instead of a clean "SSH failed" error — which is exactly the confusing output seen on failing `new-e2e-ssi-openshift` runs (INPLAT-995). ## Describe how you validated your changes - Added `ssh_test.go` with an in-memory SSH server that completes the handshake but fails every command; asserts `SshRunCommand` returns a non-nil error after exhausting retries. - Verified the test **fails** (`An error is expected but got nil`) against the original buggy code and **passes** with the fix. - Confirmed default `go vet` produces **no** shadow warning for the original code — this bug is invisible to standard tooling (follow-up: consider enabling a `shadow` linter). - `bazel test //test/e2e-framework/testing/utils/ssh:ssh_test` → PASS. `BUILD.bazel` regenerated via gazelle. ## Additional Notes Test-framework-only change (`test/e2e-framework/`), no user-facing Agent behavior — no release note. Co-authored-by: frank.spano <frank.spano@datadoghq.com>
1 parent c00e3f6 commit 3b2688f

3 files changed

Lines changed: 132 additions & 2 deletions

File tree

test/e2e-framework/testing/utils/ssh/BUILD.bazel

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
load("@rules_go//go:def.bzl", "go_library")
2+
load("//bazel/rules/go:dd_agent_go_test.bzl", "dd_agent_go_test")
23

34
go_library(
45
name = "ssh",
@@ -10,3 +11,13 @@ go_library(
1011
"@org_golang_x_crypto//ssh/agent",
1112
],
1213
)
14+
15+
dd_agent_go_test(
16+
name = "ssh_test",
17+
srcs = ["ssh_test.go"],
18+
embed = [":ssh"],
19+
deps = [
20+
"@com_github_stretchr_testify//require",
21+
"@org_golang_x_crypto//ssh",
22+
],
23+
)

test/e2e-framework/testing/utils/ssh/ssh.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,12 +77,18 @@ func SshRunCommand(sshClient *ssh.Client, command string, logger io.Writer) ([]b
7777

7878
var err error
7979
for range NR_SSH_COMMAND_RETRIES {
80-
sshSession, err := sshClient.NewSession()
80+
var sshSession *ssh.Session
81+
// Assign to the outer err (not ":=") so the last failure is returned
82+
// once retries are exhausted; shadowing it here made this function
83+
// return (nil, nil) on persistent failures.
84+
sshSession, err = sshClient.NewSession()
8185
if err != nil {
8286
return nil, err
8387
}
8488

85-
output, err := sshSession.CombinedOutput(command)
89+
var output []byte
90+
output, err = sshSession.CombinedOutput(command)
91+
sshSession.Close()
8692
if err == nil {
8793
return output, nil
8894
}
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
// Unless explicitly stated otherwise all files in this repository are licensed
2+
// under the Apache License Version 2.0.
3+
// This product includes software developed at Datadog (https://www.datadoghq.com/).
4+
// Copyright 2016-present Datadog, Inc.
5+
6+
package ssh
7+
8+
import (
9+
"crypto/rand"
10+
"crypto/rsa"
11+
"io"
12+
"net"
13+
"testing"
14+
15+
"github.com/stretchr/testify/require"
16+
"golang.org/x/crypto/ssh"
17+
)
18+
19+
// startFailingSSHServer starts an in-memory SSH server that accepts
20+
// connections and completes (no-auth) handshakes, but makes every command
21+
// execution fail with a non-zero exit status. It returns the listen address.
22+
func startFailingSSHServer(t *testing.T) string {
23+
t.Helper()
24+
25+
key, err := rsa.GenerateKey(rand.Reader, 2048)
26+
require.NoError(t, err)
27+
signer, err := ssh.NewSignerFromKey(key)
28+
require.NoError(t, err)
29+
30+
cfg := &ssh.ServerConfig{NoClientAuth: true}
31+
cfg.AddHostKey(signer)
32+
33+
ln, err := net.Listen("tcp", "127.0.0.1:0")
34+
require.NoError(t, err)
35+
t.Cleanup(func() { _ = ln.Close() })
36+
37+
go func() {
38+
for {
39+
conn, err := ln.Accept()
40+
if err != nil {
41+
return // listener closed on cleanup
42+
}
43+
go serveFailingConn(conn, cfg)
44+
}
45+
}()
46+
47+
return ln.Addr().String()
48+
}
49+
50+
func serveFailingConn(nConn net.Conn, cfg *ssh.ServerConfig) {
51+
sConn, chans, reqs, err := ssh.NewServerConn(nConn, cfg)
52+
if err != nil {
53+
return
54+
}
55+
defer sConn.Close()
56+
go ssh.DiscardRequests(reqs)
57+
58+
for newChan := range chans {
59+
if newChan.ChannelType() != "session" {
60+
_ = newChan.Reject(ssh.UnknownChannelType, "unknown channel type")
61+
continue
62+
}
63+
ch, chReqs, err := newChan.Accept()
64+
if err != nil {
65+
continue
66+
}
67+
go func() {
68+
for req := range chReqs {
69+
switch req.Type {
70+
case "exec", "shell":
71+
if req.WantReply {
72+
_ = req.Reply(true, nil)
73+
}
74+
// Report a non-zero exit status so the client's
75+
// CombinedOutput returns an *ssh.ExitError.
76+
_, _ = ch.SendRequest("exit-status", false, ssh.Marshal(struct{ Status uint32 }{Status: 1}))
77+
_ = ch.Close()
78+
default:
79+
if req.WantReply {
80+
_ = req.Reply(false, nil)
81+
}
82+
}
83+
}
84+
}()
85+
}
86+
}
87+
88+
func dialSSHClient(t *testing.T, addr string) *ssh.Client {
89+
t.Helper()
90+
conn, err := net.Dial("tcp", addr)
91+
require.NoError(t, err)
92+
c, chans, reqs, err := ssh.NewClientConn(conn, addr, &ssh.ClientConfig{
93+
User: "test",
94+
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
95+
})
96+
require.NoError(t, err)
97+
client := ssh.NewClient(c, chans, reqs)
98+
t.Cleanup(func() { _ = client.Close() })
99+
return client
100+
}
101+
102+
// TestSshRunCommandReturnsErrorAfterRetries guards against the err
103+
// variable-shadowing regression that made SshRunCommand return (nil, nil)
104+
// when a command failed on every retry (see PR #48951 / commit 7b455c21).
105+
func TestSshRunCommandReturnsErrorAfterRetries(t *testing.T) {
106+
addr := startFailingSSHServer(t)
107+
client := dialSSHClient(t, addr)
108+
109+
output, err := SshRunCommand(client, "false", io.Discard)
110+
111+
require.Error(t, err, "SshRunCommand must return the command error after exhausting retries, not (nil, nil)")
112+
require.Nil(t, output)
113+
}

0 commit comments

Comments
 (0)