Skip to content

Commit 8fcc5fd

Browse files
authored
operations.server.reboot: survive dead SSH session during askpass cleanup (#1665)
1 parent 545d2b8 commit 8fcc5fd

3 files changed

Lines changed: 75 additions & 2 deletions

File tree

src/pyinfra/connectors/util.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -233,14 +233,24 @@ def write_stdin(stdin, buffer):
233233

234234

235235
def remove_any_sudo_askpass_file(host) -> None:
236+
# Best-effort cleanup: this is called from host.disconnect(), and the
237+
# connection may already be broken (e.g. after `server.reboot`). Swallow
238+
# any errors from the remote ``rm`` and still clear the local state so a
239+
# reconnect will regenerate a fresh askpass file.
236240
sudo_askpass_path = host.connector_data.get("sudo_askpass_path")
237241
if sudo_askpass_path:
238-
host.run_shell_command(StringCommand("rm", "-f", QuoteString(sudo_askpass_path)))
242+
try:
243+
host.run_shell_command(StringCommand("rm", "-f", QuoteString(sudo_askpass_path)))
244+
except Exception as e:
245+
logger.debug("Could not remove sudo askpass file %s: %s", sudo_askpass_path, e)
239246
host.connector_data["sudo_askpass_path"] = None
240247

241248
su_askpass_path = host.connector_data.get("su_askpass_path")
242249
if su_askpass_path:
243-
host.run_shell_command(StringCommand("rm", "-f", QuoteString(su_askpass_path)))
250+
try:
251+
host.run_shell_command(StringCommand("rm", "-f", QuoteString(su_askpass_path)))
252+
except Exception as e:
253+
logger.debug("Could not remove su askpass file %s: %s", su_askpass_path, e)
244254
host.connector_data["su_askpass_path"] = None
245255

246256

src/pyinfra/operations/server.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,13 @@ def wait_and_reconnect(state, host): # pragma: no cover
9191
sleep(delay)
9292
max_retries = round(reboot_timeout / interval)
9393

94+
# The remote askpass files (if any) live on a host that has just
95+
# rebooted — the SSH session is dead and there is nothing to clean up.
96+
# Clear the stored paths before disconnecting so the disconnect path
97+
# does not attempt an ``rm -f`` over the broken connection.
98+
host.connector_data["sudo_askpass_path"] = None
99+
host.connector_data["su_askpass_path"] = None
100+
94101
host.disconnect() # make sure we are properly disconnected
95102
retries = 0
96103

tests/test_connectors/test_util.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,3 +224,59 @@ def test_command_exists_su_config_only(self):
224224
host = state.inventory.get_host("somehost")
225225
command = make_unix_command_for_host(state, host, "echo Šablony")
226226
assert command.get_raw_value() == "sh -c 'echo Šablony'"
227+
228+
229+
class TestRemoveAnySudoAskpassFile(TestCase):
230+
def test_clears_state_and_runs_rm(self):
231+
commands = []
232+
host = MagicMock()
233+
host.connector_data = {
234+
"sudo_askpass_path": "/tmp/sudo-askpass",
235+
"su_askpass_path": "/tmp/su-askpass",
236+
}
237+
host.run_shell_command = lambda cmd: commands.append(cmd.get_raw_value())
238+
239+
remove_any_sudo_askpass_file(host)
240+
241+
assert commands == [
242+
"rm -f /tmp/sudo-askpass",
243+
"rm -f /tmp/su-askpass",
244+
]
245+
assert host.connector_data["sudo_askpass_path"] is None
246+
assert host.connector_data["su_askpass_path"] is None
247+
248+
def test_swallows_errors_and_clears_state(self):
249+
"""
250+
Regression test for #1645: `host.disconnect()` → `remove_any_sudo_askpass_file`
251+
is called after ``server.reboot`` when the SSH session is already dead.
252+
The remote ``rm`` must fail gracefully and the stored path must still
253+
be cleared so a reconnect will regenerate a fresh askpass file.
254+
"""
255+
host = MagicMock()
256+
host.connector_data = {
257+
"sudo_askpass_path": "/tmp/sudo-askpass",
258+
"su_askpass_path": "/tmp/su-askpass",
259+
}
260+
261+
def failing_run(cmd):
262+
raise Exception("SSH session not active")
263+
264+
host.run_shell_command = failing_run
265+
266+
# Must not raise — this is a best-effort cleanup.
267+
remove_any_sudo_askpass_file(host)
268+
269+
assert host.connector_data["sudo_askpass_path"] is None
270+
assert host.connector_data["su_askpass_path"] is None
271+
272+
def test_noop_when_no_state(self):
273+
host = MagicMock()
274+
host.connector_data = {
275+
"sudo_askpass_path": None,
276+
"su_askpass_path": None,
277+
}
278+
host.run_shell_command = MagicMock()
279+
280+
remove_any_sudo_askpass_file(host)
281+
282+
host.run_shell_command.assert_not_called()

0 commit comments

Comments
 (0)