diff --git a/src/pyinfra/connectors/util.py b/src/pyinfra/connectors/util.py index 641606f58..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,13 +27,24 @@ # 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", ) +# 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 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""" temp=$(mktemp "${{TMPDIR:={0}}}/pyinfra-sudo-askpass-XXXXXXXXXXXX") @@ -346,6 +358,34 @@ def _ensure_askpass_set_for_host( return path +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 + + 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 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( state: State, host: Host, @@ -359,6 +399,7 @@ def make_unix_command_for_host( # Handle sudo password if command_arguments.get("_sudo"): + _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 1c9bfa561..900952015 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,56 @@ def test_write_stdin_io_object(self): call(b"abc\n"), ], ) + + def test_run_shell_command_fails_with_unsupported_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, + ) + + 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 aca0eacba..0a463d65e 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) @@ -802,22 +820,122 @@ def test_run_shell_command_retry_for_sudo_password( get_pty=False, ) + @mock.patch("pyinfra.connectors.ssh.SSHClient") + def test_run_shell_command_fails_with_unsupported_sudo_rs( + self, + fake_ssh_client, + ): + # sudo-rs versions older than 0.2.11 are not supported; pyinfra should fail fast. + fake_ssh = mock.MagicMock() + fake_stdin = 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.0"])) + + fake_ssh.exec_command.return_value = ( + fake_stdin, + sudo_version_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) + + with self.assertRaises(PyinfraError): + host.run_shell_command("echo hi", _sudo=True) + + 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_sudo_rs_password( + def test_run_shell_command_retry_for_supported_sudo_rs_password( 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. + # 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() - 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.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 @@ -828,8 +946,10 @@ def test_run_shell_command_retry_for_sudo_rs_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 - fake_stdout.channel.recv_exit_status.side_effect = lambda: return_values.pop(0) + 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 @@ -907,6 +1027,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 +1361,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 +1384,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 "