Repository: conan-io/conan

Issue:
sudo in SystemPackageTool always fails when in non-interactive terminal
- [x] I've read the [CONTRIBUTING guide](https://raw.githubusercontent.com/conan-io/conan/develop/.github/CONTRIBUTING.md).
- [x] I've specified the Conan version, operating system version and any tool that can be relevant.
- [x] I've explained the steps to reproduce the error or the motivation/use case of the question/suggestion.

### Problem
Commands using `sudo` in SystemPackageTool always fail when used in non-interactive terminal.
This issue originated from https://github.com/bincrafters/community/issues/378. 
Example: Loading a project using cmake-conan wrapper in Qt Creator, with not yet built Qt as Conan dependency.

### Proposal
This could be solved by detecting if we're in non-interactive terminal and telling `sudo` to get password from a specified `askpass` program. It could be controlled with an extension to a variable [CONAN_SYSREQUIRES_SUDO](https://docs.conan.io/en/latest/reference/env_vars.html#conan-sysrequires-sudo) or a new variable, like CONAN_SYSREQUIRES_SUDO_MODE.

Example: `sudo --askpass apt update` will try to use program from SUDO_ASKPASS, or sudo.conf, or fail. A popular application for that is `ssh-askpass`.

From sudo manual:

> -A, --askpass
>    Normally, if sudo requires a password, it will read it from the user's terminal.  If the -A (askpass) option is specified, a (possibly graphical) helper program is executed to read the
>    user's password and output the password to the standard output.  If the SUDO_ASKPASS environment variable is set, it specifies the path to the helper program.  Otherwise, if sudo.conf(5)
>    contains a line specifying the askpass program, that value will be used.  For example:
> 
>        # Path to askpass helper program
>        Path askpass /usr/X11R6/bin/ssh-askpass
> 
>    If no askpass program is available, sudo will exit with an error.

### Steps to reproduce
conanfile.txt
```
[requires]
Qt/5.11.1@bincrafters/stable

[options]
*:shared=True

[generators]
cmake
```
CMakeLists.txt
```
cmake_minimum_required(VERSION 3.10)

project(conan-qt-test LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)

if(NOT EXISTS "${CMAKE_BINARY_DIR}/conan.cmake")
   message(STATUS "Downloading conan.cmake from https://github.com/conan-io/cmake-conan")
   file(DOWNLOAD "https://raw.githubusercontent.com/conan-io/cmake-conan/v0.12/conan.cmake"
       "${CMAKE_BINARY_DIR}/conan.cmake")
endif()
include(${CMAKE_BINARY_DIR}/conan.cmake)
conan_cmake_run(
    CONANFILE conanfile.txt
    BASIC_SETUP
    BUILD missing)

add_executable(${PROJECT_NAME} main.cpp)
target_link_libraries(${PROJECT_NAME} ${CONAN_LIBS})
```
main.cpp
```
#include <QtCore/QDebug>

int main()
{
    qDebug() << "hello\n";
    return 0;
}
```
**Steps:**
* Open Qt Creator
* Click File -> Open file or project
* Find CMakeLists.txt and click Open
* Select your kit, click on details, uncheck Default, click Configure Project

**Expected result:**
CMake + Conan configuration should succeed.

**Actual result:**
CMake + Conan configuration fails with:
```
Running "/usr/local/bin/cmake -E server --pipe=/tmp/cmake-.BlsLST/socket --experimental" in /home/fazer/dev/project/test/build-conan-cmake-qtcreator-bug-Desktop-Debug.
Starting to parse CMake project, using: "-DCMAKE_BUILD_TYPE:STRING=Debug", "-DCMAKE_CXX_COMPILER:STRING=/usr/bin/g++-8", "-DCMAKE_C_COMPILER:STRING=/usr/bin/gcc-8", "-DCMAKE_PREFIX_PATH:STRING=".
The CXX compiler identification is GNU 8.1.0
Check for working CXX compiler: /usr/bin/g++-8
Check for working CXX compiler: /usr/bin/g++-8 -- works
Detecting CXX compiler ABI info
Detecting CXX compiler ABI info - done
Detecting CXX compile features
Detecting CXX compile features - done
Downloading conan.cmake from https://github.com/conan-io/cmake-conan
Conan ** WARNING** : This detection of settings from cmake is experimental and incomplete. Please check 'conan.cmake' and contribute
Conan executing: conan install /home/fazer/dev/project/test/conan-cmake-qtcreator-bug/conanfile.txt -g cmake -s build_type=Debug -s os=Linux -s compiler=gcc -s compiler.version=8 -s compiler.libcxx=libstdc++11 --build=missing
CMake Error at /home/fazer/dev/project/test/build-conan-cmake-qtcreator-bug-Desktop-Debug/conan.cmake:335 (message):
  Conan install failed='1'
Call Stack (most recent call first):
  /home/fazer/dev/project/test/build-conan-cmake-qtcreator-bug-Desktop-Debug/conan.cmake:413 (conan_cmake_install)
  CMakeLists.txt:14 (conan_cmake_run)

Configuring incomplete, errors occurred!
See also "/home/fazer/dev/project/test/build-conan-cmake-qtcreator-bug-Desktop-Debug/CMakeFiles/CMakeOutput.log".
CMake Project parsing failed.
```
Note that due to a bug in CMake (https://gitlab.kitware.com/cmake/cmake/issues/17931), it doesn't pass the `conan install` command output to Qt Creator to its "General Messages" pane.

However, it is possible to see the output in "Compile Output" pane when you follow these steps:
* Comment Qt/5.11.1@bincrafters/stable in conanfile.txt
* In Qt Creator click Build -> Run CMake
The project will now be configured successfully.
* Uncomment Qt/5.11.1@bincrafters/stable in conanfile.txt
* Click Build -> Run (Ctrl+R)
Result:
```
13:20:15: Running steps for project conan-qt-test...
13:20:15: Starting: "/usr/local/bin/cmake" --build . --target all
[0/1 ?/sec] Re-running CMake...
-- Conan ** WARNING** : This detection of settings from cmake is experimental and incomplete. Please check 'conan.cmake' and contribute
-- Conan executing: conan install /home/fazer/dev/project/test/conan-cmake-qtcreator-bug/conanfile.txt -g cmake -s build_type=Debug -s os=Linux -s compiler=gcc -s compiler.version=8 -s compiler.libcxx=libstdc++11 --build=missing
Running: sudo apt-get update
sudo: no tty present and no askpass program specified
ERROR: Qt/5.11.1@bincrafters/stable: Error in requirements() method, line 111
	installer.update() # Update the package database
	ConanException: Command 'sudo apt-get update' failed
CMake Error at /home/fazer/dev/project/test/build-conan-cmake-qtcreator-bug-Desktop-Debug/conan.cmake:335 (message):
  Conan install failed='1'
Call Stack (most recent call first):
  /home/fazer/dev/project/test/build-conan-cmake-qtcreator-bug-Desktop-Debug/conan.cmake:413 (conan_cmake_install)
  CMakeLists.txt:14 (conan_cmake_run)


-- Configuring incomplete, errors occurred!
See also "/home/fazer/dev/project/test/build-conan-cmake-qtcreator-bug-Desktop-Debug/CMakeFiles/CMakeOutput.log".
FAILED: build.ninja 
/home/fazer/dev/app/cmake/bin/cmake -H/home/fazer/dev/project/test/conan-cmake-qtcreator-bug -B/home/fazer/dev/project/test/build-conan-cmake-qtcreator-bug-Desktop-Debug
ninja: error: rebuilding 'build.ninja': subcommand failed
13:20:15: The process "/usr/local/bin/cmake" exited with code 1.
Error while building/deploying project conan-qt-test (kit: Desktop)
When executing step "CMake Build"
13:20:15: Elapsed time: 00:00.
```
Also note that once the original problem is fixed, you will need to make two changes in CMakeLists.txt in order to compile the program (they weren't in the original version to stop unrelated errors):
* add this line:
`find_package(Qt5 5.11 COMPONENTS Core REQUIRED)`
* changing linking to
`target_link_libraries(${PROJECT_NAME} Qt5::Core)`

### Environment
Conan 1.6.1
Ubuntu 18.04 x64

Discussion:
ArekPiekarz: What happens when we start the noninteractive terminal as root and run conan in a way that triggers installing system packages? Would it succeed without asking for the password? In that case, would `--askpass` unnecessarily block the execution until a password is provided? Or would it be successful anyway?
lasote: I think in that case "sudo" won't require a password so the `--askpass` argument won't have any effect, but please, @jgsogo can you try?
jgsogo: On docker ``lasote/conantests``, given

```python
from conans.client.tools.system_pm import SystemPackageTool

sp = SystemPackageTool()
```

Use cases:
1. No-root, tty:
    ```
    >>> sp.install("boost-dev")
    dpkg-query: no packages found matching boost-dev
    Running: sudo apt-get update
    [sudo] password for tom:
    ```

2. No-root, no-tty:
    ```
    >>> sp._tool._sudo_str = "sudo --askpass "
    >>> sp.install("boost-dev")
    dpkg-query: no packages found matching boost-dev
    Running: sudo --askpass apt-get install -y --no-install-recommends boost-dev
    sudo: no askpass program specified, try setting SUDO_ASKPASS
    Traceback (most recent call last):
    ```

3. No-root, no-tty, with SUDO_ASKPASS
    With file ``pw.sh``: 
    ```
    #!/bin/bash
    echo 'invalid-root-pass'
    ```

    and running python with the env variable
    ```
    $ SUDO_ASKPASS=/home/conan/conan/pw.sh python

    ```

    ```
    >>> sp._tool._sudo_str = "sudo --askpass "
    >>> sp.install("boost-dev")
    dpkg-query: no packages found matching boost-dev
    Running: sudo --askpass apt-get update
    Sorry, try again.
    Sorry, try again.
    sudo: 3 incorrect password attempts
    Traceback (most recent call last):
    ...
    ```

3. Root, tty
    ```
    >>> sp.install("ssh")
    dpkg-query: no packages found matching ssh
    Running: sudo apt-get install -y --no-install-recommends ssh
    Reading package lists... Done
    ...
    ```

4. Root, no-tty
    ```
    >>> sp._tool._sudo_str = "sudo --askpass "
    >>> sp.install("vlc")
    dpkg-query: no packages found matching vlc
    Running: sudo --askpass apt-get install -y --no-install-recommends vlc
    Reading package lists... Done
    Building dependency tree       

    ```

I think all casuistry is covered. 👨‍🏫 

Patch:
diff --git a/conans/client/runner.py b/conans/client/runner.py
index 1ea09dbf3..af65c6d3f 100644
--- a/conans/client/runner.py
+++ b/conans/client/runner.py
@@ -56,7 +56,7 @@ class ConanRunner(object):
 
         try:
             # piping both stdout, stderr and then later only reading one will hang the process
-            # if the other fills the pip. So piping stdout, and redirecting stderr to stdour,
+            # if the other fills the pip. So piping stdout, and redirecting stderr to stdout,
             # so both are merged and use just a single get_stream_lines() call
             proc = Popen(command, shell=True, stdout=PIPE, stderr=STDOUT, cwd=cwd)
         except Exception as e:
diff --git a/conans/client/tools/system_pm.py b/conans/client/tools/system_pm.py
index 36cd56772..41984bdf4 100644
--- a/conans/client/tools/system_pm.py
+++ b/conans/client/tools/system_pm.py
@@ -1,5 +1,6 @@
 import os
-from six import string_types
+import sys
+
 from conans.client.runner import ConanRunner
 from conans.client.tools.oss import OSInfo
 from conans.errors import ConanException
@@ -14,10 +15,20 @@ class SystemPackageTool(object):
         os_info = os_info or OSInfo()
         self._is_up_to_date = False
         self._tool = tool or self._create_tool(os_info)
-        self._tool._sudo_str = "sudo " if self._is_sudo_enabled() else ""
+        self._tool._sudo_str = self._get_sudo_str()
         self._tool._runner = runner or ConanRunner()
         self._tool._recommends = recommends
 
+    @staticmethod
+    def _get_sudo_str():
+        if not SystemPackageTool._is_sudo_enabled():
+            return ""
+
+        if hasattr(sys.stdout, "isatty") and not sys.stdout.isatty():
+            return "sudo --askpass "
+        else:
+            return "sudo "
+
     @staticmethod
     def _is_sudo_enabled():
         if "CONAN_SYSREQUIRES_SUDO" not in os.environ:


Test patch:
diff --git a/conans/test/util/tools_test.py b/conans/test/util/tools_test.py
index ad7d4a637..74243c5c1 100644
--- a/conans/test/util/tools_test.py
+++ b/conans/test/util/tools_test.py
@@ -46,6 +46,18 @@ class SystemPackageToolTest(unittest.TestCase):
         out = TestBufferConanOutput()
         set_global_instances(out, requests)
 
+    def test_sudo_tty(self):
+        with tools.environment_append({"CONAN_SYSREQUIRES_SUDO": "False",}):
+            self.assertFalse(SystemPackageTool._is_sudo_enabled())
+            self.assertEqual(SystemPackageTool._get_sudo_str(), "")
+
+        with tools.environment_append({"CONAN_SYSREQUIRES_SUDO": "True"}):
+            self.assertTrue(SystemPackageTool._is_sudo_enabled())
+            self.assertEqual(SystemPackageTool._get_sudo_str(), "sudo --askpass ")
+
+            with mock.patch("sys.stdout.isatty", return_value=True):
+                self.assertEqual(SystemPackageTool._get_sudo_str(), "sudo ")
+
     def verify_update_test(self):
         # https://github.com/conan-io/conan/issues/3142
         with tools.environment_append({"CONAN_SYSREQUIRES_SUDO": "False",
@@ -86,8 +98,11 @@ class SystemPackageToolTest(unittest.TestCase):
                 self.assertEqual(expected, command)
                 return ret
 
-        def _run_add_repository_test(repository, gpg_key, sudo, update):
-            sudo_cmd = "sudo " if sudo else ""
+        def _run_add_repository_test(repository, gpg_key, sudo, isatty, update):
+            sudo_cmd = ""
+            if sudo:
+                sudo_cmd = "sudo " if isatty else "sudo --askpass "
+
             runner = RunnerOrderedMock()
             runner.commands.append(("{}apt-add-repository {}".format(sudo_cmd, repository), 0))
             if gpg_key:
@@ -110,12 +125,15 @@ class SystemPackageToolTest(unittest.TestCase):
         # Run several test cases
         repository = "deb http://repo/url/ saucy universe multiverse"
         gpg_key = 'http://one/key.gpg'
-        _run_add_repository_test(repository, gpg_key, sudo=True, update=True)
-        _run_add_repository_test(repository, gpg_key, sudo=True, update=False)
-        _run_add_repository_test(repository, gpg_key, sudo=False, update=True)
-        _run_add_repository_test(repository, gpg_key, sudo=False, update=False)
-        _run_add_repository_test(repository, gpg_key=None, sudo=True, update=True)
-        _run_add_repository_test(repository, gpg_key=None, sudo=False, update=False)
+        _run_add_repository_test(repository, gpg_key, sudo=True, isatty=False, update=True)
+        _run_add_repository_test(repository, gpg_key, sudo=True, isatty=False, update=False)
+        _run_add_repository_test(repository, gpg_key, sudo=False, isatty=False, update=True)
+        _run_add_repository_test(repository, gpg_key, sudo=False, isatty=False, update=False)
+        _run_add_repository_test(repository, gpg_key=None, sudo=True, isatty=False, update=True)
+        _run_add_repository_test(repository, gpg_key=None, sudo=False, isatty=True, update=False)
+
+        with mock.patch("sys.stdout.isatty", return_value=True):
+            _run_add_repository_test(repository, gpg_key, sudo=True, isatty=True, update=True)
 
     def system_package_tool_test(self):
 
@@ -129,41 +147,41 @@ class SystemPackageToolTest(unittest.TestCase):
             os_info.linux_distro = "debian"
             spt = SystemPackageTool(runner=runner, os_info=os_info)
             spt.update()
-            self.assertEquals(runner.command_called, "sudo apt-get update")
+            self.assertEquals(runner.command_called, "sudo --askpass apt-get update")
 
             os_info.linux_distro = "ubuntu"
             spt = SystemPackageTool(runner=runner, os_info=os_info)
             spt.update()
-            self.assertEquals(runner.command_called, "sudo apt-get update")
+            self.assertEquals(runner.command_called, "sudo --askpass apt-get update")
 
             os_info.linux_distro = "knoppix"
             spt = SystemPackageTool(runner=runner, os_info=os_info)
             spt.update()
-            self.assertEquals(runner.command_called, "sudo apt-get update")
+            self.assertEquals(runner.command_called, "sudo --askpass apt-get update")
 
             os_info.linux_distro = "fedora"
             spt = SystemPackageTool(runner=runner, os_info=os_info)
             spt.update()
-            self.assertEquals(runner.command_called, "sudo yum update -y")
+            self.assertEquals(runner.command_called, "sudo --askpass yum update -y")
 
             os_info.linux_distro = "opensuse"
             spt = SystemPackageTool(runner=runner, os_info=os_info)
             spt.update()
-            self.assertEquals(runner.command_called, "sudo zypper --non-interactive ref")
+            self.assertEquals(runner.command_called, "sudo --askpass zypper --non-interactive ref")
 
             os_info.linux_distro = "redhat"
             spt = SystemPackageTool(runner=runner, os_info=os_info)
             spt.install("a_package", force=False)
             self.assertEquals(runner.command_called, "rpm -q a_package")
             spt.install("a_package", force=True)
-            self.assertEquals(runner.command_called, "sudo yum install -y a_package")
+            self.assertEquals(runner.command_called, "sudo --askpass yum install -y a_package")
 
             os_info.linux_distro = "debian"
             spt = SystemPackageTool(runner=runner, os_info=os_info)
             with self.assertRaises(ConanException):
                 runner.return_ok = False
                 spt.install("a_package")
-                self.assertEquals(runner.command_called, "sudo apt-get install -y --no-install-recommends a_package")
+                self.assertEquals(runner.command_called, "sudo --askpass apt-get install -y --no-install-recommends a_package")
 
             runner.return_ok = True
             spt.install("a_package", force=False)
@@ -184,9 +202,9 @@ class SystemPackageToolTest(unittest.TestCase):
 
             spt = SystemPackageTool(runner=runner, os_info=os_info)
             spt.update()
-            self.assertEquals(runner.command_called, "sudo pkg update")
+            self.assertEquals(runner.command_called, "sudo --askpass pkg update")
             spt.install("a_package", force=True)
-            self.assertEquals(runner.command_called, "sudo pkg install -y a_package")
+            self.assertEquals(runner.command_called, "sudo --askpass pkg install -y a_package")
             spt.install("a_package", force=False)
             self.assertEquals(runner.command_called, "pkg info a_package")
 
@@ -286,13 +304,13 @@ class SystemPackageToolTest(unittest.TestCase):
             spt = SystemPackageTool(runner=runner, tool=AptTool())
             spt.install(packages)
             self.assertEquals(2, runner.calls)
-            runner = RunnerMultipleMock(["sudo apt-get update",
-                                         "sudo apt-get install -y --no-install-recommends yet_another_package"])
+            runner = RunnerMultipleMock(["sudo --askpass apt-get update",
+                                         "sudo --askpass apt-get install -y --no-install-recommends yet_another_package"])
             spt = SystemPackageTool(runner=runner, tool=AptTool())
             spt.install(packages)
             self.assertEquals(7, runner.calls)
 
-            runner = RunnerMultipleMock(["sudo apt-get update"])
+            runner = RunnerMultipleMock(["sudo --askpass apt-get update"])
             spt = SystemPackageTool(runner=runner, tool=AptTool())
             with self.assertRaises(ConanException):
                 spt.install(packages)
@@ -334,7 +352,7 @@ class SystemPackageToolTest(unittest.TestCase):
             "CONAN_SYSREQUIRES_SUDO": "True"
         }):
             packages = ["verify_package", "verify_another_package", "verify_yet_another_package"]
-            runner = RunnerMultipleMock(["sudo apt-get update"])
+            runner = RunnerMultipleMock(["sudo --askpass apt-get update"])
             spt = SystemPackageTool(runner=runner, tool=AptTool())
             with self.assertRaises(ConanException) as exc:
                 spt.install(packages)
@@ -349,7 +367,7 @@ class SystemPackageToolTest(unittest.TestCase):
             "CONAN_SYSREQUIRES_SUDO": "True"
         }):
             packages = ["disabled_package", "disabled_another_package", "disabled_yet_another_package"]
-            runner = RunnerMultipleMock(["sudo apt-get update"])
+            runner = RunnerMultipleMock(["sudo --askpass apt-get update"])
             spt = SystemPackageTool(runner=runner, tool=AptTool())
             spt.install(packages)
             self.assertIn('\n'.join(packages), tools.system_pm._global_output)
@@ -360,7 +378,7 @@ class SystemPackageToolTest(unittest.TestCase):
             "CONAN_SYSREQUIRES_MODE": "EnAbLeD",
             "CONAN_SYSREQUIRES_SUDO": "True"
         }):
-            runner = RunnerMultipleMock(["sudo apt-get update"])
+            runner = RunnerMultipleMock(["sudo --askpass apt-get update"])
             spt = SystemPackageTool(runner=runner, tool=AptTool())
             with self.assertRaises(ConanException) as exc:
                 spt.install(packages)
@@ -391,13 +409,13 @@ class SystemPackageToolTest(unittest.TestCase):
             os_info = OSInfo()
             update_command = None
             if os_info.with_apt:
-                update_command = "sudo apt-get update"
+                update_command = "sudo --askpass apt-get update"
             elif os_info.with_yum:
-                update_command = "sudo yum update -y"
+                update_command = "sudo --askpass yum update -y"
             elif os_info.with_zypper:
-                update_command = "sudo zypper --non-interactive ref"
+                update_command = "sudo --askpass zypper --non-interactive ref"
             elif os_info.with_pacman:
-                update_command = "sudo pacman -Syyu --noconfirm"
+                update_command = "sudo --askpass pacman -Syyu --noconfirm"
 
             return "Command '{0}' failed".format(update_command) if update_command is not None else None