From 0b5f431ab679065d45cf8561b4a401f4d74a8492 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Tosser?= Date: Fri, 10 Jul 2026 11:37:20 +0200 Subject: [PATCH 1/2] feat(connectors): fail fast when target uses sudo-rs --- src/pyinfra/connectors/util.py | 29 +++++++++++++ tests/test_connectors/test_local.py | 24 +++++++++++ tests/test_connectors/test_ssh.py | 64 ++++++++++++++++++----------- 3 files changed, 92 insertions(+), 25 deletions(-) diff --git a/src/pyinfra/connectors/util.py b/src/pyinfra/connectors/util.py index 641606f58..ae3a4114e 100644 --- a/src/pyinfra/connectors/util.py +++ b/src/pyinfra/connectors/util.py @@ -33,6 +33,15 @@ "sudo-rs: interactive authentication is required", ) +# String printed by `sudo --version` when the target uses sudo-rs instead of traditional sudo. +SUDO_RS_VERSION_LINE = "sudo-rs" + +SUDO_RS_NOT_SUPPORTED_MESSAGE = ( + "sudo-rs is installed as the system sudo, but pyinfra does not support it. " + "Please install or restore traditional sudo. " + "See https://github.com/pyinfra-dev/pyinfra/issues/1499" +) + ASKPASS_COMMAND = r""" temp=$(mktemp "${{TMPDIR:={0}}}/pyinfra-sudo-askpass-XXXXXXXXXXXX") @@ -346,6 +355,25 @@ def _ensure_askpass_set_for_host( return path +def _fail_if_sudo_rs(host: Host) -> None: + # Cache the check result so we only run `sudo --version` once per host. + if host.connector_data.get("sudo_version_checked"): + return + + host.connector_data["sudo_version_checked"] = True + + # Run without sudo to avoid recursion; sudo --version does not require auth. + ok, output = host.run_shell_command( + StringCommand("sudo", "--version"), + _sudo=False, + print_output=False, + print_input=False, + ) + + if ok and any(SUDO_RS_VERSION_LINE in line for line in output.stdout_lines): + raise PyinfraError(SUDO_RS_NOT_SUPPORTED_MESSAGE) + + def make_unix_command_for_host( state: State, host: Host, @@ -359,6 +387,7 @@ def make_unix_command_for_host( # Handle sudo password if command_arguments.get("_sudo"): + _fail_if_sudo_rs(host) # If the sudo password is not set in the direct arguments, # set it from the connector data value. if "_sudo_password" not in command_arguments or not command_arguments["_sudo_password"]: diff --git a/tests/test_connectors/test_local.py b/tests/test_connectors/test_local.py index 1c9bfa561..abafef0fb 100644 --- a/tests/test_connectors/test_local.py +++ b/tests/test_connectors/test_local.py @@ -5,6 +5,7 @@ from pyinfra.api import Config, HiddenValue, State, StringCommand from pyinfra.api.connect import connect_all +from pyinfra.api.exceptions import PyinfraError from pyinfra.connectors.util import make_unix_command from ..util import make_inventory @@ -230,3 +231,26 @@ def test_write_stdin_io_object(self): call(b"abc\n"), ], ) + + def test_run_shell_command_fails_with_sudo_rs(self): + inventory = make_inventory(hosts=("@local",)) + State(inventory, Config()) + host = inventory.get_host("@local") + + fake_stdout = MagicMock() + fake_stdout.__iter__ = MagicMock(return_value=iter(["sudo-rs 0.2.0"])) + fake_process = MagicMock(returncode=0) + fake_process.stdout = fake_stdout + fake_process.stderr = MagicMock() + self.fake_popen_mock.return_value = fake_process + + with self.assertRaises(PyinfraError): + host.run_shell_command("echo hi", _sudo=True) + + self.fake_popen_mock.assert_any_call( + "sh -c 'sudo --version'", + shell=True, + stdout=PIPE, + stderr=PIPE, + stdin=PIPE, + ) diff --git a/tests/test_connectors/test_ssh.py b/tests/test_connectors/test_ssh.py index aca0eacba..157cb6d62 100644 --- a/tests/test_connectors/test_ssh.py +++ b/tests/test_connectors/test_ssh.py @@ -644,6 +644,8 @@ def test_run_shell_command_sudo_password_automatic_prompt( fake_getpass, ): fake_ssh = mock.MagicMock() + sudo_version_stdout = mock.MagicMock() + sudo_version_stdout.__iter__.return_value = ["Sudo version 1.9.14p2"] first_fake_stdout = mock.MagicMock() second_fake_stdout = mock.MagicMock() third_fake_stdout = mock.MagicMock() @@ -652,6 +654,11 @@ def test_run_shell_command_sudo_password_automatic_prompt( second_fake_stdout.__iter__.return_value = ["/tmp/pyinfra-sudo-askpass-XXXXXXXXXXXX"] fake_ssh.exec_command.side_effect = [ + ( + mock.MagicMock(), + sudo_version_stdout, + mock.MagicMock(), + ), # sudo-rs detection check ( mock.MagicMock(), first_fake_stdout, @@ -678,6 +685,7 @@ def test_run_shell_command_sudo_password_automatic_prompt( host.connect() command = "echo Šablony" + sudo_version_stdout.channel.recv_exit_status.return_value = 0 first_fake_stdout.channel.recv_exit_status.return_value = 1 second_fake_stdout.channel.recv_exit_status.return_value = 0 third_fake_stdout.channel.recv_exit_status.return_value = 0 @@ -707,6 +715,8 @@ def test_run_shell_command_sudo_password_automatic_prompt_with_special_chars_in_ fake_getpass, ): fake_ssh = mock.MagicMock() + sudo_version_stdout = mock.MagicMock() + sudo_version_stdout.__iter__.return_value = ["Sudo version 1.9.14p2"] first_fake_stdout = mock.MagicMock() second_fake_stdout = mock.MagicMock() third_fake_stdout = mock.MagicMock() @@ -715,6 +725,11 @@ def test_run_shell_command_sudo_password_automatic_prompt_with_special_chars_in_ second_fake_stdout.__iter__.return_value = ["/tmp/pyinfra-sudo-askpass-XXXXXXXXXXXX"] fake_ssh.exec_command.side_effect = [ + ( + mock.MagicMock(), + sudo_version_stdout, + mock.MagicMock(), + ), # sudo-rs detection check ( mock.MagicMock(), first_fake_stdout, @@ -741,6 +756,7 @@ def test_run_shell_command_sudo_password_automatic_prompt_with_special_chars_in_ host.connect() command = "echo Šablony" + sudo_version_stdout.channel.recv_exit_status.return_value = 0 first_fake_stdout.channel.recv_exit_status.return_value = 1 second_fake_stdout.channel.recv_exit_status.return_value = 0 third_fake_stdout.channel.recv_exit_status.return_value = 0 @@ -789,7 +805,9 @@ def test_run_shell_command_retry_for_sudo_password( host.connector_data["sudo_askpass_path__/tmp"] = "/tmp/pyinfra-sudo-askpass-XXXXXXXXXXXX" command = "echo hi" - return_values = [1, 0] # return 0 on the second call + # First value is for the sudo-rs detection check (sudo --version), + # then the failed password-required command and the retry. + return_values = [0, 1, 0] fake_stdout.channel.recv_exit_status.side_effect = lambda: return_values.pop(0) out = host.run_shell_command(command, _sudo=True) @@ -803,21 +821,24 @@ def test_run_shell_command_retry_for_sudo_password( ) @mock.patch("pyinfra.connectors.ssh.SSHClient") - @mock.patch("pyinfra.connectors.util.getpass") - def test_run_shell_command_retry_for_sudo_rs_password( + def test_run_shell_command_fails_with_sudo_rs( self, - fake_getpass, fake_ssh_client, ): - # sudo-rs (the Rust replacement, default in Ubuntu 25.10+) prints a different message - # when it cannot prompt non-interactively; the retry path should recognize it too. - fake_getpass.return_value = "PASSWORD" - + # sudo-rs (the Rust replacement, default in Ubuntu 25.10+) is not supported; + # pyinfra should fail fast with a clear message. fake_ssh = mock.MagicMock() fake_stdin = mock.MagicMock() - fake_stdout = mock.MagicMock() - fake_stderr = ["sudo-rs: interactive authentication is required"] - fake_ssh.exec_command.return_value = fake_stdin, fake_stdout, fake_stderr + + sudo_version_stdout = mock.MagicMock() + sudo_version_stdout.channel.recv_exit_status.return_value = 0 + sudo_version_stdout.__iter__ = mock.Mock(return_value=iter(["sudo-rs 0.2.0"])) + + fake_ssh.exec_command.return_value = ( + fake_stdin, + sudo_version_stdout, + mock.MagicMock(), + ) fake_ssh_client.return_value = fake_ssh @@ -825,21 +846,11 @@ def test_run_shell_command_retry_for_sudo_rs_password( state = State(inventory, Config()) host = inventory.get_host("somehost") host.connect(state) - host.connector_data["sudo_askpass_path__/tmp"] = "/tmp/pyinfra-sudo-askpass-XXXXXXXXXXXX" - command = "echo hi" - return_values = [1, 0] # return 0 on the second call - fake_stdout.channel.recv_exit_status.side_effect = lambda: return_values.pop(0) + with self.assertRaises(PyinfraError): + host.run_shell_command("echo hi", _sudo=True) - out = host.run_shell_command(command, _sudo=True) - assert len(out) == 2 - assert out[0] is True - assert fake_getpass.called - fake_ssh.exec_command.assert_called_with( - "env SUDO_ASKPASS=/tmp/pyinfra-sudo-askpass-XXXXXXXXXXXX " - "PYINFRA_SUDO_PASSWORD=PASSWORD sudo -H -A -k sh -c 'echo hi'", - get_pty=False, - ) + fake_ssh.exec_command.assert_any_call("sh -c 'sudo --version'", get_pty=False) # SSH file put/get tests # @@ -907,6 +918,7 @@ def test_put_file_sudo(self, fake_sftp_client, fake_ssh_client): ), get_pty=False, ), + mock.call("sh -c 'sudo --version'", get_pty=False), mock.call( ( "sudo -H -n -u ubuntu sh -c 'cp /tmp/pyinfra-de01e82cb691e8a31369da3c7c8f17341c44ac24 '\"'\"'not another file'\"'\"''" # noqa: E501 @@ -1240,7 +1252,8 @@ def test_get_file_sudo_remove_fail(self, fake_sftp_client, fake_ssh_client): host.connect() stdout_mock = mock.MagicMock() - stdout_mock.channel.recv_exit_status.side_effect = [0, 1] + # First call is the sudo-rs detection check (sudo --version), then cp and rm. + stdout_mock.channel.recv_exit_status.side_effect = [0, 0, 1] fake_ssh_client().exec_command.return_value = ( mock.MagicMock(), stdout_mock, @@ -1262,6 +1275,7 @@ def test_get_file_sudo_remove_fail(self, fake_sftp_client, fake_ssh_client): fake_ssh_client().exec_command.assert_has_calls( [ + mock.call("sh -c 'sudo --version'", get_pty=False), mock.call( ( "sudo -H -n -u ubuntu sh -c 'cp not-a-file " From 957ff6de553f372d839722afd61f93f599efd54f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Tosser?= Date: Fri, 10 Jul 2026 14:56:24 +0200 Subject: [PATCH 2/2] feat(connectors): allow sudo-rs 0.2.11+ and detect new password prefix --- src/pyinfra/connectors/util.py | 32 +++++--- tests/test_connectors/test_local.py | 32 +++++++- tests/test_connectors/test_ssh.py | 115 +++++++++++++++++++++++++++- 3 files changed, 165 insertions(+), 14 deletions(-) diff --git a/src/pyinfra/connectors/util.py b/src/pyinfra/connectors/util.py index ae3a4114e..bebe2090c 100644 --- a/src/pyinfra/connectors/util.py +++ b/src/pyinfra/connectors/util.py @@ -1,5 +1,6 @@ from __future__ import annotations +import re from dataclasses import dataclass from getpass import getpass from queue import Queue @@ -26,21 +27,23 @@ # Output lines that indicate sudo could not prompt for a password and we should retry with one. # - sudo (Todd C. Miller's): "sudo: a password is required" -# - sudo-rs (Trifecta Tech): "sudo-rs: interactive authentication is required" +# - sudo-rs >= 0.2.11: "sudo: interactive authentication is required" +# - sudo-rs < 0.2.11: "sudo-rs: interactive authentication is required" # https://github.com/trifectatechfoundation/sudo-rs (default sudo on Ubuntu 25.10+) SUDO_PASSWORD_REQUIRED_LINES = ( "sudo: a password is required", + "sudo: interactive authentication is required", "sudo-rs: interactive authentication is required", ) -# String printed by `sudo --version` when the target uses sudo-rs instead of traditional sudo. -SUDO_RS_VERSION_LINE = "sudo-rs" +# Minimum sudo-rs version that supports the flags and behavior pyinfra needs. +SUDO_RS_MINIMUM_VERSION = (0, 2, 11) SUDO_RS_NOT_SUPPORTED_MESSAGE = ( - "sudo-rs is installed as the system sudo, but pyinfra does not support it. " - "Please install or restore traditional sudo. " + "sudo-rs is installed as the system sudo, but pyinfra does not support versions " + "older than {}.{}.{}. Please upgrade sudo-rs or install traditional sudo. " "See https://github.com/pyinfra-dev/pyinfra/issues/1499" -) +).format(*SUDO_RS_MINIMUM_VERSION) ASKPASS_COMMAND = r""" @@ -355,7 +358,7 @@ def _ensure_askpass_set_for_host( return path -def _fail_if_sudo_rs(host: Host) -> None: +def _fail_if_unsupported_sudo_rs(host: Host) -> None: # Cache the check result so we only run `sudo --version` once per host. if host.connector_data.get("sudo_version_checked"): return @@ -370,8 +373,17 @@ def _fail_if_sudo_rs(host: Host) -> None: print_input=False, ) - if ok and any(SUDO_RS_VERSION_LINE in line for line in output.stdout_lines): - raise PyinfraError(SUDO_RS_NOT_SUPPORTED_MESSAGE) + if not ok: + return + + for line in output.stdout_lines: + match = re.search(r"sudo-rs\s+(\d+)\.(\d+)\.(\d+)", line) + if not match: + continue + + version = tuple(int(part) for part in match.groups()) + if version < SUDO_RS_MINIMUM_VERSION: + raise PyinfraError(SUDO_RS_NOT_SUPPORTED_MESSAGE) def make_unix_command_for_host( @@ -387,7 +399,7 @@ def make_unix_command_for_host( # Handle sudo password if command_arguments.get("_sudo"): - _fail_if_sudo_rs(host) + _fail_if_unsupported_sudo_rs(host) # If the sudo password is not set in the direct arguments, # set it from the connector data value. if "_sudo_password" not in command_arguments or not command_arguments["_sudo_password"]: diff --git a/tests/test_connectors/test_local.py b/tests/test_connectors/test_local.py index abafef0fb..900952015 100644 --- a/tests/test_connectors/test_local.py +++ b/tests/test_connectors/test_local.py @@ -232,7 +232,7 @@ def test_write_stdin_io_object(self): ], ) - def test_run_shell_command_fails_with_sudo_rs(self): + def test_run_shell_command_fails_with_unsupported_sudo_rs(self): inventory = make_inventory(hosts=("@local",)) State(inventory, Config()) host = inventory.get_host("@local") @@ -254,3 +254,33 @@ def test_run_shell_command_fails_with_sudo_rs(self): stderr=PIPE, stdin=PIPE, ) + + def test_run_shell_command_allows_supported_sudo_rs(self): + inventory = make_inventory(hosts=("@local",)) + State(inventory, Config()) + host = inventory.get_host("@local") + + def make_process(stdout_lines, returncode): + fake_stdout = MagicMock() + fake_stdout.__iter__ = MagicMock(return_value=iter(stdout_lines)) + fake_process = MagicMock(returncode=returncode) + fake_process.stdout = fake_stdout + fake_process.stderr = MagicMock() + return fake_process + + self.fake_popen_mock.side_effect = [ + make_process(["sudo-rs 0.2.13"], 0), + make_process([], 0), + ] + + out = host.run_shell_command("echo hi", _sudo=True) + assert len(out) == 2 + assert out[0] is True + + self.fake_popen_mock.assert_any_call( + "sh -c 'sudo --version'", + shell=True, + stdout=PIPE, + stderr=PIPE, + stdin=PIPE, + ) diff --git a/tests/test_connectors/test_ssh.py b/tests/test_connectors/test_ssh.py index 157cb6d62..0a463d65e 100644 --- a/tests/test_connectors/test_ssh.py +++ b/tests/test_connectors/test_ssh.py @@ -821,12 +821,11 @@ def test_run_shell_command_retry_for_sudo_password( ) @mock.patch("pyinfra.connectors.ssh.SSHClient") - def test_run_shell_command_fails_with_sudo_rs( + def test_run_shell_command_fails_with_unsupported_sudo_rs( self, fake_ssh_client, ): - # sudo-rs (the Rust replacement, default in Ubuntu 25.10+) is not supported; - # pyinfra should fail fast with a clear message. + # sudo-rs versions older than 0.2.11 are not supported; pyinfra should fail fast. fake_ssh = mock.MagicMock() fake_stdin = mock.MagicMock() @@ -852,6 +851,116 @@ def test_run_shell_command_fails_with_sudo_rs( fake_ssh.exec_command.assert_any_call("sh -c 'sudo --version'", get_pty=False) + @mock.patch("pyinfra.connectors.ssh.SSHClient") + def test_run_shell_command_allows_supported_sudo_rs( + self, + fake_ssh_client, + ): + # sudo-rs 0.2.11+ is allowed through and executes the command normally. + fake_ssh = mock.MagicMock() + sudo_version_stdout = mock.MagicMock() + sudo_version_stdout.channel.recv_exit_status.return_value = 0 + sudo_version_stdout.__iter__ = mock.Mock(return_value=iter(["sudo-rs 0.2.13"])) + + command_stdout = mock.MagicMock() + command_stdout.channel.recv_exit_status.return_value = 0 + command_stdout.__iter__ = mock.Mock(return_value=iter([])) + + fake_ssh.exec_command.side_effect = [ + ( + mock.MagicMock(), + sudo_version_stdout, + mock.MagicMock(), + ), + ( + mock.MagicMock(), + command_stdout, + mock.MagicMock(), + ), + ] + + fake_ssh_client.return_value = fake_ssh + + inventory = make_inventory(hosts=("somehost",)) + state = State(inventory, Config()) + host = inventory.get_host("somehost") + host.connect(state) + + out = host.run_shell_command("echo hi", _sudo=True) + assert len(out) == 2 + assert out[0] is True + + fake_ssh.exec_command.assert_any_call("sh -c 'sudo --version'", get_pty=False) + + @mock.patch("pyinfra.connectors.ssh.SSHClient") + @mock.patch("pyinfra.connectors.util.getpass") + def test_run_shell_command_retry_for_supported_sudo_rs_password( + self, + fake_getpass, + fake_ssh_client, + ): + # sudo-rs 0.2.11+ uses the same "sudo:" prefix as traditional sudo when + # it cannot prompt non-interactively; the retry path should recognize it. + fake_getpass.return_value = "PASSWORD" + + fake_ssh = mock.MagicMock() + sudo_version_stdout = mock.MagicMock() + sudo_version_stdout.channel.recv_exit_status.return_value = 0 + sudo_version_stdout.__iter__ = mock.Mock(return_value=iter(["sudo-rs 0.2.13"])) + first_fake_stdout = mock.MagicMock() + second_fake_stdout = mock.MagicMock() + third_fake_stdout = mock.MagicMock() + + first_fake_stdout.__iter__.return_value = ["sudo: interactive authentication is required\r"] + second_fake_stdout.__iter__.return_value = ["/tmp/pyinfra-sudo-askpass-XXXXXXXXXXXX"] + + fake_ssh.exec_command.side_effect = [ + ( + mock.MagicMock(), + sudo_version_stdout, + mock.MagicMock(), + ), # sudo-rs version check + ( + mock.MagicMock(), + first_fake_stdout, + mock.MagicMock(), + ), # command w/o sudo password + ( + mock.MagicMock(), + second_fake_stdout, + mock.MagicMock(), + ), # SUDO_ASKPASS_COMMAND + ( + mock.MagicMock(), + third_fake_stdout, + mock.MagicMock(), + ), # command with sudo pw + ] + + fake_ssh_client.return_value = fake_ssh + + inventory = make_inventory(hosts=("somehost",)) + state = State(inventory, Config()) + host = inventory.get_host("somehost") + host.connect(state) + host.connector_data["sudo_askpass_path__/tmp"] = "/tmp/pyinfra-sudo-askpass-XXXXXXXXXXXX" + + command = "echo hi" + sudo_version_stdout.channel.recv_exit_status.return_value = 0 + first_fake_stdout.channel.recv_exit_status.return_value = 1 + second_fake_stdout.channel.recv_exit_status.return_value = 0 + third_fake_stdout.channel.recv_exit_status.return_value = 0 + + out = host.run_shell_command(command, _sudo=True) + assert len(out) == 2 + assert out[0] is True + assert fake_getpass.called + fake_ssh.exec_command.assert_called_with( + "env SUDO_ASKPASS=/tmp/pyinfra-sudo-askpass-XXXXXXXXXXXX " + "PYINFRA_SUDO_PASSWORD=PASSWORD sudo -H -A -k sh -c 'echo hi'", + get_pty=False, + ) + # SSH file put/get tests #