Mercurial > public > mercurial-scm > hg
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) |