comparison contrib/automation/hgautomation/windows.py @ 42024:b05a3e28cf24

automation: perform tasks on remote machines Sometimes you don't have access to a machine in order to do something. For example, you may not have access to a Windows machine required to build Windows binaries or run tests on that platform. This commit introduces a pile of code intended to help "automate" common tasks, like building release artifacts. In its current form, the automation code provides functionality for performing tasks on Windows EC2 instances. The hgautomation.aws module provides functionality for integrating with AWS. It manages EC2 resources such as IAM roles, EC2 security groups, AMIs, and instances. The hgautomation.windows module provides a higher-level interface for performing tasks on remote Windows machines. The hgautomation.cli module provides a command-line interface to these higher-level primitives. I attempted to structure Windows remote machine interaction around Windows Remoting / PowerShell. This is kinda/sorta like SSH + shell, but for Windows. In theory, most of the functionality is cloud provider agnostic, as we should be able to use any established WinRM connection to interact with a remote. In reality, we're tightly coupled to AWS at the moment because I didn't want to prematurely add abstractions for a 2nd cloud provider. (1 was hard enough to implement.) In the aws module is code for creating an image with a fully functional Mercurial development environment. It contains VC9, VC2017, msys, and other dependencies. The image is fully capable of building all the existing Mercurial release artifacts and running tests. There are a few things that don't work. For example, running Windows tests with Python 3. But building the Windows release artifacts does work. And that was an impetus for this work. (Although we don't yet support code signing.) Getting this functionality to work was extremely time consuming. It took hours debugging permissions failures and other wonky behavior due to PowerShell Remoting. (The permissions model for PowerShell is crazy and you brush up against all kinds of issues because of the user/privileges of the user running the PowerShell and the permissions of the PowerShell session itself.) The functionality around AWS resource management could use some improving. In theory we support shared tenancy via resource name prefixing. In reality, we don't offer a way to configure this. Speaking of AWS resource management, I thought about using a tool like Terraform to manage resources. But at our scale, writing a few dozen lines of code to manage resources seemed acceptable. Maybe we should reconsider this if things grow out of control. Time will tell. Currently, emphasis is placed on Windows. But I only started there because it was likely to be the most difficult to implement. It should be relatively trivial to automate tasks on remote Linux machines. In fact, I have a ~1 year old script to run tests on a remote EC2 instance. I will likely be porting that to this new "framework" in the near future. # no-check-commit because foo_bar functions Differential Revision: https://phab.mercurial-scm.org/D6142
author Gregory Szorc <gregory.szorc@gmail.com>
date Fri, 15 Mar 2019 11:24:08 -0700
parents
children 0e9066db5e44
comparison
equal deleted inserted replaced
42023:bf87d34a675c 42024:b05a3e28cf24
1 # windows.py - Automation specific to Windows
2 #
3 # Copyright 2019 Gregory Szorc <gregory.szorc@gmail.com>
4 #
5 # This software may be used and distributed according to the terms of the
6 # GNU General Public License version 2 or any later version.
7
8 # no-check-code because Python 3 native.
9
10 import os
11 import pathlib
12 import re
13 import subprocess
14 import tempfile
15
16 from .winrm import (
17 run_powershell,
18 )
19
20
21 # PowerShell commands to activate a Visual Studio 2008 environment.
22 # This is essentially a port of vcvarsall.bat to PowerShell.
23 ACTIVATE_VC9_AMD64 = r'''
24 Write-Output "activating Visual Studio 2008 environment for AMD64"
25 $root = "$env:LOCALAPPDATA\Programs\Common\Microsoft\Visual C++ for Python\9.0"
26 $Env:VCINSTALLDIR = "${root}\VC\"
27 $Env:WindowsSdkDir = "${root}\WinSDK\"
28 $Env:PATH = "${root}\VC\Bin\amd64;${root}\WinSDK\Bin\x64;${root}\WinSDK\Bin;$Env:PATH"
29 $Env:INCLUDE = "${root}\VC\Include;${root}\WinSDK\Include;$Env:PATH"
30 $Env:LIB = "${root}\VC\Lib\amd64;${root}\WinSDK\Lib\x64;$Env:LIB"
31 $Env:LIBPATH = "${root}\VC\Lib\amd64;${root}\WinSDK\Lib\x64;$Env:LIBPATH"
32 '''.lstrip()
33
34 ACTIVATE_VC9_X86 = r'''
35 Write-Output "activating Visual Studio 2008 environment for x86"
36 $root = "$env:LOCALAPPDATA\Programs\Common\Microsoft\Visual C++ for Python\9.0"
37 $Env:VCINSTALLDIR = "${root}\VC\"
38 $Env:WindowsSdkDir = "${root}\WinSDK\"
39 $Env:PATH = "${root}\VC\Bin;${root}\WinSDK\Bin;$Env:PATH"
40 $Env:INCLUDE = "${root}\VC\Include;${root}\WinSDK\Include;$Env:INCLUDE"
41 $Env:LIB = "${root}\VC\Lib;${root}\WinSDK\Lib;$Env:LIB"
42 $Env:LIBPATH = "${root}\VC\lib;${root}\WinSDK\Lib:$Env:LIBPATH"
43 '''.lstrip()
44
45 HG_PURGE = r'''
46 $Env:PATH = "C:\hgdev\venv-bootstrap\Scripts;$Env:PATH"
47 Set-Location C:\hgdev\src
48 hg.exe --config extensions.purge= purge --all
49 if ($LASTEXITCODE -ne 0) {
50 throw "process exited non-0: $LASTEXITCODE"
51 }
52 Write-Output "purged Mercurial repo"
53 '''
54
55 HG_UPDATE_CLEAN = r'''
56 $Env:PATH = "C:\hgdev\venv-bootstrap\Scripts;$Env:PATH"
57 Set-Location C:\hgdev\src
58 hg.exe --config extensions.purge= purge --all
59 if ($LASTEXITCODE -ne 0) {{
60 throw "process exited non-0: $LASTEXITCODE"
61 }}
62 hg.exe update -C {revision}
63 if ($LASTEXITCODE -ne 0) {{
64 throw "process exited non-0: $LASTEXITCODE"
65 }}
66 hg.exe log -r .
67 Write-Output "updated Mercurial working directory to {revision}"
68 '''.lstrip()
69
70 BUILD_INNO = r'''
71 Set-Location C:\hgdev\src
72 $python = "C:\hgdev\python27-{arch}\python.exe"
73 C:\hgdev\python37-x64\python.exe contrib\packaging\inno\build.py --python $python
74 if ($LASTEXITCODE -ne 0) {{
75 throw "process exited non-0: $LASTEXITCODE"
76 }}
77 '''.lstrip()
78
79 BUILD_WHEEL = r'''
80 Set-Location C:\hgdev\src
81 C:\hgdev\python27-{arch}\Scripts\pip.exe wheel --wheel-dir dist .
82 if ($LASTEXITCODE -ne 0) {{
83 throw "process exited non-0: $LASTEXITCODE"
84 }}
85 '''
86
87 BUILD_WIX = r'''
88 Set-Location C:\hgdev\src
89 $python = "C:\hgdev\python27-{arch}\python.exe"
90 C:\hgdev\python37-x64\python.exe contrib\packaging\wix\build.py --python $python {extra_args}
91 if ($LASTEXITCODE -ne 0) {{
92 throw "process exited non-0: $LASTEXITCODE"
93 }}
94 '''
95
96 RUN_TESTS = r'''
97 C:\hgdev\MinGW\msys\1.0\bin\sh.exe --login -c "cd /c/hgdev/src/tests && /c/hgdev/{python_path}/python.exe run-tests.py {test_flags}"
98 if ($LASTEXITCODE -ne 0) {{
99 throw "process exited non-0: $LASTEXITCODE"
100 }}
101 '''
102
103
104 def get_vc_prefix(arch):
105 if arch == 'x86':
106 return ACTIVATE_VC9_X86
107 elif arch == 'x64':
108 return ACTIVATE_VC9_AMD64
109 else:
110 raise ValueError('illegal arch: %s; must be x86 or x64' % arch)
111
112
113 def fix_authorized_keys_permissions(winrm_client, path):
114 commands = [
115 '$ErrorActionPreference = "Stop"',
116 'Repair-AuthorizedKeyPermission -FilePath %s -Confirm:$false' % path,
117 'icacls %s /remove:g "NT Service\sshd"' % path,
118 ]
119
120 run_powershell(winrm_client, '\n'.join(commands))
121
122
123 def synchronize_hg(hg_repo: pathlib.Path, revision: str, ec2_instance):
124 """Synchronize local Mercurial repo to remote EC2 instance."""
125
126 winrm_client = ec2_instance.winrm_client
127
128 with tempfile.TemporaryDirectory() as temp_dir:
129 temp_dir = pathlib.Path(temp_dir)
130
131 ssh_dir = temp_dir / '.ssh'
132 ssh_dir.mkdir()
133 ssh_dir.chmod(0o0700)
134
135 # Generate SSH key to use for communication.
136 subprocess.run([
137 'ssh-keygen', '-t', 'rsa', '-b', '4096', '-N', '',
138 '-f', str(ssh_dir / 'id_rsa')],
139 check=True, capture_output=True)
140
141 # Add it to ~/.ssh/authorized_keys on remote.
142 # This assumes the file doesn't already exist.
143 authorized_keys = r'c:\Users\Administrator\.ssh\authorized_keys'
144 winrm_client.execute_cmd(r'mkdir c:\Users\Administrator\.ssh')
145 winrm_client.copy(str(ssh_dir / 'id_rsa.pub'), authorized_keys)
146 fix_authorized_keys_permissions(winrm_client, authorized_keys)
147
148 public_ip = ec2_instance.public_ip_address
149
150 ssh_config = temp_dir / '.ssh' / 'config'
151
152 with open(ssh_config, 'w', encoding='utf-8') as fh:
153 fh.write('Host %s\n' % public_ip)
154 fh.write(' User Administrator\n')
155 fh.write(' StrictHostKeyChecking no\n')
156 fh.write(' UserKnownHostsFile %s\n' % (ssh_dir / 'known_hosts'))
157 fh.write(' IdentityFile %s\n' % (ssh_dir / 'id_rsa'))
158
159 env = dict(os.environ)
160 env['HGPLAIN'] = '1'
161 env['HGENCODING'] = 'utf-8'
162
163 hg_bin = hg_repo / 'hg'
164
165 res = subprocess.run(
166 ['python2.7', str(hg_bin), 'log', '-r', revision, '-T', '{node}'],
167 cwd=str(hg_repo), env=env, check=True, capture_output=True)
168
169 full_revision = res.stdout.decode('ascii')
170
171 args = [
172 'python2.7', hg_bin,
173 '--config', 'ui.ssh=ssh -F %s' % ssh_config,
174 '--config', 'ui.remotecmd=c:/hgdev/venv-bootstrap/Scripts/hg.exe',
175 'push', '-r', full_revision, 'ssh://%s/c:/hgdev/src' % public_ip,
176 ]
177
178 subprocess.run(args, cwd=str(hg_repo), env=env, check=True)
179
180 run_powershell(winrm_client,
181 HG_UPDATE_CLEAN.format(revision=full_revision))
182
183 # TODO detect dirty local working directory and synchronize accordingly.
184
185
186 def purge_hg(winrm_client):
187 """Purge the Mercurial source repository on an EC2 instance."""
188 run_powershell(winrm_client, HG_PURGE)
189
190
191 def find_latest_dist(winrm_client, pattern):
192 """Find path to newest file in dist/ directory matching a pattern."""
193
194 res = winrm_client.execute_ps(
195 '$v = Get-ChildItem -Path C:\hgdev\src\dist -Filter "%s" '
196 '| Sort-Object LastWriteTime -Descending '
197 '| Select-Object -First 1\n'
198 '$v.name' % pattern
199 )
200 return res[0]
201
202
203 def copy_latest_dist(winrm_client, pattern, dest_path):
204 """Copy latest file matching pattern in dist/ directory.
205
206 Given a WinRM client and a file pattern, find the latest file on the remote
207 matching that pattern and copy it to the ``dest_path`` directory on the
208 local machine.
209 """
210 latest = find_latest_dist(winrm_client, pattern)
211 source = r'C:\hgdev\src\dist\%s' % latest
212 dest = dest_path / latest
213 print('copying %s to %s' % (source, dest))
214 winrm_client.fetch(source, str(dest))
215
216
217 def build_inno_installer(winrm_client, arch: str, dest_path: pathlib.Path,
218 version=None):
219 """Build the Inno Setup installer on a remote machine.
220
221 Using a WinRM client, remote commands are executed to build
222 a Mercurial Inno Setup installer.
223 """
224 print('building Inno Setup installer for %s' % arch)
225
226 extra_args = []
227 if version:
228 extra_args.extend(['--version', version])
229
230 ps = get_vc_prefix(arch) + BUILD_INNO.format(arch=arch,
231 extra_args=' '.join(extra_args))
232 run_powershell(winrm_client, ps)
233 copy_latest_dist(winrm_client, '*.exe', dest_path)
234
235
236 def build_wheel(winrm_client, arch: str, dest_path: pathlib.Path):
237 """Build Python wheels on a remote machine.
238
239 Using a WinRM client, remote commands are executed to build a Python wheel
240 for Mercurial.
241 """
242 print('Building Windows wheel for %s' % arch)
243 ps = get_vc_prefix(arch) + BUILD_WHEEL.format(arch=arch)
244 run_powershell(winrm_client, ps)
245 copy_latest_dist(winrm_client, '*.whl', dest_path)
246
247
248 def build_wix_installer(winrm_client, arch: str, dest_path: pathlib.Path,
249 version=None):
250 """Build the WiX installer on a remote machine.
251
252 Using a WinRM client, remote commands are executed to build a WiX installer.
253 """
254 print('Building WiX installer for %s' % arch)
255 extra_args = []
256 if version:
257 extra_args.extend(['--version', version])
258
259 ps = get_vc_prefix(arch) + BUILD_WIX.format(arch=arch,
260 extra_args=' '.join(extra_args))
261 run_powershell(winrm_client, ps)
262 copy_latest_dist(winrm_client, '*.msi', dest_path)
263
264
265 def run_tests(winrm_client, python_version, arch, test_flags=''):
266 """Run tests on a remote Windows machine.
267
268 ``python_version`` is a ``X.Y`` string like ``2.7`` or ``3.7``.
269 ``arch`` is ``x86`` or ``x64``.
270 ``test_flags`` is a str representing extra arguments to pass to
271 ``run-tests.py``.
272 """
273 if not re.match('\d\.\d', python_version):
274 raise ValueError('python_version must be \d.\d; got %s' %
275 python_version)
276
277 if arch not in ('x86', 'x64'):
278 raise ValueError('arch must be x86 or x64; got %s' % arch)
279
280 python_path = 'python%s-%s' % (python_version.replace('.', ''), arch)
281
282 ps = RUN_TESTS.format(
283 python_path=python_path,
284 test_flags=test_flags or '',
285 )
286
287 run_powershell(winrm_client, ps)