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:
If I have understood it all, this should have an easy fix: just add the ``--askpass`` parameter to ``sudo`` if the terminal is not interactive. Once this is implemented the user will get an error (if no password is found) telling him what to do: 

```bash
Running: sudo apt-get update
sudo: no tty present and no askpass program specified
```

Then the user will be able to solve it by using the ``SUDO_ASKPASS`` env variable or the ``sudo.conf`` file.

Am I missing something? Thanks for your feedback, @ArekPiekarz.



Resolving patch:
diff --git a/conans/client/runner.py b/conans/client/runner.py
--- a/conans/client/runner.py
+++ b/conans/client/runner.py
@@ -56,7 +56,7 @@ def _pipe_os_call(self, command, stream_output, log_handler, cwd):
 
         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
--- 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 @@ def __init__(self, runner=None, os_info=None, tool=None, recommends=False):
         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
--- a/conans/test/util/tools_test.py
+++ b/conans/test/util/tools_test.py
@@ -46,6 +46,18 @@ def setUp(self):
         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 @@ def __call__(runner_self, command, output, win_bash=False, subsystem=None):
                 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 @@ def _run_add_repository_test(repository, gpg_key, sudo, update):
         # 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 @@ def system_package_tool_test(self):
             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 @@ def system_package_tool_test(self):
 
             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 @@ def __call__(self, command, output):  # @UnusedVariable
             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 @@ def __call__(self, command, *args, **kwargs):  # @UnusedVariable
             "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 @@ def __call__(self, command, *args, **kwargs):  # @UnusedVariable
             "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 @@ def __call__(self, command, *args, **kwargs):  # @UnusedVariable
             "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 @@ def get_linux_error_message():
             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