如果需要多个标准输入,python asyncio将陷入僵局

时间:2019-03-14 05:04:38

标签: python git subprocess stdin python-asyncio

我编写了一个命令行工具,以使用python asyncio对多个git repos执行git pull。如果所有存储库均具有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()

完整的代码库为here on github

我认为问题出在以下方面。在run_asyncasyncio.create_subprocess_exec中,stdin没有重定向,并且系统的stdin用于所有子进程(存储库)。当第一个存储库要求输入密码时,asyncio调度程序会看到阻止输入,并在等待命令行输入的同时切换到第二个存储库。但是,如果第二个存储库在第一个存储库的密码输入完成之前要求输入密码,则系统的标准输入将链接到第二个存储库。并且第一个回购将永远等待输入。

我不确定该如何处理。我必须为每个子流程重定向stdin吗?如果某些存储库使用无密码登录而有些则没有密码怎么办?

一些想法如下

  1. 检测何时需要在create_subprocess_exec中输入密码。如果是这样,则调用input()并将其结果传递给process.communicate(input)。但是我怎么能即时检测到呢?

  2. 检测哪个存储库需要输入密码,并将其从异步执行中排除。最好的方法是什么?

3 个答案:

答案 0 :(得分:6)

在默认配置中,当需要用户名或密码时,gitdirectly access the /dev/tty synonym用于更好地控制“控制”终端设备,例如与您互动的设备。由于默认情况下,子进程会从其父级继承控制终端,因此您启动的所有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 documentation。如果您的代码必须能够将凭据请求转发给最终用户,则可以利用此功能。我不会将其留给git命令来通过终端执行此操作,因为用户将如何知道特定的git命令将要接收哪些凭据,更不用说确保提示到达的问题了逻辑顺序。

相反,我会通过您的脚本路由所有凭据请求。您可以通过以下两种方式进行此操作:

  • 设置GIT_ASKPASS环境变量,指向git应该为每个提示运行的可执行文件。

    使用单个参数调用此可执行文件,提示显示用户。对于给定凭证所需的每条信息,分别为用户名(如果尚不知道)和密码,分别调用该信息。提示文字应使用户清楚要求什么(例如"Username for 'https://github.com': ""Password for 'https://someusername@github.com': "

  • 注册credential helper;它作为shell命令执行(因此可以有自己的预配置命令行参数),还有一个额外的参数告诉助手需要什么样的操作。如果将get作为最后一个参数传递,则要求它提供给定主机和协议的凭据,或者可以告诉您某些凭据已通过store成功,或被{拒绝了。 {1}}。在所有情况下,它都可以以多行erase格式从stdin读取信息,以了解主机git试图验证的身份。

    因此,借助凭证帮助器,您只需一步即可提示输入用户名和密码组合在一起,而且还可以获得有关该过程的更多信息;处理key=valuestore操作可让您更有效地缓存凭据。

Git fill首先以配置顺序询问每个配置的凭据帮助程序(请参阅FILES section to understand how the 4 config file locations的处理顺序)。您可以使用erase命令行开关在git命令行上添加新的一次性帮助程序配置,该配置已添加到最后。如果没有凭据助手可以填写丢失的用户名或密码,则会提示用户-c credential.helper=...the other prompting options

对于SSH连接,git会创建一个新的GIT_ASKPASS子进程。然后,SSH将处理身份验证,并可能要求用户提供凭据或ssh密钥,并要求用户提供密码短语。再次通过ssh完成此操作,SSH对此更加固执。虽然您可以将/dev/tty环境变量设置为用于提示的二进制文件,但SSH将仅使用此if there is no TTY session and DISPLAY is also set

SSH_ASKPASS必须是可执行文件(因此不能传递参数),并且不会提示提示凭据成功或失败。

我还要确保将当前环境变量复制到子进程,因为如果用户已设置SSH密钥代理以缓存ssh密钥,则您希望git开始使用SSH进程他们;通过环境变量发现关键代理。

因此,要为凭证助手创建一个连接,该连接也适用于SSH_ASKPASS,可以使用一个简单的同步脚本,该脚本从环境变量获取套接字:

SSH_ASKPASS

这应该设置可执行位。

然后可以将其作为临时文件或预先构建的文件传递给git命令,然后在#!/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()) 环境变量中添加Unix域套接字路径。它可以兼作PROMPTING_SOCKET_PATH提示,将操作设置为SSH_ASKPASS

然后,此脚本使SSH和git在每个用户单独的连接中向UNIX域套接字服务器询问用户凭据。我已经使用了很大的接收缓冲区大小,我认为您不会遇到超过该协议的交换协议,也看不出任何未满的原因。它使脚本保持简洁美观。

您可以将其用作prompt命令,但这样一来,您将无法获得有关非ssh连接凭据成功的宝贵信息。

这是UNIX域套接字服务器的演示实现,该服务器处理来自上述凭据帮助器的git和凭据请求,该请求仅生成随机的十六进制值而不询问用户:

GIT_ASKPASS

请注意,凭据帮助者还可以在输出中添加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,以告诉git不要寻找任何其他凭据帮助者,也无需进一步提示。

通过使用git quit=1命令行选项传入帮助脚本(/full/path/to/credhelper.py),您可以使用git credential <operation> command来测试凭据帮助程序是否有效。 -c credential.helper=...可以在标准输入中使用一个git credential字符串,它将像git那样联系凭证助手来解析它;请参阅文档以获取完整的交换格式规范。

首先,在单独的终端中启动上述演示脚本:

url=...

,然后尝试从中获取凭据;我还包括了$ /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的助手提供一组解析出的字段,并且省略了路径;如果您设置了git config选项host(或者已经为您设置了),那么credential.useHttpPath=true将被添加到传递的信息中。

对于SSH,可执行文件仅通过显示以下提示进行调用:

path=some/path.git

并且演示服务器已打印:

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

只需确保在启动git进程时仍设置Received credentials request: {'operation': 'prompt', 'prompt': 'Please enter a super-secret passphrase: '} Sending prompt response: '30b5978210f46bb968b2' Closing the connection ,以确保SSH被强制使用start_new_session=True

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) 命令将耐心等待凭据助手返回所请求的信息),您可以排队请求用户填写,您可以根据需要缓存凭据(以防万一所有命令都在等待同一主机的凭据)。

答案 1 :(得分:4)

一般来说,建议将密码提供给git的方法是通过{credential helpers”或GIT_ASKPASS,如answer of Martijn所指出,但是对于Git + SSH,情况是复杂(下面有更多讨论)。因此,很难在整个OS上正确设置它。 如果您只想对脚本进行快速修补,以下是可在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,以便为其分配新的会话which have no controlling TTY by default。 然后,SSH将被迫从stdin管道读取密码。 在Windows上,start_new_session似乎无效(Windows AFAIK上没有SID的概念)。

除非您计划在项目“ gita”中实现Git-credential-manager(GCM),否则我建议您根本不向Git提供任何密码(unix philosophy)。只需设置stdin=asyncio.subprocess.DEVNULL并将None传递给process.communicate()。这将迫使Git和SSH使用现有的CM或中止(您可以稍后处理该错误)。 而且,我认为“ gita”不想弄乱其他CM的配置,例如GCM for windows。因此,请勿打扰触摸GIT_ASKPASSSSH_ASKPASS变量或任何credential.*配置。为每个存储库设置适当的GCM是用户的责任(和自由)。通常,Git发行版已经包含GCM或ASKPASS实现。

讨论

这个问题有一个普遍的误解:Git不会打开TTY进行密码输入,SSH会打开!实际上,其他与ssh相关的实用程序,例如rsyncscp,具有相同的行为(几个月前调试SELinux相关问题时,我发现了这种困难的方式)。参见附录进行验证。

因为Git将SSH作为子进程调用,所以它不知道SSH是否会打开TTY。诸如core.askpassGIT_ASKPASS之类的Git可配置项将不会阻止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。该程序随附main-stream distribution和官方Git-GUI软件包。 因此,在Windows和Linux桌面环境上,如果通过start_new_session=True禁用TTY并保持其他可配置项不变,则SSH将自动弹出separate UI window进行密码提示。

附录

要验证哪个进程打开了TTY,可以在Git进程正在等待密码时运行ps -fo pid,tty,cmd

$ 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

答案 2 :(得分:1)

我最终使用了@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_asyncexec_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

您可以看到this pull request进行完整的更改。

进一步更新

上面的PR解决了https类型远程要求用户名/密码输入的问题,但是当ssh需要多个仓库输入密码时仍然存在问题。感谢@gdlmx在下面的评论。

在0.9.1版中,我基本上遵循@gdlmx的建议:在异步模式下运行时,完全禁用用户输入,失败的存储库将再次使用subprocess顺序运行委托的命令。