Python Asyncio получает тупик, если требуется несколько ввода стандартного ввода - PullRequest
11 голосов
/ 14 марта 2019

Я написал инструмент командной строки для выполнения 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. определяет, какие репозитории требуют ввода пароля, и исключает их из асинхронных выполнений.Какой лучший способ сделать это?

Ответы [ 3 ]

6 голосов
/ 04 апреля 2019

В конфигурации по умолчанию, когда требуется имя пользователя или пароль, 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 можно настроить для получения учетных данных из целого ряда местоположений, и они используются только в том случае, если удаленный репозиторий на самом деле проблемы для аутентификации.

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

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

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

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

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

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

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

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

.

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

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

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

Итак, чтобы создать соединение для помощника по учетным данным, которое также работает для 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 как временный файл или включить предварительно созданный, и вы добавите путь к сокету домена 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) с параметром командной строки 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://someuser@example.com/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 будет добавлено к передаваемой информации.

Для 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

Обязательно установите start_new_session=True при запуске процессов git, чтобы 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 будет терпеливо ждать, пока помощник по учетным данным вернет запрошенную информацию), и вы можете ставить запросы в очередь для пользователя, чтобы заполнить, и вы можете кэшировать учетные данные по мере необходимости (если все команды ожидают учетные данные для одного хоста).

4 голосов
/ 04 апреля 2019

Вообще говоря, рекомендуемый способ ввода пароля в git - через «помощников по учетным данным» или GIT_ASKPASS, на что указывает ответ Martijn , , но для 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 AFAIK нет понятия 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://username@hostname/path/to/repo ./repo
3839453 pts/0             \_ ssh username@hostname 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
1 голос
/ 07 апреля 2019

В итоге я воспользовался простым решением, предложенным @vincent, то есть отключил любой существующий механизм паролей, установив переменную среды 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 поочередно.

Добро пожаловать на сайт PullRequest, где вы можете задавать вопросы и получать ответы от других членов сообщества.
...