##// END OF EJS Templates
automation: perform tasks on remote machines...
Gregory Szorc -
r42191:b05a3e28 default
parent child Browse files
Show More
@@ -0,0 +1,127 b''
1 ====================
2 Mercurial Automation
3 ====================
4
5 This directory contains code and utilities for building and testing Mercurial
6 on remote machines.
7
8 The ``automation.py`` Script
9 ============================
10
11 ``automation.py`` is an executable Python script (requires Python 3.5+)
12 that serves as a driver to common automation tasks.
13
14 When executed, the script will *bootstrap* a virtualenv in
15 ``<source-root>/build/venv-automation`` then re-execute itself using
16 that virtualenv. So there is no need for the caller to have a virtualenv
17 explicitly activated. This virtualenv will be populated with various
18 dependencies (as defined by the ``requirements.txt`` file).
19
20 To see what you can do with this script, simply run it::
21
22 $ ./automation.py
23
24 Local State
25 ===========
26
27 By default, local state required to interact with remote servers is stored
28 in the ``~/.hgautomation`` directory.
29
30 We attempt to limit persistent state to this directory. Even when
31 performing tasks that may have side-effects, we try to limit those
32 side-effects so they don't impact the local system. e.g. when we SSH
33 into a remote machine, we create a temporary directory for the SSH
34 config so the user's known hosts file isn't updated.
35
36 AWS Integration
37 ===============
38
39 Various automation tasks integrate with AWS to provide access to
40 resources such as EC2 instances for generic compute.
41
42 This obviously requires an AWS account and credentials to work.
43
44 We use the ``boto3`` library for interacting with AWS APIs. We do not employ
45 any special functionality for telling ``boto3`` where to find AWS credentials. See
46 https://boto3.amazonaws.com/v1/documentation/api/latest/guide/configuration.html
47 for how ``boto3`` works. Once you have configured your environment such
48 that ``boto3`` can find credentials, interaction with AWS should *just work*.
49
50 .. hint::
51
52 Typically you have a ``~/.aws/credentials`` file containing AWS
53 credentials. If you manage multiple credentials, you can override which
54 *profile* to use at run-time by setting the ``AWS_PROFILE`` environment
55 variable.
56
57 Resource Management
58 -------------------
59
60 Depending on the task being performed, various AWS services will be accessed.
61 This of course requires AWS credentials with permissions to access these
62 services.
63
64 The following AWS services can be accessed by automation tasks:
65
66 * EC2
67 * IAM
68 * Simple Systems Manager (SSM)
69
70 Various resources will also be created as part of performing various tasks.
71 This also requires various permissions.
72
73 The following AWS resources can be created by automation tasks:
74
75 * EC2 key pairs
76 * EC2 security groups
77 * EC2 instances
78 * IAM roles and instance profiles
79 * SSM command invocations
80
81 When possible, we prefix resource names with ``hg-`` so they can easily
82 be identified as belonging to Mercurial.
83
84 .. important::
85
86 We currently assume that AWS accounts utilized by *us* are single
87 tenancy. Attempts to have discrete users of ``automation.py`` (including
88 sharing credentials across machines) using the same AWS account can result
89 in them interfering with each other and things breaking.
90
91 Cost of Operation
92 -----------------
93
94 ``automation.py`` tries to be frugal with regards to utilization of remote
95 resources. Persistent remote resources are minimized in order to keep costs
96 in check. For example, EC2 instances are often ephemeral and only live as long
97 as the operation being performed.
98
99 Under normal operation, recurring costs are limited to:
100
101 * Storage costs for AMI / EBS snapshots. This should be just a few pennies
102 per month.
103
104 When running EC2 instances, you'll be billed accordingly. By default, we
105 use *small* instances, like ``t3.medium``. This instance type costs ~$0.07 per
106 hour.
107
108 .. note::
109
110 When running Windows EC2 instances, AWS bills at the full hourly cost, even
111 if the instance doesn't run for a full hour (per-second billing doesn't
112 apply to Windows AMIs).
113
114 Managing Remote Resources
115 -------------------------
116
117 Occassionally, there may be an error purging a temporary resource. Or you
118 may wish to forcefully purge remote state. Commands can be invoked to manually
119 purge remote resources.
120
121 To terminate all EC2 instances that we manage::
122
123 $ automation.py terminate-ec2-instances
124
125 To purge all EC2 resources that we manage::
126
127 $ automation.py purge-ec2-resources
@@ -0,0 +1,70 b''
1 #!/usr/bin/env python3
2 #
3 # automation.py - Perform tasks on remote machines
4 #
5 # Copyright 2019 Gregory Szorc <gregory.szorc@gmail.com>
6 #
7 # This software may be used and distributed according to the terms of the
8 # GNU General Public License version 2 or any later version.
9
10 import os
11 import pathlib
12 import subprocess
13 import sys
14 import venv
15
16
17 HERE = pathlib.Path(os.path.abspath(__file__)).parent
18 REQUIREMENTS_TXT = HERE / 'requirements.txt'
19 SOURCE_DIR = HERE.parent.parent
20 VENV = SOURCE_DIR / 'build' / 'venv-automation'
21
22
23 def bootstrap():
24 venv_created = not VENV.exists()
25
26 VENV.parent.mkdir(exist_ok=True)
27
28 venv.create(VENV, with_pip=True)
29
30 if os.name == 'nt':
31 venv_bin = VENV / 'Scripts'
32 pip = venv_bin / 'pip.exe'
33 python = venv_bin / 'python.exe'
34 else:
35 venv_bin = VENV / 'bin'
36 pip = venv_bin / 'pip'
37 python = venv_bin / 'python'
38
39 args = [str(pip), 'install', '-r', str(REQUIREMENTS_TXT),
40 '--disable-pip-version-check']
41
42 if not venv_created:
43 args.append('-q')
44
45 subprocess.run(args, check=True)
46
47 os.environ['HGAUTOMATION_BOOTSTRAPPED'] = '1'
48 os.environ['PATH'] = '%s%s%s' % (
49 venv_bin, os.pathsep, os.environ['PATH'])
50
51 subprocess.run([str(python), __file__] + sys.argv[1:], check=True)
52
53
54 def run():
55 import hgautomation.cli as cli
56
57 # Need to strip off main Python executable.
58 cli.main()
59
60
61 if __name__ == '__main__':
62 try:
63 if 'HGAUTOMATION_BOOTSTRAPPED' not in os.environ:
64 bootstrap()
65 else:
66 run()
67 except subprocess.CalledProcessError as e:
68 sys.exit(e.returncode)
69 except KeyboardInterrupt:
70 sys.exit(1)
@@ -0,0 +1,59 b''
1 # __init__.py - High-level automation interfaces
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 pathlib
11 import secrets
12
13 from .aws import (
14 AWSConnection,
15 )
16
17
18 class HGAutomation:
19 """High-level interface for Mercurial automation.
20
21 Holds global state, provides access to other primitives, etc.
22 """
23
24 def __init__(self, state_path: pathlib.Path):
25 self.state_path = state_path
26
27 state_path.mkdir(exist_ok=True)
28
29 def default_password(self):
30 """Obtain the default password to use for remote machines.
31
32 A new password will be generated if one is not stored.
33 """
34 p = self.state_path / 'default-password'
35
36 try:
37 with p.open('r', encoding='ascii') as fh:
38 data = fh.read().strip()
39
40 if data:
41 return data
42
43 except FileNotFoundError:
44 pass
45
46 password = secrets.token_urlsafe(24)
47
48 with p.open('w', encoding='ascii') as fh:
49 fh.write(password)
50 fh.write('\n')
51
52 p.chmod(0o0600)
53
54 return password
55
56 def aws_connection(self, region: str):
57 """Obtain an AWSConnection instance bound to a specific region."""
58
59 return AWSConnection(self, region)
This diff has been collapsed as it changes many lines, (879 lines changed) Show them Hide them
@@ -0,0 +1,879 b''
1 # aws.py - Automation code for Amazon Web Services
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 contextlib
11 import copy
12 import hashlib
13 import json
14 import os
15 import pathlib
16 import subprocess
17 import time
18
19 import boto3
20 import botocore.exceptions
21
22 from .winrm import (
23 run_powershell,
24 wait_for_winrm,
25 )
26
27
28 SOURCE_ROOT = pathlib.Path(os.path.abspath(__file__)).parent.parent.parent.parent
29
30 INSTALL_WINDOWS_DEPENDENCIES = (SOURCE_ROOT / 'contrib' /
31 'install-windows-dependencies.ps1')
32
33
34 KEY_PAIRS = {
35 'automation',
36 }
37
38
39 SECURITY_GROUPS = {
40 'windows-dev-1': {
41 'description': 'Mercurial Windows instances that perform build automation',
42 'ingress': [
43 {
44 'FromPort': 22,
45 'ToPort': 22,
46 'IpProtocol': 'tcp',
47 'IpRanges': [
48 {
49 'CidrIp': '0.0.0.0/0',
50 'Description': 'SSH from entire Internet',
51 },
52 ],
53 },
54 {
55 'FromPort': 3389,
56 'ToPort': 3389,
57 'IpProtocol': 'tcp',
58 'IpRanges': [
59 {
60 'CidrIp': '0.0.0.0/0',
61 'Description': 'RDP from entire Internet',
62 },
63 ],
64
65 },
66 {
67 'FromPort': 5985,
68 'ToPort': 5986,
69 'IpProtocol': 'tcp',
70 'IpRanges': [
71 {
72 'CidrIp': '0.0.0.0/0',
73 'Description': 'PowerShell Remoting (Windows Remote Management)',
74 },
75 ],
76 }
77 ],
78 },
79 }
80
81
82 IAM_ROLES = {
83 'ephemeral-ec2-role-1': {
84 'description': 'Mercurial temporary EC2 instances',
85 'policy_arns': [
86 'arn:aws:iam::aws:policy/service-role/AmazonEC2RoleforSSM',
87 ],
88 },
89 }
90
91
92 ASSUME_ROLE_POLICY_DOCUMENT = '''
93 {
94 "Version": "2012-10-17",
95 "Statement": [
96 {
97 "Effect": "Allow",
98 "Principal": {
99 "Service": "ec2.amazonaws.com"
100 },
101 "Action": "sts:AssumeRole"
102 }
103 ]
104 }
105 '''.strip()
106
107
108 IAM_INSTANCE_PROFILES = {
109 'ephemeral-ec2-1': {
110 'roles': [
111 'ephemeral-ec2-role-1',
112 ],
113 }
114 }
115
116
117 # User Data for Windows EC2 instance. Mainly used to set the password
118 # and configure WinRM.
119 # Inspired by the User Data script used by Packer
120 # (from https://www.packer.io/intro/getting-started/build-image.html).
121 WINDOWS_USER_DATA = '''
122 <powershell>
123
124 # TODO enable this once we figure out what is failing.
125 #$ErrorActionPreference = "stop"
126
127 # Set administrator password
128 net user Administrator "%s"
129 wmic useraccount where "name='Administrator'" set PasswordExpires=FALSE
130
131 # First, make sure WinRM can't be connected to
132 netsh advfirewall firewall set rule name="Windows Remote Management (HTTP-In)" new enable=yes action=block
133
134 # Delete any existing WinRM listeners
135 winrm delete winrm/config/listener?Address=*+Transport=HTTP 2>$Null
136 winrm delete winrm/config/listener?Address=*+Transport=HTTPS 2>$Null
137
138 # Create a new WinRM listener and configure
139 winrm create winrm/config/listener?Address=*+Transport=HTTP
140 winrm set winrm/config/winrs '@{MaxMemoryPerShellMB="0"}'
141 winrm set winrm/config '@{MaxTimeoutms="7200000"}'
142 winrm set winrm/config/service '@{AllowUnencrypted="true"}'
143 winrm set winrm/config/service '@{MaxConcurrentOperationsPerUser="12000"}'
144 winrm set winrm/config/service/auth '@{Basic="true"}'
145 winrm set winrm/config/client/auth '@{Basic="true"}'
146
147 # Configure UAC to allow privilege elevation in remote shells
148 $Key = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System'
149 $Setting = 'LocalAccountTokenFilterPolicy'
150 Set-ItemProperty -Path $Key -Name $Setting -Value 1 -Force
151
152 # Configure and restart the WinRM Service; Enable the required firewall exception
153 Stop-Service -Name WinRM
154 Set-Service -Name WinRM -StartupType Automatic
155 netsh advfirewall firewall set rule name="Windows Remote Management (HTTP-In)" new action=allow localip=any remoteip=any
156 Start-Service -Name WinRM
157
158 # Disable firewall on private network interfaces so prompts don't appear.
159 Set-NetFirewallProfile -Name private -Enabled false
160 </powershell>
161 '''.lstrip()
162
163
164 WINDOWS_BOOTSTRAP_POWERSHELL = '''
165 Write-Output "installing PowerShell dependencies"
166 Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force
167 Set-PSRepository -Name PSGallery -InstallationPolicy Trusted
168 Install-Module -Name OpenSSHUtils -RequiredVersion 0.0.2.0
169
170 Write-Output "installing OpenSSL server"
171 Add-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0
172 # Various tools will attempt to use older versions of .NET. So we enable
173 # the feature that provides them so it doesn't have to be auto-enabled
174 # later.
175 Write-Output "enabling .NET Framework feature"
176 Install-WindowsFeature -Name Net-Framework-Core
177 '''
178
179
180 class AWSConnection:
181 """Manages the state of a connection with AWS."""
182
183 def __init__(self, automation, region: str):
184 self.automation = automation
185 self.local_state_path = automation.state_path
186
187 self.prefix = 'hg-'
188
189 self.session = boto3.session.Session(region_name=region)
190 self.ec2client = self.session.client('ec2')
191 self.ec2resource = self.session.resource('ec2')
192 self.iamclient = self.session.client('iam')
193 self.iamresource = self.session.resource('iam')
194
195 ensure_key_pairs(automation.state_path, self.ec2resource)
196
197 self.security_groups = ensure_security_groups(self.ec2resource)
198 ensure_iam_state(self.iamresource)
199
200 def key_pair_path_private(self, name):
201 """Path to a key pair private key file."""
202 return self.local_state_path / 'keys' / ('keypair-%s' % name)
203
204 def key_pair_path_public(self, name):
205 return self.local_state_path / 'keys' / ('keypair-%s.pub' % name)
206
207
208 def rsa_key_fingerprint(p: pathlib.Path):
209 """Compute the fingerprint of an RSA private key."""
210
211 # TODO use rsa package.
212 res = subprocess.run(
213 ['openssl', 'pkcs8', '-in', str(p), '-nocrypt', '-topk8',
214 '-outform', 'DER'],
215 capture_output=True,
216 check=True)
217
218 sha1 = hashlib.sha1(res.stdout).hexdigest()
219 return ':'.join(a + b for a, b in zip(sha1[::2], sha1[1::2]))
220
221
222 def ensure_key_pairs(state_path: pathlib.Path, ec2resource, prefix='hg-'):
223 remote_existing = {}
224
225 for kpi in ec2resource.key_pairs.all():
226 if kpi.name.startswith(prefix):
227 remote_existing[kpi.name[len(prefix):]] = kpi.key_fingerprint
228
229 # Validate that we have these keys locally.
230 key_path = state_path / 'keys'
231 key_path.mkdir(exist_ok=True, mode=0o700)
232
233 def remove_remote(name):
234 print('deleting key pair %s' % name)
235 key = ec2resource.KeyPair(name)
236 key.delete()
237
238 def remove_local(name):
239 pub_full = key_path / ('keypair-%s.pub' % name)
240 priv_full = key_path / ('keypair-%s' % name)
241
242 print('removing %s' % pub_full)
243 pub_full.unlink()
244 print('removing %s' % priv_full)
245 priv_full.unlink()
246
247 local_existing = {}
248
249 for f in sorted(os.listdir(key_path)):
250 if not f.startswith('keypair-') or not f.endswith('.pub'):
251 continue
252
253 name = f[len('keypair-'):-len('.pub')]
254
255 pub_full = key_path / f
256 priv_full = key_path / ('keypair-%s' % name)
257
258 with open(pub_full, 'r', encoding='ascii') as fh:
259 data = fh.read()
260
261 if not data.startswith('ssh-rsa '):
262 print('unexpected format for key pair file: %s; removing' %
263 pub_full)
264 pub_full.unlink()
265 priv_full.unlink()
266 continue
267
268 local_existing[name] = rsa_key_fingerprint(priv_full)
269
270 for name in sorted(set(remote_existing) | set(local_existing)):
271 if name not in local_existing:
272 actual = '%s%s' % (prefix, name)
273 print('remote key %s does not exist locally' % name)
274 remove_remote(actual)
275 del remote_existing[name]
276
277 elif name not in remote_existing:
278 print('local key %s does not exist remotely' % name)
279 remove_local(name)
280 del local_existing[name]
281
282 elif remote_existing[name] != local_existing[name]:
283 print('key fingerprint mismatch for %s; '
284 'removing from local and remote' % name)
285 remove_local(name)
286 remove_remote('%s%s' % (prefix, name))
287 del local_existing[name]
288 del remote_existing[name]
289
290 missing = KEY_PAIRS - set(remote_existing)
291
292 for name in sorted(missing):
293 actual = '%s%s' % (prefix, name)
294 print('creating key pair %s' % actual)
295
296 priv_full = key_path / ('keypair-%s' % name)
297 pub_full = key_path / ('keypair-%s.pub' % name)
298
299 kp = ec2resource.create_key_pair(KeyName=actual)
300
301 with priv_full.open('w', encoding='ascii') as fh:
302 fh.write(kp.key_material)
303 fh.write('\n')
304
305 priv_full.chmod(0o0600)
306
307 # SSH public key can be extracted via `ssh-keygen`.
308 with pub_full.open('w', encoding='ascii') as fh:
309 subprocess.run(
310 ['ssh-keygen', '-y', '-f', str(priv_full)],
311 stdout=fh,
312 check=True)
313
314 pub_full.chmod(0o0600)
315
316
317 def delete_instance_profile(profile):
318 for role in profile.roles:
319 print('removing role %s from instance profile %s' % (role.name,
320 profile.name))
321 profile.remove_role(RoleName=role.name)
322
323 print('deleting instance profile %s' % profile.name)
324 profile.delete()
325
326
327 def ensure_iam_state(iamresource, prefix='hg-'):
328 """Ensure IAM state is in sync with our canonical definition."""
329
330 remote_profiles = {}
331
332 for profile in iamresource.instance_profiles.all():
333 if profile.name.startswith(prefix):
334 remote_profiles[profile.name[len(prefix):]] = profile
335
336 for name in sorted(set(remote_profiles) - set(IAM_INSTANCE_PROFILES)):
337 delete_instance_profile(remote_profiles[name])
338 del remote_profiles[name]
339
340 remote_roles = {}
341
342 for role in iamresource.roles.all():
343 if role.name.startswith(prefix):
344 remote_roles[role.name[len(prefix):]] = role
345
346 for name in sorted(set(remote_roles) - set(IAM_ROLES)):
347 role = remote_roles[name]
348
349 print('removing role %s' % role.name)
350 role.delete()
351 del remote_roles[name]
352
353 # We've purged remote state that doesn't belong. Create missing
354 # instance profiles and roles.
355 for name in sorted(set(IAM_INSTANCE_PROFILES) - set(remote_profiles)):
356 actual = '%s%s' % (prefix, name)
357 print('creating IAM instance profile %s' % actual)
358
359 profile = iamresource.create_instance_profile(
360 InstanceProfileName=actual)
361 remote_profiles[name] = profile
362
363 for name in sorted(set(IAM_ROLES) - set(remote_roles)):
364 entry = IAM_ROLES[name]
365
366 actual = '%s%s' % (prefix, name)
367 print('creating IAM role %s' % actual)
368
369 role = iamresource.create_role(
370 RoleName=actual,
371 Description=entry['description'],
372 AssumeRolePolicyDocument=ASSUME_ROLE_POLICY_DOCUMENT,
373 )
374
375 remote_roles[name] = role
376
377 for arn in entry['policy_arns']:
378 print('attaching policy %s to %s' % (arn, role.name))
379 role.attach_policy(PolicyArn=arn)
380
381 # Now reconcile state of profiles.
382 for name, meta in sorted(IAM_INSTANCE_PROFILES.items()):
383 profile = remote_profiles[name]
384 wanted = {'%s%s' % (prefix, role) for role in meta['roles']}
385 have = {role.name for role in profile.roles}
386
387 for role in sorted(have - wanted):
388 print('removing role %s from %s' % (role, profile.name))
389 profile.remove_role(RoleName=role)
390
391 for role in sorted(wanted - have):
392 print('adding role %s to %s' % (role, profile.name))
393 profile.add_role(RoleName=role)
394
395
396 def find_windows_server_2019_image(ec2resource):
397 """Find the Amazon published Windows Server 2019 base image."""
398
399 images = ec2resource.images.filter(
400 Filters=[
401 {
402 'Name': 'owner-alias',
403 'Values': ['amazon'],
404 },
405 {
406 'Name': 'state',
407 'Values': ['available'],
408 },
409 {
410 'Name': 'image-type',
411 'Values': ['machine'],
412 },
413 {
414 'Name': 'name',
415 'Values': ['Windows_Server-2019-English-Full-Base-2019.02.13'],
416 },
417 ])
418
419 for image in images:
420 return image
421
422 raise Exception('unable to find Windows Server 2019 image')
423
424
425 def ensure_security_groups(ec2resource, prefix='hg-'):
426 """Ensure all necessary Mercurial security groups are present.
427
428 All security groups are prefixed with ``hg-`` by default. Any security
429 groups having this prefix but aren't in our list are deleted.
430 """
431 existing = {}
432
433 for group in ec2resource.security_groups.all():
434 if group.group_name.startswith(prefix):
435 existing[group.group_name[len(prefix):]] = group
436
437 purge = set(existing) - set(SECURITY_GROUPS)
438
439 for name in sorted(purge):
440 group = existing[name]
441 print('removing legacy security group: %s' % group.group_name)
442 group.delete()
443
444 security_groups = {}
445
446 for name, group in sorted(SECURITY_GROUPS.items()):
447 if name in existing:
448 security_groups[name] = existing[name]
449 continue
450
451 actual = '%s%s' % (prefix, name)
452 print('adding security group %s' % actual)
453
454 group_res = ec2resource.create_security_group(
455 Description=group['description'],
456 GroupName=actual,
457 )
458
459 group_res.authorize_ingress(
460 IpPermissions=group['ingress'],
461 )
462
463 security_groups[name] = group_res
464
465 return security_groups
466
467
468 def terminate_ec2_instances(ec2resource, prefix='hg-'):
469 """Terminate all EC2 instances managed by us."""
470 waiting = []
471
472 for instance in ec2resource.instances.all():
473 if instance.state['Name'] == 'terminated':
474 continue
475
476 for tag in instance.tags or []:
477 if tag['Key'] == 'Name' and tag['Value'].startswith(prefix):
478 print('terminating %s' % instance.id)
479 instance.terminate()
480 waiting.append(instance)
481
482 for instance in waiting:
483 instance.wait_until_terminated()
484
485
486 def remove_resources(c, prefix='hg-'):
487 """Purge all of our resources in this EC2 region."""
488 ec2resource = c.ec2resource
489 iamresource = c.iamresource
490
491 terminate_ec2_instances(ec2resource, prefix=prefix)
492
493 for image in ec2resource.images.all():
494 if image.name.startswith(prefix):
495 remove_ami(ec2resource, image)
496
497 for group in ec2resource.security_groups.all():
498 if group.group_name.startswith(prefix):
499 print('removing security group %s' % group.group_name)
500 group.delete()
501
502 for profile in iamresource.instance_profiles.all():
503 if profile.name.startswith(prefix):
504 delete_instance_profile(profile)
505
506 for role in iamresource.roles.all():
507 if role.name.startswith(prefix):
508 print('removing role %s' % role.name)
509 role.delete()
510
511
512 def wait_for_ip_addresses(instances):
513 """Wait for the public IP addresses of an iterable of instances."""
514 for instance in instances:
515 while True:
516 if not instance.public_ip_address:
517 time.sleep(2)
518 instance.reload()
519 continue
520
521 print('public IP address for %s: %s' % (
522 instance.id, instance.public_ip_address))
523 break
524
525
526 def remove_ami(ec2resource, image):
527 """Remove an AMI and its underlying snapshots."""
528 snapshots = []
529
530 for device in image.block_device_mappings:
531 if 'Ebs' in device:
532 snapshots.append(ec2resource.Snapshot(device['Ebs']['SnapshotId']))
533
534 print('deregistering %s' % image.id)
535 image.deregister()
536
537 for snapshot in snapshots:
538 print('deleting snapshot %s' % snapshot.id)
539 snapshot.delete()
540
541
542 def wait_for_ssm(ssmclient, instances):
543 """Wait for SSM to come online for an iterable of instance IDs."""
544 while True:
545 res = ssmclient.describe_instance_information(
546 Filters=[
547 {
548 'Key': 'InstanceIds',
549 'Values': [i.id for i in instances],
550 },
551 ],
552 )
553
554 available = len(res['InstanceInformationList'])
555 wanted = len(instances)
556
557 print('%d/%d instances available in SSM' % (available, wanted))
558
559 if available == wanted:
560 return
561
562 time.sleep(2)
563
564
565 def run_ssm_command(ssmclient, instances, document_name, parameters):
566 """Run a PowerShell script on an EC2 instance."""
567
568 res = ssmclient.send_command(
569 InstanceIds=[i.id for i in instances],
570 DocumentName=document_name,
571 Parameters=parameters,
572 CloudWatchOutputConfig={
573 'CloudWatchOutputEnabled': True,
574 },
575 )
576
577 command_id = res['Command']['CommandId']
578
579 for instance in instances:
580 while True:
581 try:
582 res = ssmclient.get_command_invocation(
583 CommandId=command_id,
584 InstanceId=instance.id,
585 )
586 except botocore.exceptions.ClientError as e:
587 if e.response['Error']['Code'] == 'InvocationDoesNotExist':
588 print('could not find SSM command invocation; waiting')
589 time.sleep(1)
590 continue
591 else:
592 raise
593
594 if res['Status'] == 'Success':
595 break
596 elif res['Status'] in ('Pending', 'InProgress', 'Delayed'):
597 time.sleep(2)
598 else:
599 raise Exception('command failed on %s: %s' % (
600 instance.id, res['Status']))
601
602
603 @contextlib.contextmanager
604 def temporary_ec2_instances(ec2resource, config):
605 """Create temporary EC2 instances.
606
607 This is a proxy to ``ec2client.run_instances(**config)`` that takes care of
608 managing the lifecycle of the instances.
609
610 When the context manager exits, the instances are terminated.
611
612 The context manager evaluates to the list of data structures
613 describing each created instance. The instances may not be available
614 for work immediately: it is up to the caller to wait for the instance
615 to start responding.
616 """
617
618 ids = None
619
620 try:
621 res = ec2resource.create_instances(**config)
622
623 ids = [i.id for i in res]
624 print('started instances: %s' % ' '.join(ids))
625
626 yield res
627 finally:
628 if ids:
629 print('terminating instances: %s' % ' '.join(ids))
630 for instance in res:
631 instance.terminate()
632 print('terminated %d instances' % len(ids))
633
634
635 @contextlib.contextmanager
636 def create_temp_windows_ec2_instances(c: AWSConnection, config):
637 """Create temporary Windows EC2 instances.
638
639 This is a higher-level wrapper around ``create_temp_ec2_instances()`` that
640 configures the Windows instance for Windows Remote Management. The emitted
641 instances will have a ``winrm_client`` attribute containing a
642 ``pypsrp.client.Client`` instance bound to the instance.
643 """
644 if 'IamInstanceProfile' in config:
645 raise ValueError('IamInstanceProfile cannot be provided in config')
646 if 'UserData' in config:
647 raise ValueError('UserData cannot be provided in config')
648
649 password = c.automation.default_password()
650
651 config = copy.deepcopy(config)
652 config['IamInstanceProfile'] = {
653 'Name': 'hg-ephemeral-ec2-1',
654 }
655 config.setdefault('TagSpecifications', []).append({
656 'ResourceType': 'instance',
657 'Tags': [{'Key': 'Name', 'Value': 'hg-temp-windows'}],
658 })
659 config['UserData'] = WINDOWS_USER_DATA % password
660
661 with temporary_ec2_instances(c.ec2resource, config) as instances:
662 wait_for_ip_addresses(instances)
663
664 print('waiting for Windows Remote Management service...')
665
666 for instance in instances:
667 client = wait_for_winrm(instance.public_ip_address, 'Administrator', password)
668 print('established WinRM connection to %s' % instance.id)
669 instance.winrm_client = client
670
671 yield instances
672
673
674 def ensure_windows_dev_ami(c: AWSConnection, prefix='hg-'):
675 """Ensure Windows Development AMI is available and up-to-date.
676
677 If necessary, a modern AMI will be built by starting a temporary EC2
678 instance and bootstrapping it.
679
680 Obsolete AMIs will be deleted so there is only a single AMI having the
681 desired name.
682
683 Returns an ``ec2.Image`` of either an existing AMI or a newly-built
684 one.
685 """
686 ec2client = c.ec2client
687 ec2resource = c.ec2resource
688 ssmclient = c.session.client('ssm')
689
690 name = '%s%s' % (prefix, 'windows-dev')
691
692 config = {
693 'BlockDeviceMappings': [
694 {
695 'DeviceName': '/dev/sda1',
696 'Ebs': {
697 'DeleteOnTermination': True,
698 'VolumeSize': 32,
699 'VolumeType': 'gp2',
700 },
701 }
702 ],
703 'ImageId': find_windows_server_2019_image(ec2resource).id,
704 'InstanceInitiatedShutdownBehavior': 'stop',
705 'InstanceType': 't3.medium',
706 'KeyName': '%sautomation' % prefix,
707 'MaxCount': 1,
708 'MinCount': 1,
709 'SecurityGroupIds': [c.security_groups['windows-dev-1'].id],
710 }
711
712 commands = [
713 # Need to start the service so sshd_config is generated.
714 'Start-Service sshd',
715 'Write-Output "modifying sshd_config"',
716 r'$content = Get-Content C:\ProgramData\ssh\sshd_config',
717 '$content = $content -replace "Match Group administrators","" -replace "AuthorizedKeysFile __PROGRAMDATA__/ssh/administrators_authorized_keys",""',
718 r'$content | Set-Content C:\ProgramData\ssh\sshd_config',
719 'Import-Module OpenSSHUtils',
720 r'Repair-SshdConfigPermission C:\ProgramData\ssh\sshd_config -Confirm:$false',
721 'Restart-Service sshd',
722 'Write-Output "installing OpenSSL client"',
723 'Add-WindowsCapability -Online -Name OpenSSH.Client~~~~0.0.1.0',
724 'Set-Service -Name sshd -StartupType "Automatic"',
725 'Write-Output "OpenSSH server running"',
726 ]
727
728 with INSTALL_WINDOWS_DEPENDENCIES.open('r', encoding='utf-8') as fh:
729 commands.extend(l.rstrip() for l in fh)
730
731 # Disable Windows Defender when bootstrapping because it just slows
732 # things down.
733 commands.insert(0, 'Set-MpPreference -DisableRealtimeMonitoring $true')
734 commands.append('Set-MpPreference -DisableRealtimeMonitoring $false')
735
736 # Compute a deterministic fingerprint to determine whether image needs
737 # to be regenerated.
738 fingerprint = {
739 'instance_config': config,
740 'user_data': WINDOWS_USER_DATA,
741 'initial_bootstrap': WINDOWS_BOOTSTRAP_POWERSHELL,
742 'bootstrap_commands': commands,
743 }
744
745 fingerprint = json.dumps(fingerprint, sort_keys=True)
746 fingerprint = hashlib.sha256(fingerprint.encode('utf-8')).hexdigest()
747
748 # Find existing AMIs with this name and delete the ones that are invalid.
749 # Store a reference to a good image so it can be returned one the
750 # image state is reconciled.
751 images = ec2resource.images.filter(
752 Filters=[{'Name': 'name', 'Values': [name]}])
753
754 existing_image = None
755
756 for image in images:
757 if image.tags is None:
758 print('image %s for %s lacks required tags; removing' % (
759 image.id, image.name))
760 remove_ami(ec2resource, image)
761 else:
762 tags = {t['Key']: t['Value'] for t in image.tags}
763
764 if tags.get('HGIMAGEFINGERPRINT') == fingerprint:
765 existing_image = image
766 else:
767 print('image %s for %s has wrong fingerprint; removing' % (
768 image.id, image.name))
769 remove_ami(ec2resource, image)
770
771 if existing_image:
772 return existing_image
773
774 print('no suitable Windows development image found; creating one...')
775
776 with create_temp_windows_ec2_instances(c, config) as instances:
777 assert len(instances) == 1
778 instance = instances[0]
779
780 wait_for_ssm(ssmclient, [instance])
781
782 # On first boot, install various Windows updates.
783 # We would ideally use PowerShell Remoting for this. However, there are
784 # trust issues that make it difficult to invoke Windows Update
785 # remotely. So we use SSM, which has a mechanism for running Windows
786 # Update.
787 print('installing Windows features...')
788 run_ssm_command(
789 ssmclient,
790 [instance],
791 'AWS-RunPowerShellScript',
792 {
793 'commands': WINDOWS_BOOTSTRAP_POWERSHELL.split('\n'),
794 },
795 )
796
797 # Reboot so all updates are fully applied.
798 print('rebooting instance %s' % instance.id)
799 ec2client.reboot_instances(InstanceIds=[instance.id])
800
801 time.sleep(15)
802
803 print('waiting for Windows Remote Management to come back...')
804 client = wait_for_winrm(instance.public_ip_address, 'Administrator',
805 c.automation.default_password())
806 print('established WinRM connection to %s' % instance.id)
807 instance.winrm_client = client
808
809 print('bootstrapping instance...')
810 run_powershell(instance.winrm_client, '\n'.join(commands))
811
812 print('bootstrap completed; stopping %s to create image' % instance.id)
813 instance.stop()
814
815 ec2client.get_waiter('instance_stopped').wait(
816 InstanceIds=[instance.id],
817 WaiterConfig={
818 'Delay': 5,
819 })
820 print('%s is stopped' % instance.id)
821
822 image = instance.create_image(
823 Name=name,
824 Description='Mercurial Windows development environment',
825 )
826
827 image.create_tags(Tags=[
828 {
829 'Key': 'HGIMAGEFINGERPRINT',
830 'Value': fingerprint,
831 },
832 ])
833
834 print('waiting for image %s' % image.id)
835
836 ec2client.get_waiter('image_available').wait(
837 ImageIds=[image.id],
838 )
839
840 print('image %s available as %s' % (image.id, image.name))
841
842 return image
843
844
845 @contextlib.contextmanager
846 def temporary_windows_dev_instances(c: AWSConnection, image, instance_type,
847 prefix='hg-', disable_antivirus=False):
848 """Create a temporary Windows development EC2 instance.
849
850 Context manager resolves to the list of ``EC2.Instance`` that were created.
851 """
852 config = {
853 'BlockDeviceMappings': [
854 {
855 'DeviceName': '/dev/sda1',
856 'Ebs': {
857 'DeleteOnTermination': True,
858 'VolumeSize': 32,
859 'VolumeType': 'gp2',
860 },
861 }
862 ],
863 'ImageId': image.id,
864 'InstanceInitiatedShutdownBehavior': 'stop',
865 'InstanceType': instance_type,
866 'KeyName': '%sautomation' % prefix,
867 'MaxCount': 1,
868 'MinCount': 1,
869 'SecurityGroupIds': [c.security_groups['windows-dev-1'].id],
870 }
871
872 with create_temp_windows_ec2_instances(c, config) as instances:
873 if disable_antivirus:
874 for instance in instances:
875 run_powershell(
876 instance.winrm_client,
877 'Set-MpPreference -DisableRealtimeMonitoring $true')
878
879 yield instances
@@ -0,0 +1,273 b''
1 # cli.py - Command line interface for automation
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 argparse
11 import os
12 import pathlib
13
14 from . import (
15 aws,
16 HGAutomation,
17 windows,
18 )
19
20
21 SOURCE_ROOT = pathlib.Path(os.path.abspath(__file__)).parent.parent.parent.parent
22 DIST_PATH = SOURCE_ROOT / 'dist'
23
24
25 def bootstrap_windows_dev(hga: HGAutomation, aws_region):
26 c = hga.aws_connection(aws_region)
27 image = aws.ensure_windows_dev_ami(c)
28 print('Windows development AMI available as %s' % image.id)
29
30
31 def build_inno(hga: HGAutomation, aws_region, arch, revision, version):
32 c = hga.aws_connection(aws_region)
33 image = aws.ensure_windows_dev_ami(c)
34 DIST_PATH.mkdir(exist_ok=True)
35
36 with aws.temporary_windows_dev_instances(c, image, 't3.medium') as insts:
37 instance = insts[0]
38
39 windows.synchronize_hg(SOURCE_ROOT, revision, instance)
40
41 for a in arch:
42 windows.build_inno_installer(instance.winrm_client, a,
43 DIST_PATH,
44 version=version)
45
46
47 def build_wix(hga: HGAutomation, aws_region, arch, revision, version):
48 c = hga.aws_connection(aws_region)
49 image = aws.ensure_windows_dev_ami(c)
50 DIST_PATH.mkdir(exist_ok=True)
51
52 with aws.temporary_windows_dev_instances(c, image, 't3.medium') as insts:
53 instance = insts[0]
54
55 windows.synchronize_hg(SOURCE_ROOT, revision, instance)
56
57 for a in arch:
58 windows.build_wix_installer(instance.winrm_client, a,
59 DIST_PATH, version=version)
60
61
62 def build_windows_wheel(hga: HGAutomation, aws_region, arch, revision):
63 c = hga.aws_connection(aws_region)
64 image = aws.ensure_windows_dev_ami(c)
65 DIST_PATH.mkdir(exist_ok=True)
66
67 with aws.temporary_windows_dev_instances(c, image, 't3.medium') as insts:
68 instance = insts[0]
69
70 windows.synchronize_hg(SOURCE_ROOT, revision, instance)
71
72 for a in arch:
73 windows.build_wheel(instance.winrm_client, a, DIST_PATH)
74
75
76 def build_all_windows_packages(hga: HGAutomation, aws_region, revision):
77 c = hga.aws_connection(aws_region)
78 image = aws.ensure_windows_dev_ami(c)
79 DIST_PATH.mkdir(exist_ok=True)
80
81 with aws.temporary_windows_dev_instances(c, image, 't3.medium') as insts:
82 instance = insts[0]
83
84 winrm_client = instance.winrm_client
85
86 windows.synchronize_hg(SOURCE_ROOT, revision, instance)
87
88 for arch in ('x86', 'x64'):
89 windows.purge_hg(winrm_client)
90 windows.build_wheel(winrm_client, arch, DIST_PATH)
91 windows.purge_hg(winrm_client)
92 windows.build_inno_installer(winrm_client, arch, DIST_PATH)
93 windows.purge_hg(winrm_client)
94 windows.build_wix_installer(winrm_client, arch, DIST_PATH)
95
96
97 def terminate_ec2_instances(hga: HGAutomation, aws_region):
98 c = hga.aws_connection(aws_region)
99 aws.terminate_ec2_instances(c.ec2resource)
100
101
102 def purge_ec2_resources(hga: HGAutomation, aws_region):
103 c = hga.aws_connection(aws_region)
104 aws.remove_resources(c)
105
106
107 def run_tests_windows(hga: HGAutomation, aws_region, instance_type,
108 python_version, arch, test_flags):
109 c = hga.aws_connection(aws_region)
110 image = aws.ensure_windows_dev_ami(c)
111
112 with aws.temporary_windows_dev_instances(c, image, instance_type,
113 disable_antivirus=True) as insts:
114 instance = insts[0]
115
116 windows.synchronize_hg(SOURCE_ROOT, '.', instance)
117 windows.run_tests(instance.winrm_client, python_version, arch,
118 test_flags)
119
120
121 def get_parser():
122 parser = argparse.ArgumentParser()
123
124 parser.add_argument(
125 '--state-path',
126 default='~/.hgautomation',
127 help='Path for local state files',
128 )
129 parser.add_argument(
130 '--aws-region',
131 help='AWS region to use',
132 default='us-west-1',
133 )
134
135 subparsers = parser.add_subparsers()
136
137 sp = subparsers.add_parser(
138 'bootstrap-windows-dev',
139 help='Bootstrap the Windows development environment',
140 )
141 sp.set_defaults(func=bootstrap_windows_dev)
142
143 sp = subparsers.add_parser(
144 'build-all-windows-packages',
145 help='Build all Windows packages',
146 )
147 sp.add_argument(
148 '--revision',
149 help='Mercurial revision to build',
150 default='.',
151 )
152 sp.set_defaults(func=build_all_windows_packages)
153
154 sp = subparsers.add_parser(
155 'build-inno',
156 help='Build Inno Setup installer(s)',
157 )
158 sp.add_argument(
159 '--arch',
160 help='Architecture to build for',
161 choices={'x86', 'x64'},
162 nargs='*',
163 default=['x64'],
164 )
165 sp.add_argument(
166 '--revision',
167 help='Mercurial revision to build',
168 default='.',
169 )
170 sp.add_argument(
171 '--version',
172 help='Mercurial version string to use in installer',
173 )
174 sp.set_defaults(func=build_inno)
175
176 sp = subparsers.add_parser(
177 'build-windows-wheel',
178 help='Build Windows wheel(s)',
179 )
180 sp.add_argument(
181 '--arch',
182 help='Architecture to build for',
183 choices={'x86', 'x64'},
184 nargs='*',
185 default=['x64'],
186 )
187 sp.add_argument(
188 '--revision',
189 help='Mercurial revision to build',
190 default='.',
191 )
192 sp.set_defaults(func=build_windows_wheel)
193
194 sp = subparsers.add_parser(
195 'build-wix',
196 help='Build WiX installer(s)'
197 )
198 sp.add_argument(
199 '--arch',
200 help='Architecture to build for',
201 choices={'x86', 'x64'},
202 nargs='*',
203 default=['x64'],
204 )
205 sp.add_argument(
206 '--revision',
207 help='Mercurial revision to build',
208 default='.',
209 )
210 sp.add_argument(
211 '--version',
212 help='Mercurial version string to use in installer',
213 )
214 sp.set_defaults(func=build_wix)
215
216 sp = subparsers.add_parser(
217 'terminate-ec2-instances',
218 help='Terminate all active EC2 instances managed by us',
219 )
220 sp.set_defaults(func=terminate_ec2_instances)
221
222 sp = subparsers.add_parser(
223 'purge-ec2-resources',
224 help='Purge all EC2 resources managed by us',
225 )
226 sp.set_defaults(func=purge_ec2_resources)
227
228 sp = subparsers.add_parser(
229 'run-tests-windows',
230 help='Run tests on Windows',
231 )
232 sp.add_argument(
233 '--instance-type',
234 help='EC2 instance type to use',
235 default='t3.medium',
236 )
237 sp.add_argument(
238 '--python-version',
239 help='Python version to use',
240 choices={'2.7', '3.5', '3.6', '3.7', '3.8'},
241 default='2.7',
242 )
243 sp.add_argument(
244 '--arch',
245 help='Architecture to test',
246 choices={'x86', 'x64'},
247 default='x64',
248 )
249 sp.add_argument(
250 '--test-flags',
251 help='Extra command line flags to pass to run-tests.py',
252 )
253 sp.set_defaults(func=run_tests_windows)
254
255 return parser
256
257
258 def main():
259 parser = get_parser()
260 args = parser.parse_args()
261
262 local_state_path = pathlib.Path(os.path.expanduser(args.state_path))
263 automation = HGAutomation(local_state_path)
264
265 if not hasattr(args, 'func'):
266 parser.print_help()
267 return
268
269 kwargs = dict(vars(args))
270 del kwargs['func']
271 del kwargs['state_path']
272
273 args.func(automation, **kwargs)
@@ -0,0 +1,287 b''
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)
@@ -0,0 +1,82 b''
1 # winrm.py - Interact with Windows Remote Management (WinRM)
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 logging
11 import pprint
12 import time
13
14 from pypsrp.client import (
15 Client,
16 )
17 from pypsrp.powershell import (
18 PowerShell,
19 PSInvocationState,
20 RunspacePool,
21 )
22 import requests.exceptions
23
24
25 logger = logging.getLogger(__name__)
26
27
28 def wait_for_winrm(host, username, password, timeout=120, ssl=False):
29 """Wait for the Windows Remoting (WinRM) service to become available.
30
31 Returns a ``psrpclient.Client`` instance.
32 """
33
34 end_time = time.time() + timeout
35
36 while True:
37 try:
38 client = Client(host, username=username, password=password,
39 ssl=ssl, connection_timeout=5)
40 client.execute_cmd('echo "hello world"')
41 return client
42 except requests.exceptions.ConnectionError:
43 if time.time() >= end_time:
44 raise
45
46 time.sleep(1)
47
48
49 def format_object(o):
50 if isinstance(o, str):
51 return o
52
53 try:
54 o = str(o)
55 except TypeError:
56 o = pprint.pformat(o.extended_properties)
57
58 return o
59
60
61 def run_powershell(client, script):
62 with RunspacePool(client.wsman) as pool:
63 ps = PowerShell(pool)
64 ps.add_script(script)
65
66 ps.begin_invoke()
67
68 while ps.state == PSInvocationState.RUNNING:
69 ps.poll_invoke()
70 for o in ps.output:
71 print(format_object(o))
72
73 ps.output[:] = []
74
75 ps.end_invoke()
76
77 for o in ps.output:
78 print(format_object(o))
79
80 if ps.state == PSInvocationState.FAILED:
81 raise Exception('PowerShell execution failed: %s' %
82 ' '.join(map(format_object, ps.streams.error)))
@@ -0,0 +1,119 b''
1 #
2 # This file is autogenerated by pip-compile
3 # To update, run:
4 #
5 # pip-compile -U --generate-hashes --output-file contrib/automation/requirements.txt contrib/automation/requirements.txt.in
6 #
7 asn1crypto==0.24.0 \
8 --hash=sha256:2f1adbb7546ed199e3c90ef23ec95c5cf3585bac7d11fb7eb562a3fe89c64e87 \
9 --hash=sha256:9d5c20441baf0cb60a4ac34cc447c6c189024b6b4c6cd7877034f4965c464e49 \
10 # via cryptography
11 boto3==1.9.111 \
12 --hash=sha256:06414c75d1f62af7d04fd652b38d1e4fd3cfd6b35bad978466af88e2aaecd00d \
13 --hash=sha256:f3b77dff382374773d02411fa47ee408f4f503aeebd837fd9dc9ed8635bc5e8e
14 botocore==1.12.111 \
15 --hash=sha256:6af473c52d5e3e7ff82de5334e9fee96b2d5ec2df5d78bc00cd9937e2573a7a8 \
16 --hash=sha256:9f5123c7be704b17aeacae99b5842ab17bda1f799dd29134de8c70e0a50a45d7 \
17 # via boto3, s3transfer
18 certifi==2019.3.9 \
19 --hash=sha256:59b7658e26ca9c7339e00f8f4636cdfe59d34fa37b9b04f6f9e9926b3cece1a5 \
20 --hash=sha256:b26104d6835d1f5e49452a26eb2ff87fe7090b89dfcaee5ea2212697e1e1d7ae \
21 # via requests
22 cffi==1.12.2 \
23 --hash=sha256:00b97afa72c233495560a0793cdc86c2571721b4271c0667addc83c417f3d90f \
24 --hash=sha256:0ba1b0c90f2124459f6966a10c03794082a2f3985cd699d7d63c4a8dae113e11 \
25 --hash=sha256:0bffb69da295a4fc3349f2ec7cbe16b8ba057b0a593a92cbe8396e535244ee9d \
26 --hash=sha256:21469a2b1082088d11ccd79dd84157ba42d940064abbfa59cf5f024c19cf4891 \
27 --hash=sha256:2e4812f7fa984bf1ab253a40f1f4391b604f7fc424a3e21f7de542a7f8f7aedf \
28 --hash=sha256:2eac2cdd07b9049dd4e68449b90d3ef1adc7c759463af5beb53a84f1db62e36c \
29 --hash=sha256:2f9089979d7456c74d21303c7851f158833d48fb265876923edcb2d0194104ed \
30 --hash=sha256:3dd13feff00bddb0bd2d650cdb7338f815c1789a91a6f68fdc00e5c5ed40329b \
31 --hash=sha256:4065c32b52f4b142f417af6f33a5024edc1336aa845b9d5a8d86071f6fcaac5a \
32 --hash=sha256:51a4ba1256e9003a3acf508e3b4f4661bebd015b8180cc31849da222426ef585 \
33 --hash=sha256:59888faac06403767c0cf8cfb3f4a777b2939b1fbd9f729299b5384f097f05ea \
34 --hash=sha256:59c87886640574d8b14910840327f5cd15954e26ed0bbd4e7cef95fa5aef218f \
35 --hash=sha256:610fc7d6db6c56a244c2701575f6851461753c60f73f2de89c79bbf1cc807f33 \
36 --hash=sha256:70aeadeecb281ea901bf4230c6222af0248c41044d6f57401a614ea59d96d145 \
37 --hash=sha256:71e1296d5e66c59cd2c0f2d72dc476d42afe02aeddc833d8e05630a0551dad7a \
38 --hash=sha256:8fc7a49b440ea752cfdf1d51a586fd08d395ff7a5d555dc69e84b1939f7ddee3 \
39 --hash=sha256:9b5c2afd2d6e3771d516045a6cfa11a8da9a60e3d128746a7fe9ab36dfe7221f \
40 --hash=sha256:9c759051ebcb244d9d55ee791259ddd158188d15adee3c152502d3b69005e6bd \
41 --hash=sha256:b4d1011fec5ec12aa7cc10c05a2f2f12dfa0adfe958e56ae38dc140614035804 \
42 --hash=sha256:b4f1d6332339ecc61275bebd1f7b674098a66fea11a00c84d1c58851e618dc0d \
43 --hash=sha256:c030cda3dc8e62b814831faa4eb93dd9a46498af8cd1d5c178c2de856972fd92 \
44 --hash=sha256:c2e1f2012e56d61390c0e668c20c4fb0ae667c44d6f6a2eeea5d7148dcd3df9f \
45 --hash=sha256:c37c77d6562074452120fc6c02ad86ec928f5710fbc435a181d69334b4de1d84 \
46 --hash=sha256:c8149780c60f8fd02752d0429246088c6c04e234b895c4a42e1ea9b4de8d27fb \
47 --hash=sha256:cbeeef1dc3c4299bd746b774f019de9e4672f7cc666c777cd5b409f0b746dac7 \
48 --hash=sha256:e113878a446c6228669144ae8a56e268c91b7f1fafae927adc4879d9849e0ea7 \
49 --hash=sha256:e21162bf941b85c0cda08224dade5def9360f53b09f9f259adb85fc7dd0e7b35 \
50 --hash=sha256:fb6934ef4744becbda3143d30c6604718871495a5e36c408431bf33d9c146889 \
51 # via cryptography
52 chardet==3.0.4 \
53 --hash=sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae \
54 --hash=sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691 \
55 # via requests
56 cryptography==2.6.1 \
57 --hash=sha256:066f815f1fe46020877c5983a7e747ae140f517f1b09030ec098503575265ce1 \
58 --hash=sha256:210210d9df0afba9e000636e97810117dc55b7157c903a55716bb73e3ae07705 \
59 --hash=sha256:26c821cbeb683facb966045e2064303029d572a87ee69ca5a1bf54bf55f93ca6 \
60 --hash=sha256:2afb83308dc5c5255149ff7d3fb9964f7c9ee3d59b603ec18ccf5b0a8852e2b1 \
61 --hash=sha256:2db34e5c45988f36f7a08a7ab2b69638994a8923853dec2d4af121f689c66dc8 \
62 --hash=sha256:409c4653e0f719fa78febcb71ac417076ae5e20160aec7270c91d009837b9151 \
63 --hash=sha256:45a4f4cf4f4e6a55c8128f8b76b4c057027b27d4c67e3fe157fa02f27e37830d \
64 --hash=sha256:48eab46ef38faf1031e58dfcc9c3e71756a1108f4c9c966150b605d4a1a7f659 \
65 --hash=sha256:6b9e0ae298ab20d371fc26e2129fd683cfc0cfde4d157c6341722de645146537 \
66 --hash=sha256:6c4778afe50f413707f604828c1ad1ff81fadf6c110cb669579dea7e2e98a75e \
67 --hash=sha256:8c33fb99025d353c9520141f8bc989c2134a1f76bac6369cea060812f5b5c2bb \
68 --hash=sha256:9873a1760a274b620a135054b756f9f218fa61ca030e42df31b409f0fb738b6c \
69 --hash=sha256:9b069768c627f3f5623b1cbd3248c5e7e92aec62f4c98827059eed7053138cc9 \
70 --hash=sha256:9e4ce27a507e4886efbd3c32d120db5089b906979a4debf1d5939ec01b9dd6c5 \
71 --hash=sha256:acb424eaca214cb08735f1a744eceb97d014de6530c1ea23beb86d9c6f13c2ad \
72 --hash=sha256:c8181c7d77388fe26ab8418bb088b1a1ef5fde058c6926790c8a0a3d94075a4a \
73 --hash=sha256:d4afbb0840f489b60f5a580a41a1b9c3622e08ecb5eec8614d4fb4cd914c4460 \
74 --hash=sha256:d9ed28030797c00f4bc43c86bf819266c76a5ea61d006cd4078a93ebf7da6bfd \
75 --hash=sha256:e603aa7bb52e4e8ed4119a58a03b60323918467ef209e6ff9db3ac382e5cf2c6 \
76 # via pypsrp
77 docutils==0.14 \
78 --hash=sha256:02aec4bd92ab067f6ff27a38a38a41173bf01bed8f89157768c1573f53e474a6 \
79 --hash=sha256:51e64ef2ebfb29cae1faa133b3710143496eca21c530f3f71424d77687764274 \
80 --hash=sha256:7a4bd47eaf6596e1295ecb11361139febe29b084a87bf005bf899f9a42edc3c6 \
81 # via botocore
82 idna==2.8 \
83 --hash=sha256:c357b3f628cf53ae2c4c05627ecc484553142ca23264e593d327bcde5e9c3407 \
84 --hash=sha256:ea8b7f6188e6fa117537c3df7da9fc686d485087abf6ac197f9c46432f7e4a3c \
85 # via requests
86 jmespath==0.9.4 \
87 --hash=sha256:3720a4b1bd659dd2eecad0666459b9788813e032b83e7ba58578e48254e0a0e6 \
88 --hash=sha256:bde2aef6f44302dfb30320115b17d030798de8c4110e28d5cf6cf91a7a31074c \
89 # via boto3, botocore
90 ntlm-auth==1.2.0 \
91 --hash=sha256:7bc02a3fbdfee7275d3dc20fce8028ed8eb6d32364637f28be9e9ae9160c6d5c \
92 --hash=sha256:9b13eaf88f16a831637d75236a93d60c0049536715aafbf8190ba58a590b023e \
93 # via pypsrp
94 pycparser==2.19 \
95 --hash=sha256:a988718abfad80b6b157acce7bf130a30876d27603738ac39f140993246b25b3 \
96 # via cffi
97 pypsrp==0.3.1 \
98 --hash=sha256:309853380fe086090a03cc6662a778ee69b1cae355ae4a932859034fd76e9d0b \
99 --hash=sha256:90f946254f547dc3493cea8493c819ab87e152a755797c93aa2668678ba8ae85
100 python-dateutil==2.8.0 \
101 --hash=sha256:7e6584c74aeed623791615e26efd690f29817a27c73085b78e4bad02493df2fb \
102 --hash=sha256:c89805f6f4d64db21ed966fda138f8a5ed7a4fdbc1a8ee329ce1b74e3c74da9e \
103 # via botocore
104 requests==2.21.0 \
105 --hash=sha256:502a824f31acdacb3a35b6690b5fbf0bc41d63a24a45c4004352b0242707598e \
106 --hash=sha256:7bf2a778576d825600030a110f3c0e3e8edc51dfaafe1c146e39a2027784957b \
107 # via pypsrp
108 s3transfer==0.2.0 \
109 --hash=sha256:7b9ad3213bff7d357f888e0fab5101b56fa1a0548ee77d121c3a3dbfbef4cb2e \
110 --hash=sha256:f23d5cb7d862b104401d9021fc82e5fa0e0cf57b7660a1331425aab0c691d021 \
111 # via boto3
112 six==1.12.0 \
113 --hash=sha256:3350809f0555b11f552448330d0b52d5f24c91a322ea4a15ef22629740f3761c \
114 --hash=sha256:d16a0141ec1a18405cd4ce8b4613101da75da0e9a7aec5bdd4fa804d0e0eba73 \
115 # via cryptography, pypsrp, python-dateutil
116 urllib3==1.24.1 \
117 --hash=sha256:61bf29cada3fc2fbefad4fdf059ea4bd1b4a86d2b6d15e1c7c0b582b9752fe39 \
118 --hash=sha256:de9529817c93f27c8ccbfead6985011db27bd0ddfcdb2d86f3f663385c6a9c22 \
119 # via botocore, requests
@@ -0,0 +1,2 b''
1 boto3
2 pypsrp
@@ -12,6 +12,11 b' New errors are not allowed. Warnings are'
12 > -X hgext/fsmonitor/pywatchman \
12 > -X hgext/fsmonitor/pywatchman \
13 > -X mercurial/thirdparty \
13 > -X mercurial/thirdparty \
14 > | sed 's-\\-/-g' | "$check_code" --warnings --per-file=0 - || false
14 > | sed 's-\\-/-g' | "$check_code" --warnings --per-file=0 - || false
15 Skipping contrib/automation/hgautomation/__init__.py it has no-che?k-code (glob)
16 Skipping contrib/automation/hgautomation/aws.py it has no-che?k-code (glob)
17 Skipping contrib/automation/hgautomation/cli.py it has no-che?k-code (glob)
18 Skipping contrib/automation/hgautomation/windows.py it has no-che?k-code (glob)
19 Skipping contrib/automation/hgautomation/winrm.py it has no-che?k-code (glob)
15 Skipping contrib/packaging/hgpackaging/downloads.py it has no-che?k-code (glob)
20 Skipping contrib/packaging/hgpackaging/downloads.py it has no-che?k-code (glob)
16 Skipping contrib/packaging/hgpackaging/inno.py it has no-che?k-code (glob)
21 Skipping contrib/packaging/hgpackaging/inno.py it has no-che?k-code (glob)
17 Skipping contrib/packaging/hgpackaging/py2exe.py it has no-che?k-code (glob)
22 Skipping contrib/packaging/hgpackaging/py2exe.py it has no-che?k-code (glob)
@@ -18,6 +18,7 b' outputs, which should be fixed later.'
18 > 'tests/**.t' \
18 > 'tests/**.t' \
19 > -X hgweb.cgi \
19 > -X hgweb.cgi \
20 > -X setup.py \
20 > -X setup.py \
21 > -X contrib/automation/ \
21 > -X contrib/debugshell.py \
22 > -X contrib/debugshell.py \
22 > -X contrib/hgweb.fcgi \
23 > -X contrib/hgweb.fcgi \
23 > -X contrib/packaging/hg-docker \
24 > -X contrib/packaging/hg-docker \
@@ -5,6 +5,7 b''
5
5
6 #if no-py3
6 #if no-py3
7 $ testrepohg files 'set:(**.py)' \
7 $ testrepohg files 'set:(**.py)' \
8 > -X contrib/automation/ \
8 > -X contrib/packaging/hgpackaging/ \
9 > -X contrib/packaging/hgpackaging/ \
9 > -X contrib/packaging/inno/ \
10 > -X contrib/packaging/inno/ \
10 > -X contrib/packaging/wix/ \
11 > -X contrib/packaging/wix/ \
General Comments 0
You need to be logged in to leave comments. Login now