Skip to content

Commit eb4bb4d

Browse files
authored
Merge pull request #551 from xcp-ng/gln/ssh-err-to-ssherr-field-uqtu
commands: expose ssh's own errors separately via ssherr field
2 parents 03887a1 + 336403d commit eb4bb4d

1 file changed

Lines changed: 54 additions & 30 deletions

File tree

lib/commands.py

Lines changed: 54 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import os
66
import platform
77
import subprocess
8+
import tempfile
89

910
import lib.config as config
1011
from lib.netutil import wrap_ip
@@ -24,12 +25,15 @@ def __init__(self, returncode: int, stdout: str, cmd: str | list[str], exception
2425
self.cmd = cmd
2526

2627
class SSHCommandFailed(BaseCommandFailed):
27-
def __init__(self, returncode: int, stdout: str, cmd: str):
28+
__slots__ = ('ssherr',)
29+
30+
def __init__(self, returncode: int, stdout: str, cmd: str, ssherr: str = ''):
2831
msg_end = f": {stdout}" if stdout else "."
2932
super(SSHCommandFailed, self).__init__(
3033
returncode, stdout, cmd,
3134
f'SSH command ({cmd}) failed with return code {returncode}{msg_end}'
3235
)
36+
self.ssherr = ssherr
3337

3438
class LocalCommandFailed(BaseCommandFailed):
3539
def __init__(self, returncode: int, stdout: str, cmd: str | list[str]):
@@ -49,8 +53,11 @@ def __init__(self, returncode: int, stdout: ResultOutputT):
4953
self.stdout: ResultOutputT = stdout
5054

5155
class SSHResult(BaseCmdResult[ResultOutputT]):
52-
def __init__(self, returncode: int, stdout: ResultOutputT):
56+
__slots__ = ('ssherr',)
57+
58+
def __init__(self, returncode: int, stdout: ResultOutputT, ssherr: str = ''):
5359
super(SSHResult, self).__init__(returncode, stdout)
60+
self.ssherr: str = ssherr
5461

5562
class LocalCommandResult(BaseCmdResult[ResultOutputT]):
5663
def __init__(self, returncode: int, stdout: ResultOutputT):
@@ -102,63 +109,80 @@ def _ssh(
102109
else:
103110
opts += ['-o', 'ControlMaster no']
104111

105-
ssh_cmd = ['ssh', f'root@{hostname_or_ip}'] + opts + [cmd]
106-
107112
# Fetch banner and remove it to avoid stdout/stderr pollution.
108113
banner_res = None
109114
if config.ignore_ssh_banner:
110-
banner_res = subprocess.run(
111-
['ssh', f'root@{hostname_or_ip}'] + opts + ['\n'],
112-
stdout=subprocess.PIPE,
113-
stderr=subprocess.STDOUT,
114-
check=False
115-
)
115+
with tempfile.NamedTemporaryFile(suffix='.log', prefix='ssh_err_banner_', mode='r') as banner_log_file:
116+
banner_res = subprocess.run(
117+
['ssh', f'root@{hostname_or_ip}'] + opts + ['-E', banner_log_file.name] + ['\n'],
118+
stdout=subprocess.PIPE,
119+
stderr=subprocess.STDOUT,
120+
check=False
121+
)
122+
banner_ssherr = banner_log_file.read()
123+
if banner_res.returncode == 255:
124+
return SSHCommandFailed(255, "SSH Error: %s" % banner_ssherr, cmd, ssherr=banner_ssherr)
116125

117-
logging.debug(f"[{hostname_or_ip}] {cmd}")
118-
process = subprocess.Popen(
119-
ssh_cmd,
120-
stdout=subprocess.PIPE,
121-
stderr=subprocess.STDOUT
122-
)
123126
if background:
127+
ssh_cmd = ['ssh', f'root@{hostname_or_ip}'] + opts + [cmd]
128+
logging.debug(f"[{hostname_or_ip}] {cmd}")
129+
subprocess.Popen(
130+
ssh_cmd,
131+
stdout=subprocess.PIPE,
132+
stderr=subprocess.STDOUT
133+
)
124134
return None
125135

126-
stdout = []
127-
assert process.stdout is not None
128-
for line in iter(process.stdout.readline, b''):
129-
readable_line = line.decode(errors='replace').strip()
130-
stdout.append(line)
131-
logging.debug("> %s", readable_line)
132-
_, stderr = process.communicate()
133-
res = subprocess.CompletedProcess(ssh_cmd, process.returncode, b''.join(stdout), stderr)
136+
with tempfile.NamedTemporaryFile(suffix='.log', prefix='ssh_err_', mode='r') as ssh_log_file:
137+
opts += ['-E', ssh_log_file.name]
138+
ssh_cmd = ['ssh', f'root@{hostname_or_ip}'] + opts + [cmd]
139+
140+
logging.debug(f"[{hostname_or_ip}] {cmd}")
141+
process = subprocess.Popen(
142+
ssh_cmd,
143+
stdout=subprocess.PIPE,
144+
stderr=subprocess.STDOUT
145+
)
146+
147+
stdout = []
148+
assert process.stdout is not None
149+
for line in iter(process.stdout.readline, b''):
150+
readable_line = line.decode(errors='replace').strip()
151+
stdout.append(line)
152+
logging.debug("> %s", readable_line)
153+
_, stderr = process.communicate()
154+
res = subprocess.CompletedProcess(ssh_cmd, process.returncode, b''.join(stdout), stderr)
155+
156+
ssherr = ssh_log_file.read()
157+
158+
if ssherr:
159+
logging.debug("[%s] ssh stderr: %s", hostname_or_ip, ssherr)
134160

135161
# Get a decoded version of the output in any case, replacing potential errors
136162
output_for_errors = res.stdout.decode(errors='replace').strip()
137163

138164
# Even if check is False, we still raise in case of return code 255, which means a SSH error.
139165
if res.returncode == 255:
140-
return SSHCommandFailed(255, "SSH Error: %s" % output_for_errors, cmd)
166+
return SSHCommandFailed(255, "SSH Error: %s" % ssherr, cmd, ssherr=ssherr)
141167

142168
output: bytes = res.stdout
143169
if banner_res:
144-
if banner_res.returncode == 255:
145-
return SSHCommandFailed(255, "SSH Error: %s" % banner_res.stdout.decode(errors='replace'), cmd)
146170
output = output[len(banner_res.stdout):]
147171

148172
if res.returncode and check:
149-
return SSHCommandFailed(res.returncode, output_for_errors, cmd)
173+
return SSHCommandFailed(res.returncode, output_for_errors, cmd, ssherr=ssherr)
150174

151175
if decode:
152176
output_str = output.decode()
153177
if simple_output:
154178
return output_str.strip()
155179
else:
156-
return SSHResult[str](res.returncode, output_str)
180+
return SSHResult[str](res.returncode, output_str, ssherr=ssherr)
157181
else:
158182
if simple_output:
159183
return output.strip()
160184
else:
161-
return SSHResult[bytes](res.returncode, output)
185+
return SSHResult[bytes](res.returncode, output, ssherr=ssherr)
162186

163187
# The actual code is in _ssh().
164188
# This function is kept short for shorter pytest traces upon SSH failures, which are common,

0 commit comments

Comments
 (0)