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