Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 42 additions & 1 deletion src/pyinfra/connectors/util.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import re
from dataclasses import dataclass
from getpass import getpass
from queue import Queue
Expand All @@ -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")
Expand Down Expand Up @@ -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,
Expand All @@ -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"]:
Expand Down
54 changes: 54 additions & 0 deletions tests/test_connectors/test_local.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
)
145 changes: 134 additions & 11 deletions tests/test_connectors/test_ssh.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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 "
Expand Down
Loading