Python Asyncio получает тупик, если требуется многократный ввод stdin

Я написал инструмент командной строки для выполнения git pull для нескольких репозиториев git с использованием Python Asyncio. Это работает нормально, если все репозитории имеют настройки входа по паролю ssh. Это также работает нормально, если только 1 репо требует ввода пароля. Когда для нескольких репозиториев требуется ввод пароля, кажется, что они зашли в тупик.

Моя реализация очень проста. Основная логика

utils.exec_async_tasks(
        utils.run_async(path, cmds) for path in repos.values())

где run_async создает и ожидает вызов подпроцесса, а exec_async_tasks выполняет все задачи.

async def run_async(path: str, cmds: List[str]):
    """
    Run 'cmds' asynchronously in 'path' directory
    """
    process = await asyncio.create_subprocess_exec(
        *cmds, stdout=asyncio.subprocess.PIPE, cwd=path)
    stdout, _ = await process.communicate()
    stdout and print(stdout.decode())


def exec_async_tasks(tasks: List[Coroutine]):
    """
    Execute tasks asynchronously
    """
    # TODO: asyncio API is nicer in python 3.7
    if platform.system() == 'Windows':
        loop = asyncio.ProactorEventLoop()
        asyncio.set_event_loop(loop)
    else:
        loop = asyncio.get_event_loop()

    try:
        loop.run_until_complete(asyncio.gather(*tasks))
    finally:
        loop.close()

Полная база кода здесь на github.

Я думаю, что проблема заключается в следующем. В run_async asyncio.create_subprocess_exec перенаправление для stdin отсутствует, а системный stdin используется для всех подпроцессов (репо). Когда первый репозиторий запрашивает ввод пароля, планировщик асинхронного режима видит блокирующий ввод и переключается на второй репо, ожидая ввода из командной строки. Но если второе хранилище запрашивает ввод пароля до того, как ввод пароля для первого хранилища завершится, системный ввод будет связан со вторым хранилищем. И первый репо будет ждать ввода навсегда.

Я не уверен, как справиться с этой ситуацией. Нужно ли перенаправлять стандартный ввод для каждого подпроцесса? Что делать, если некоторые репозитории имеют логин без пароля, а некоторые нет?

Некоторые идеи заключаются в следующем

  1. определить, когда требуется ввод пароля в create_subprocess_exec. Если это так, тогда вызовите input() и передайте его результат в process.communicate(input). Но как я могу обнаружить это на лету?

  2. определить, какие репозитории требуют ввода пароля, и исключить их из асинхронного выполнения. Какой лучший способ сделать это?

Ответ 1

В конфигурации по умолчанию, когда требуется имя пользователя или пароль, git будет напрямую обращаться к /dev/tty для лучшего контроля над "управляющим" терминальным устройством, например устройством, которое позволяет вам взаимодействовать с пользователем. Поскольку подпроцессы по умолчанию наследуют управляющий терминал от своего родителя, все запущенные вами процессы git будут обращаться к одному и тому же устройству TTY. Так что да, они будут зависать при попытке чтения и записи в один и тот же TTY с процессами, забивающими друг друга ожидаемым вводом.

Упрощенный метод предотвращения этого состоит в том, чтобы дать каждому подпроцессу свой собственный сеанс; каждый сеанс имеет различный управляющий TTY. Сделайте это, установив start_new_session=True:

process = await asyncio.create_subprocess_exec(
    *cmds, stdout=asyncio.subprocess.PIPE, cwd=path, start_new_session=True)

Вы не можете заранее определить, какие команды git могут требовать учетные данные пользователя, потому что git может быть настроен на получение учетных данных из целого ряда мест, и они используются только в том случае, если удаленный репозиторий действительно требует проверки подлинности.

Что еще хуже, для удаленных URL-адресов ssh:// git вообще не обрабатывает аутентификацию, а оставляет ее клиентскому процессу ssh который он открывает. Подробнее об этом ниже.

Как Git запрашивает учетные данные (для чего угодно, кроме ssh), однако настраивается; см. документацию gitcredentials. Вы можете использовать это, если ваш код должен перенаправлять запросы учетных данных конечному пользователю. Я не позволю командам git делать это через терминал, потому что как пользователь узнает, какая конкретная команда git получит какие учетные данные, не говоря уже о проблемах, которые у вас возникнут, чтобы убедиться, что приглашения приходят в логический порядок.

Вместо этого я бы направил все запросы на учетные данные через ваш скрипт. У вас есть два варианта сделать это с:

  • Установите GIT_ASKPASS среды GIT_ASKPASS, указав на исполняемый файл, который git должен запускать для каждого приглашения.

    Этот исполняемый файл вызывается с одним аргументом, подсказкой для отображения пользователю. Он вызывается отдельно для каждой части информации, необходимой для заданных учетных данных, например, для имени пользователя (если оно еще не известно) и пароля. Текст подсказки должен прояснить пользователю, о чем его просят (например, "Username for 'https://github.com': " или "Password for 'https://[email protected]': ".

  • Зарегистрировать учетную запись помощника; это выполняется как команда оболочки (так что может иметь свои собственные предварительно настроенные аргументы командной строки -c) и один дополнительный аргумент, сообщающий помощнику, какая операция ожидается от него. Если он передается get в качестве последнего аргумента, то просят предоставить учетные данные для данного хоста и протокола, или можно сказать, что определенные полномочия были успешными с store, или были отвергнуты с erase. Во всех случаях он может читать информацию из stdin, чтобы узнать, на каком хосте git пытается пройти аутентификацию, в формате многострочный key=value.

    Таким образом, с помощником по учетным данным, вы получаете запрос на комбинацию имени пользователя и пароля вместе, как один шаг, и вы также получаете больше информации о процессе; обработка операций store и erase позволяет более эффективно кэшировать учетные данные.

Git fill сначала запрашивает каждого сконфигурированного помощника по FILES данным в порядке конфигурации (см. Раздел " FILES ", чтобы понять, как 4 расположения файла конфигурации обрабатываются по порядку). Вы можете добавить новую одноразовую вспомогательную конфигурацию в командной строке git с помощью -c credential.helper=... командной строки -c credential.helper=..., который добавляется в конец. Если никакой помощник по GIT_ASKPASS данным не смог заполнить отсутствующее имя пользователя или пароль, то пользователю предлагается GIT_ASKPASS или другие параметры запроса.

Для соединений SSH git создает новый дочерний процесс ssh. Затем SSH будет обрабатывать аутентификацию и может запрашивать у пользователя учетные данные или ssh-ключи, запрашивать у пользователя кодовую фразу. Это снова будет сделано через /dev/tty, и SSH более упрям в этом. Хотя вы можете установить SSH_ASKPASS среды SSH_ASKPASS в двоичный файл, который будет использоваться для запроса, SSH будет использовать его только в том случае, если нет сеанса TTY и также установлен DISPLAY.

SSH_ASKPASS должен быть исполняемым файлом (поэтому не следует передавать аргументы), и вы не будете уведомлены об успехе или сбое запрашиваемых учетных данных.

Я также обязательно скопировал бы текущие переменные окружения в дочерние процессы, потому что, если пользователь настроил агент ключей SSH для кэширования ключей ssh, вы бы хотели, чтобы процессы SSH, которые git начал использовать их; ключевой агент обнаруживается через переменные среды.

Итак, чтобы создать соединение для помощника по SSH_ASKPASS, который также работает для SSH_ASKPASS, вы можете использовать простой синхронный скрипт, который берет сокет из переменной среды:

#!/path/to/python3
import os, socket, sys
path = os.environ['PROMPTING_SOCKET_PATH']
operation = sys.argv[1]
if operation not in {'get', 'store', 'erase'}:
    operation, params = 'prompt', f'prompt={operation}\n'
else:
    params = sys.stdin.read()
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as s:
    s.connect(path)
    s.sendall(f'''operation={operation}\n{params}'''.encode())
    print(s.recv(2048).decode())

Для этого должен быть установлен исполняемый бит.

Затем он может быть передан команде git как временный файл или включен в PROMPTING_SOCKET_PATH, и вы добавите путь к PROMPTING_SOCKET_PATH домена Unix в переменную среды PROMPTING_SOCKET_PATH. Он может использоваться как SSH_ASKPASS, что позволяет настроить операцию на prompt.

Затем этот сценарий заставляет и SSH, и git запрашивать учетные данные пользователя на сервере сокетов домена UNIX в отдельном соединении для каждого пользователя. Я использовал щедрый размер приемного буфера, я не думаю, что вы когда-нибудь столкнетесь с обменом с этим протоколом, который будет превышать его, и при этом я не вижу причин для его недостаточного заполнения. Это делает сценарий красивым и простым.

Вместо этого вы можете использовать ее в качестве команды GIT_ASKPASS, но тогда вы не получите ценной информации об успешном использовании учетных данных для соединений не-ssh.

Вот демонстрационная реализация сервера сокетов домена UNIX, который обрабатывает git и запросы учетных данных от вышеупомянутого помощника учетных данных, тот, который просто генерирует случайные шестнадцатеричные значения, а не спрашивает пользователя:

import asyncio
import os
import secrets
import tempfile

async def handle_git_prompt(reader, writer):
    data = await reader.read(2048)
    info = dict(line.split('=', 1) for line in data.decode().splitlines())
    print(f"Received credentials request: {info!r}")

    response = []
    operation = info.pop('operation', 'get')

    if operation == 'prompt':
        # new prompt for a username or password or pass phrase for SSH
        password = secrets.token_hex(10)
        print(f"Sending prompt response: {password!r}")
        response.append(password)

    elif operation == 'get':
        # new request for credentials, for a username (optional) and password
        if 'username' not in info:
            username = secrets.token_hex(10)
            print(f"Sending username: {username!r}")
            response.append(f'username={username}\n')

        password = secrets.token_hex(10)
        print(f"Sending password: {password!r}")
        response.append(f'password={password}\n')

    elif operation == 'store':
        # credentials were used successfully, perhaps store these for re-use
        print(f"Credentials for {info['username']} were approved")

    elif operation == 'erase':
        # credentials were rejected, if we cached anything, clear this now.
        print(f"Credentials for {info['username']} were rejected")

    writer.write(''.join(response).encode())
    await writer.drain()

    print("Closing the connection")
    writer.close()
    await writer.wait_closed()

async def main():
    with tempfile.TemporaryDirectory() as dirname:
        socket_path = os.path.join(dirname, 'credential.helper.sock')
        server = await asyncio.start_unix_server(handle_git_prompt, socket_path)

        print(f'Starting a domain socket at {server.sockets[0].getsockname()}')

        async with server:
            await server.serve_forever()

asyncio.run(main())

Обратите внимание, что помощник по учетным данным может также добавить quit=true или quit=1 к выводу, чтобы сказать git, что не нужно искать никаких других помощников по учетным данным и никаких дальнейших запросов.

Вы можете использовать команду git credential <operation> чтобы проверить, работает ли помощник по /full/path/to/credhelper.py данным, передав сценарий помощника (/full/path/to/credhelper.py) с помощью git -c credential.helper=... опция командной строки. git credential могут принимать строку url=... на стандартном вводе, это будет проанализировано так же, как git связался бы с помощниками учетных данных; см. документацию для полной спецификации формата обмена.

Сначала запустите приведенный выше демонстрационный скрипт в отдельном терминале:

$ /usr/local/bin/python3.7 git-credentials-demo.py
Starting a domain socket at /tmp/credhelper.py /var/folders/vh/80414gbd6p1cs28cfjtql3l80000gn/T/tmprxgyvecj/credential.helper.sock

и затем попытайтесь получить учетные данные от этого; Я включил демонстрацию операций store и erase тоже:

$ export PROMPTING_SOCKET_PATH="/var/folders/vh/80414gbd6p1cs28cfjtql3l80000gn/T/tmprxgyvecj/credential.helper.sock"
$ CREDHELPER="/tmp/credhelper.py"
$ echo "url=https://example.com:4242/some/path.git" | git -c "credential.helper=$CREDHELPER" credential fill
protocol=https
host=example.com:4242
username=5b5b0b9609c1a4f94119
password=e259f5be2c96fed718e6
$ echo "url=https://[email protected]/some/path.git" | git -c "credential.helper=$CREDHELPER" credential fill
protocol=https
host=example.com
username=someuser
password=766df0fba1de153c3e99
$ printf "protocol=https\nhost=example.com:4242\nusername=5b5b0b9609c1a4f94119\npassword=e259f5be2c96fed718e6" | git -c "credential.helper=$CREDHELPER" credential approve
$ printf "protocol=https\nhost=example.com\nusername=someuser\npassword=e259f5be2c96fed718e6" | git -c "credential.helper=$CREDHELPER" credential reject

и когда вы посмотрите на вывод примера сценария, вы увидите:

Received credentials request: {'operation': 'get', 'protocol': 'https', 'host': 'example.com:4242'}
Sending username: '5b5b0b9609c1a4f94119'
Sending password: 'e259f5be2c96fed718e6'
Closing the connection
Received credentials request: {'operation': 'get', 'protocol': 'https', 'host': 'example.com', 'username': 'someuser'}
Sending password: '766df0fba1de153c3e99'
Closing the connection
Received credentials request: {'operation': 'store', 'protocol': 'https', 'host': 'example.com:4242', 'username': '5b5b0b9609c1a4f94119', 'password': 'e259f5be2c96fed718e6'}
Credentials for 5b5b0b9609c1a4f94119 were approved
Closing the connection
Received credentials request: {'operation': 'erase', 'protocol': 'https', 'host': 'example.com', 'username': 'someuser', 'password': 'e259f5be2c96fed718e6'}
Credentials for someuser were rejected
Closing the connection

Обратите внимание, как помощнику предоставляется разобранный набор полей для protocol и host, а путь опущен; если вы установите опцию git config credential.useHttpPath=true (или она уже была установлена для вас), то к path=some/path.git будет добавлен path=some/path.git.

Для SSH исполняемый файл просто вызывается с приглашением для отображения:

$ $CREDHELPER "Please enter a super-secret passphrase: "
30b5978210f46bb968b2

и демонстрационный сервер напечатал:

Received credentials request: {'operation': 'prompt', 'prompt': 'Please enter a super-secret passphrase: '}
Sending prompt response: '30b5978210f46bb968b2'
Closing the connection

Просто убедитесь, что при запуске процессов git все еще установлено start_new_session=True чтобы SSH принудительно использовал SSH_ASKPASS.

env = {
    os.environ,
    SSH_ASKPASS='../path/to/credhelper.py',
    DISPLAY='dummy value',
    PROMPTING_SOCKET_PATH='../path/to/domain/socket',
}
process = await asyncio.create_subprocess_exec(
    *cmds, stdout=asyncio.subprocess.PIPE, cwd=path, 
    start_new_session=True, env=env)

Конечно, то, как вы затем обрабатываете запросы пользователей, является отдельной проблемой, но ваш скрипт теперь имеет полный контроль (каждая команда git будет терпеливо ждать, пока помощник по учетным данным вернет запрошенную информацию), и вы можете ставить в очередь запросы, чтобы пользователь заполнил в, и вы можете кэшировать учетные данные по мере необходимости (в случае, если все команды ждут учетные данные для одного и того же хоста).

Ответ 2

GIT_ASKPASS говоря, рекомендуемый способ подачи пароля в git - через "помощников по GIT_ASKPASS " или GIT_ASKPASS, как указано в ответе Мартина, но для Git + SSH ситуация сложная (более подробное обсуждение приведено ниже). Так что было бы трудно правильно установить это в ОС. Если вы просто хотите быстро исправить патч для своего скрипта, вот код, который работает как в Linux, так и в Windows:

async def run_async(...):
    ...
    process = await asyncio.create_subprocess_exec( *cmds, 
        stdin=asyncio.subprocess.PIPE, 
        stdout=asyncio.subprocess.PIPE,
        stderr=asyncio.subprocess.PIPE, 
        start_new_session=True, cwd=path)
    stdout, stderr = await process.communicate(password + b'\n')

Параметр start_new_session=True устанавливает новый SID для дочернего процесса, чтобы ему был назначен новый сеанс, который по умолчанию не имеет управляющего TTY. Тогда SSH будет вынужден прочитать пароль из канала stdin. В Windows start_new_session кажется, не имеет никакого эффекта (в Windows start_new_session нет понятия SID).

Если вы не планируете внедрить Git-credential-manager (GCM) в свой проект "gita", я не буду рекомендовать вводить какой-либо пароль для Git вообще (философия Unix). Просто установите stdin=asyncio.subprocess.DEVNULL и передайте None в process.communicate(). Это заставит Git и SSH использовать существующий CM или прервать (вы можете обработать ошибку позже). Более того, я думаю, что "gita" не хочет портить конфигурацию других CM, таких как GCM для Windows. Таким образом, не беспокойтесь касаться переменных GIT_ASKPASS или SSH_ASKPASS или любой конфигурации credential.*. Это ответственность пользователя (и свобода) установить надлежащий GCM для каждого репо. Обычно дистрибутив Git уже включает реализацию GCM или ASKPASS.

обсуждение

Существует общее недопонимание проблемы: Git не открывает TTY для ввода пароля, SSH делает! На самом деле, другие утилиты, связанные с ssh, такие как rsync и scp, используют то же самое поведение (я понял это непросто при отладке проблемы, связанной с SELinux, несколько месяцев назад). Смотрите приложение для проверки.

Поскольку Git вызывает SSH как подпроцесс, он не может знать, будет ли SSH открывать TTY или нет. Настраиваемые Git, такие как core.askpass или GIT_ASKPASS, не будут препятствовать открытию SSH /dev/tty, по крайней мере, для меня при тестировании с Git 1.8.3 на CentOS 7 (подробности в приложении). Существует два распространенных случая, когда вам следует ожидать ввода пароля:

  • Сервер требует аутентификации по паролю;
  • Для проверки подлинности с открытым ключом хранилище закрытого ключа (в локальном файле ~/.ssh/id_rsa или чипе PKCS11) защищено паролем.

В этих случаях ASKPASS или GCM не помогут вам решить проблему взаимоблокировки. Вы должны отключить TTY.

Вы также можете прочитать о переменной окружения SSH_ASKPASS. Он указывает на исполняемый файл, который будет вызываться при выполнении следующих условий:

  • Контролируемый TTY недоступен для текущего сеанса;
  • Ко. переменная DISPLAY установлена.

Например, в Windows по умолчанию используется SSH_ASKPASS=/mingw64/libexec/git-core/git-gui--askpass. Эта программа поставляется с дистрибутивом основного потока и официальным пакетом Git-GUI. Поэтому в средах рабочего стола Windows и Linux, если вы отключите TTY с помощью start_new_session=True и оставите другие настраиваемые параметры без изменений, SSH автоматически отобразит отдельное окно пользовательского интерфейса для запроса пароля.

аппендикс

Чтобы проверить, какой процесс открывает TTY, вы можете запустить ps -fo pid,tty,cmd когда процесс Git ожидает пароль.

$ ps -fo pid,tty,cmd
3839452 pts/0         \_ git clone ssh://[email protected]/path/to/repo ./repo
3839453 pts/0             \_ ssh [email protected] git-upload-pack '/path/to/repo'

$ ls -l /proc/3839453/fd /proc/3839452/fd
/proc/3839452/fd:
total 0
lrwx------. 1 xxx xxx 64 Apr  4 21:45 0 -> /dev/pts/0
lrwx------. 1 xxx xxx 64 Apr  4 21:45 1 -> /dev/pts/0
lrwx------. 1 xxx xxx 64 Apr  4 21:43 2 -> /dev/pts/0
l-wx------. 1 xxx xxx 64 Apr  4 21:45 4 -> pipe:[49095162]
lr-x------. 1 xxx xxx 64 Apr  4 21:45 5 -> pipe:[49095163]

/proc/3839453/fd:
total 0
lr-x------. 1 xxx xxx 64 Apr  4 21:42 0 -> pipe:[49095162]
l-wx------. 1 xxx xxx 64 Apr  4 21:42 1 -> pipe:[49095163]
lrwx------. 1 xxx xxx 64 Apr  4 21:42 2 -> /dev/pts/0
lrwx------. 1 xxx xxx 64 Apr  4 21:42 3 -> socket:[49091282]
lrwx------. 1 xxx xxx 64 Apr  4 21:45 4 -> /dev/tty

Ответ 3

В итоге я использовал простое решение, предложенное @vincent, то есть отключил любой существующий механизм паролей, установив GIT_ASKPASS среды GIT_ASKPASS, запустил async во всех репозиториях и перезапустил GIT_ASKPASS синхронно.

Основная логика меняется на

cache = os.environ.get('GIT_ASKPASS')
os.environ['GIT_ASKPASS'] = 'echo'
errors = utils.exec_async_tasks(
    utils.run_async(path, cmds) for path in repos.values())
# Reset context and re-run
if cache:
    os.environ['GIT_ASKPASS'] = cache
else:
    del os.environ['GIT_ASKPASS']
for path in errors:
    if path:
        subprocess.run(cmds, cwd=path)

В run_async и exec_async_tasks я просто перенаправляю ошибку и возвращаю path репо в случае сбоя выполнения подпроцесса.

async def run_async(path: str, cmds: List[str]) -> Union[None, str]:
    """
    Run 'cmds' asynchronously in 'path' directory. Return the 'path' if
    execution fails.
    """
    process = await asyncio.create_subprocess_exec(
        *cmds,
        stdout=asyncio.subprocess.PIPE,
        stderr=asyncio.subprocess.PIPE,
        cwd=path)
    stdout, stderr = await process.communicate()
    stdout and print(stdout.decode())
    if stderr:
        return path

Вы можете увидеть этот запрос на получение полного изменения.

Дальнейшее обновление

Приведенный выше PR решает проблему, когда удаленный тип https требует ввода имени пользователя/пароля, но все еще имеет проблему, когда ssh требует ввода пароля для нескольких репозиториев. Благодаря @gdlmx комментарию ниже.

В версии 0.9.1 я в основном следовал предложению @gdlmx: полностью отключить пользовательский ввод при работе в асинхронном режиме, и неудачные репозитории снова выполнят делегированную команду, используя subprocess поочередно.