##// END OF EJS Templates
automation: always use latest Windows AMI...
Gregory Szorc -
r45241:828d3277 stable
parent child Browse files
Show More
@@ -1,1266 +1,1278 b''
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 .linux import BOOTSTRAP_DEBIAN
23 23 from .ssh import (
24 24 exec_command as ssh_exec_command,
25 25 wait_for_ssh,
26 26 )
27 27 from .winrm import (
28 28 run_powershell,
29 29 wait_for_winrm,
30 30 )
31 31
32 32
33 33 SOURCE_ROOT = pathlib.Path(
34 34 os.path.abspath(__file__)
35 35 ).parent.parent.parent.parent
36 36
37 37 INSTALL_WINDOWS_DEPENDENCIES = (
38 38 SOURCE_ROOT / 'contrib' / 'install-windows-dependencies.ps1'
39 39 )
40 40
41 41
42 42 INSTANCE_TYPES_WITH_STORAGE = {
43 43 'c5d',
44 44 'd2',
45 45 'h1',
46 46 'i3',
47 47 'm5ad',
48 48 'm5d',
49 49 'r5d',
50 50 'r5ad',
51 51 'x1',
52 52 'z1d',
53 53 }
54 54
55 55
56 56 AMAZON_ACCOUNT_ID = '801119661308'
57 57 DEBIAN_ACCOUNT_ID = '379101102735'
58 58 DEBIAN_ACCOUNT_ID_2 = '136693071363'
59 59 UBUNTU_ACCOUNT_ID = '099720109477'
60 60
61 61
62 WINDOWS_BASE_IMAGE_NAME = 'Windows_Server-2019-English-Full-Base-2019.11.13'
62 WINDOWS_BASE_IMAGE_NAME = 'Windows_Server-2019-English-Full-Base-*'
63 63
64 64
65 65 KEY_PAIRS = {
66 66 'automation',
67 67 }
68 68
69 69
70 70 SECURITY_GROUPS = {
71 71 'linux-dev-1': {
72 72 'description': 'Mercurial Linux instances that perform build/test automation',
73 73 'ingress': [
74 74 {
75 75 'FromPort': 22,
76 76 'ToPort': 22,
77 77 'IpProtocol': 'tcp',
78 78 'IpRanges': [
79 79 {
80 80 'CidrIp': '0.0.0.0/0',
81 81 'Description': 'SSH from entire Internet',
82 82 },
83 83 ],
84 84 },
85 85 ],
86 86 },
87 87 'windows-dev-1': {
88 88 'description': 'Mercurial Windows instances that perform build automation',
89 89 'ingress': [
90 90 {
91 91 'FromPort': 22,
92 92 'ToPort': 22,
93 93 'IpProtocol': 'tcp',
94 94 'IpRanges': [
95 95 {
96 96 'CidrIp': '0.0.0.0/0',
97 97 'Description': 'SSH from entire Internet',
98 98 },
99 99 ],
100 100 },
101 101 {
102 102 'FromPort': 3389,
103 103 'ToPort': 3389,
104 104 'IpProtocol': 'tcp',
105 105 'IpRanges': [
106 106 {
107 107 'CidrIp': '0.0.0.0/0',
108 108 'Description': 'RDP from entire Internet',
109 109 },
110 110 ],
111 111 },
112 112 {
113 113 'FromPort': 5985,
114 114 'ToPort': 5986,
115 115 'IpProtocol': 'tcp',
116 116 'IpRanges': [
117 117 {
118 118 'CidrIp': '0.0.0.0/0',
119 119 'Description': 'PowerShell Remoting (Windows Remote Management)',
120 120 },
121 121 ],
122 122 },
123 123 ],
124 124 },
125 125 }
126 126
127 127
128 128 IAM_ROLES = {
129 129 'ephemeral-ec2-role-1': {
130 130 'description': 'Mercurial temporary EC2 instances',
131 131 'policy_arns': [
132 132 'arn:aws:iam::aws:policy/service-role/AmazonEC2RoleforSSM',
133 133 ],
134 134 },
135 135 }
136 136
137 137
138 138 ASSUME_ROLE_POLICY_DOCUMENT = '''
139 139 {
140 140 "Version": "2012-10-17",
141 141 "Statement": [
142 142 {
143 143 "Effect": "Allow",
144 144 "Principal": {
145 145 "Service": "ec2.amazonaws.com"
146 146 },
147 147 "Action": "sts:AssumeRole"
148 148 }
149 149 ]
150 150 }
151 151 '''.strip()
152 152
153 153
154 154 IAM_INSTANCE_PROFILES = {
155 155 'ephemeral-ec2-1': {'roles': ['ephemeral-ec2-role-1',],}
156 156 }
157 157
158 158
159 159 # User Data for Windows EC2 instance. Mainly used to set the password
160 160 # and configure WinRM.
161 161 # Inspired by the User Data script used by Packer
162 162 # (from https://www.packer.io/intro/getting-started/build-image.html).
163 163 WINDOWS_USER_DATA = r'''
164 164 <powershell>
165 165
166 166 # TODO enable this once we figure out what is failing.
167 167 #$ErrorActionPreference = "stop"
168 168
169 169 # Set administrator password
170 170 net user Administrator "%s"
171 171 wmic useraccount where "name='Administrator'" set PasswordExpires=FALSE
172 172
173 173 # First, make sure WinRM can't be connected to
174 174 netsh advfirewall firewall set rule name="Windows Remote Management (HTTP-In)" new enable=yes action=block
175 175
176 176 # Delete any existing WinRM listeners
177 177 winrm delete winrm/config/listener?Address=*+Transport=HTTP 2>$Null
178 178 winrm delete winrm/config/listener?Address=*+Transport=HTTPS 2>$Null
179 179
180 180 # Create a new WinRM listener and configure
181 181 winrm create winrm/config/listener?Address=*+Transport=HTTP
182 182 winrm set winrm/config/winrs '@{MaxMemoryPerShellMB="0"}'
183 183 winrm set winrm/config '@{MaxTimeoutms="7200000"}'
184 184 winrm set winrm/config/service '@{AllowUnencrypted="true"}'
185 185 winrm set winrm/config/service '@{MaxConcurrentOperationsPerUser="12000"}'
186 186 winrm set winrm/config/service/auth '@{Basic="true"}'
187 187 winrm set winrm/config/client/auth '@{Basic="true"}'
188 188
189 189 # Configure UAC to allow privilege elevation in remote shells
190 190 $Key = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System'
191 191 $Setting = 'LocalAccountTokenFilterPolicy'
192 192 Set-ItemProperty -Path $Key -Name $Setting -Value 1 -Force
193 193
194 194 # Avoid long usernames in the temp directory path because the '~' causes extra quoting in ssh output
195 195 [System.Environment]::SetEnvironmentVariable('TMP', 'C:\Temp', [System.EnvironmentVariableTarget]::User)
196 196 [System.Environment]::SetEnvironmentVariable('TEMP', 'C:\Temp', [System.EnvironmentVariableTarget]::User)
197 197
198 198 # Configure and restart the WinRM Service; Enable the required firewall exception
199 199 Stop-Service -Name WinRM
200 200 Set-Service -Name WinRM -StartupType Automatic
201 201 netsh advfirewall firewall set rule name="Windows Remote Management (HTTP-In)" new action=allow localip=any remoteip=any
202 202 Start-Service -Name WinRM
203 203
204 204 # Disable firewall on private network interfaces so prompts don't appear.
205 205 Set-NetFirewallProfile -Name private -Enabled false
206 206 </powershell>
207 207 '''.lstrip()
208 208
209 209
210 210 WINDOWS_BOOTSTRAP_POWERSHELL = '''
211 211 Write-Output "installing PowerShell dependencies"
212 212 Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force
213 213 Set-PSRepository -Name PSGallery -InstallationPolicy Trusted
214 214 Install-Module -Name OpenSSHUtils -RequiredVersion 0.0.2.0
215 215
216 216 Write-Output "installing OpenSSL server"
217 217 Add-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0
218 218 # Various tools will attempt to use older versions of .NET. So we enable
219 219 # the feature that provides them so it doesn't have to be auto-enabled
220 220 # later.
221 221 Write-Output "enabling .NET Framework feature"
222 222 Install-WindowsFeature -Name Net-Framework-Core
223 223 '''
224 224
225 225
226 226 class AWSConnection:
227 227 """Manages the state of a connection with AWS."""
228 228
229 229 def __init__(self, automation, region: str, ensure_ec2_state: bool = True):
230 230 self.automation = automation
231 231 self.local_state_path = automation.state_path
232 232
233 233 self.prefix = 'hg-'
234 234
235 235 self.session = boto3.session.Session(region_name=region)
236 236 self.ec2client = self.session.client('ec2')
237 237 self.ec2resource = self.session.resource('ec2')
238 238 self.iamclient = self.session.client('iam')
239 239 self.iamresource = self.session.resource('iam')
240 240 self.security_groups = {}
241 241
242 242 if ensure_ec2_state:
243 243 ensure_key_pairs(automation.state_path, self.ec2resource)
244 244 self.security_groups = ensure_security_groups(self.ec2resource)
245 245 ensure_iam_state(self.iamclient, self.iamresource)
246 246
247 247 def key_pair_path_private(self, name):
248 248 """Path to a key pair private key file."""
249 249 return self.local_state_path / 'keys' / ('keypair-%s' % name)
250 250
251 251 def key_pair_path_public(self, name):
252 252 return self.local_state_path / 'keys' / ('keypair-%s.pub' % name)
253 253
254 254
255 255 def rsa_key_fingerprint(p: pathlib.Path):
256 256 """Compute the fingerprint of an RSA private key."""
257 257
258 258 # TODO use rsa package.
259 259 res = subprocess.run(
260 260 [
261 261 'openssl',
262 262 'pkcs8',
263 263 '-in',
264 264 str(p),
265 265 '-nocrypt',
266 266 '-topk8',
267 267 '-outform',
268 268 'DER',
269 269 ],
270 270 capture_output=True,
271 271 check=True,
272 272 )
273 273
274 274 sha1 = hashlib.sha1(res.stdout).hexdigest()
275 275 return ':'.join(a + b for a, b in zip(sha1[::2], sha1[1::2]))
276 276
277 277
278 278 def ensure_key_pairs(state_path: pathlib.Path, ec2resource, prefix='hg-'):
279 279 remote_existing = {}
280 280
281 281 for kpi in ec2resource.key_pairs.all():
282 282 if kpi.name.startswith(prefix):
283 283 remote_existing[kpi.name[len(prefix) :]] = kpi.key_fingerprint
284 284
285 285 # Validate that we have these keys locally.
286 286 key_path = state_path / 'keys'
287 287 key_path.mkdir(exist_ok=True, mode=0o700)
288 288
289 289 def remove_remote(name):
290 290 print('deleting key pair %s' % name)
291 291 key = ec2resource.KeyPair(name)
292 292 key.delete()
293 293
294 294 def remove_local(name):
295 295 pub_full = key_path / ('keypair-%s.pub' % name)
296 296 priv_full = key_path / ('keypair-%s' % name)
297 297
298 298 print('removing %s' % pub_full)
299 299 pub_full.unlink()
300 300 print('removing %s' % priv_full)
301 301 priv_full.unlink()
302 302
303 303 local_existing = {}
304 304
305 305 for f in sorted(os.listdir(key_path)):
306 306 if not f.startswith('keypair-') or not f.endswith('.pub'):
307 307 continue
308 308
309 309 name = f[len('keypair-') : -len('.pub')]
310 310
311 311 pub_full = key_path / f
312 312 priv_full = key_path / ('keypair-%s' % name)
313 313
314 314 with open(pub_full, 'r', encoding='ascii') as fh:
315 315 data = fh.read()
316 316
317 317 if not data.startswith('ssh-rsa '):
318 318 print(
319 319 'unexpected format for key pair file: %s; removing' % pub_full
320 320 )
321 321 pub_full.unlink()
322 322 priv_full.unlink()
323 323 continue
324 324
325 325 local_existing[name] = rsa_key_fingerprint(priv_full)
326 326
327 327 for name in sorted(set(remote_existing) | set(local_existing)):
328 328 if name not in local_existing:
329 329 actual = '%s%s' % (prefix, name)
330 330 print('remote key %s does not exist locally' % name)
331 331 remove_remote(actual)
332 332 del remote_existing[name]
333 333
334 334 elif name not in remote_existing:
335 335 print('local key %s does not exist remotely' % name)
336 336 remove_local(name)
337 337 del local_existing[name]
338 338
339 339 elif remote_existing[name] != local_existing[name]:
340 340 print(
341 341 'key fingerprint mismatch for %s; '
342 342 'removing from local and remote' % name
343 343 )
344 344 remove_local(name)
345 345 remove_remote('%s%s' % (prefix, name))
346 346 del local_existing[name]
347 347 del remote_existing[name]
348 348
349 349 missing = KEY_PAIRS - set(remote_existing)
350 350
351 351 for name in sorted(missing):
352 352 actual = '%s%s' % (prefix, name)
353 353 print('creating key pair %s' % actual)
354 354
355 355 priv_full = key_path / ('keypair-%s' % name)
356 356 pub_full = key_path / ('keypair-%s.pub' % name)
357 357
358 358 kp = ec2resource.create_key_pair(KeyName=actual)
359 359
360 360 with priv_full.open('w', encoding='ascii') as fh:
361 361 fh.write(kp.key_material)
362 362 fh.write('\n')
363 363
364 364 priv_full.chmod(0o0600)
365 365
366 366 # SSH public key can be extracted via `ssh-keygen`.
367 367 with pub_full.open('w', encoding='ascii') as fh:
368 368 subprocess.run(
369 369 ['ssh-keygen', '-y', '-f', str(priv_full)],
370 370 stdout=fh,
371 371 check=True,
372 372 )
373 373
374 374 pub_full.chmod(0o0600)
375 375
376 376
377 377 def delete_instance_profile(profile):
378 378 for role in profile.roles:
379 379 print(
380 380 'removing role %s from instance profile %s'
381 381 % (role.name, profile.name)
382 382 )
383 383 profile.remove_role(RoleName=role.name)
384 384
385 385 print('deleting instance profile %s' % profile.name)
386 386 profile.delete()
387 387
388 388
389 389 def ensure_iam_state(iamclient, iamresource, prefix='hg-'):
390 390 """Ensure IAM state is in sync with our canonical definition."""
391 391
392 392 remote_profiles = {}
393 393
394 394 for profile in iamresource.instance_profiles.all():
395 395 if profile.name.startswith(prefix):
396 396 remote_profiles[profile.name[len(prefix) :]] = profile
397 397
398 398 for name in sorted(set(remote_profiles) - set(IAM_INSTANCE_PROFILES)):
399 399 delete_instance_profile(remote_profiles[name])
400 400 del remote_profiles[name]
401 401
402 402 remote_roles = {}
403 403
404 404 for role in iamresource.roles.all():
405 405 if role.name.startswith(prefix):
406 406 remote_roles[role.name[len(prefix) :]] = role
407 407
408 408 for name in sorted(set(remote_roles) - set(IAM_ROLES)):
409 409 role = remote_roles[name]
410 410
411 411 print('removing role %s' % role.name)
412 412 role.delete()
413 413 del remote_roles[name]
414 414
415 415 # We've purged remote state that doesn't belong. Create missing
416 416 # instance profiles and roles.
417 417 for name in sorted(set(IAM_INSTANCE_PROFILES) - set(remote_profiles)):
418 418 actual = '%s%s' % (prefix, name)
419 419 print('creating IAM instance profile %s' % actual)
420 420
421 421 profile = iamresource.create_instance_profile(
422 422 InstanceProfileName=actual
423 423 )
424 424 remote_profiles[name] = profile
425 425
426 426 waiter = iamclient.get_waiter('instance_profile_exists')
427 427 waiter.wait(InstanceProfileName=actual)
428 428 print('IAM instance profile %s is available' % actual)
429 429
430 430 for name in sorted(set(IAM_ROLES) - set(remote_roles)):
431 431 entry = IAM_ROLES[name]
432 432
433 433 actual = '%s%s' % (prefix, name)
434 434 print('creating IAM role %s' % actual)
435 435
436 436 role = iamresource.create_role(
437 437 RoleName=actual,
438 438 Description=entry['description'],
439 439 AssumeRolePolicyDocument=ASSUME_ROLE_POLICY_DOCUMENT,
440 440 )
441 441
442 442 waiter = iamclient.get_waiter('role_exists')
443 443 waiter.wait(RoleName=actual)
444 444 print('IAM role %s is available' % actual)
445 445
446 446 remote_roles[name] = role
447 447
448 448 for arn in entry['policy_arns']:
449 449 print('attaching policy %s to %s' % (arn, role.name))
450 450 role.attach_policy(PolicyArn=arn)
451 451
452 452 # Now reconcile state of profiles.
453 453 for name, meta in sorted(IAM_INSTANCE_PROFILES.items()):
454 454 profile = remote_profiles[name]
455 455 wanted = {'%s%s' % (prefix, role) for role in meta['roles']}
456 456 have = {role.name for role in profile.roles}
457 457
458 458 for role in sorted(have - wanted):
459 459 print('removing role %s from %s' % (role, profile.name))
460 460 profile.remove_role(RoleName=role)
461 461
462 462 for role in sorted(wanted - have):
463 463 print('adding role %s to %s' % (role, profile.name))
464 464 profile.add_role(RoleName=role)
465 465
466 466
467 def find_image(ec2resource, owner_id, name):
467 def find_image(ec2resource, owner_id, name, reverse_sort_field=None):
468 468 """Find an AMI by its owner ID and name."""
469 469
470 470 images = ec2resource.images.filter(
471 471 Filters=[
472 472 {'Name': 'owner-id', 'Values': [owner_id],},
473 473 {'Name': 'state', 'Values': ['available'],},
474 474 {'Name': 'image-type', 'Values': ['machine'],},
475 475 {'Name': 'name', 'Values': [name],},
476 476 ]
477 477 )
478 478
479 if reverse_sort_field:
480 images = sorted(
481 images,
482 key=lambda image: getattr(image, reverse_sort_field),
483 reverse=True,
484 )
485
479 486 for image in images:
480 487 return image
481 488
482 489 raise Exception('unable to find image for %s' % name)
483 490
484 491
485 492 def ensure_security_groups(ec2resource, prefix='hg-'):
486 493 """Ensure all necessary Mercurial security groups are present.
487 494
488 495 All security groups are prefixed with ``hg-`` by default. Any security
489 496 groups having this prefix but aren't in our list are deleted.
490 497 """
491 498 existing = {}
492 499
493 500 for group in ec2resource.security_groups.all():
494 501 if group.group_name.startswith(prefix):
495 502 existing[group.group_name[len(prefix) :]] = group
496 503
497 504 purge = set(existing) - set(SECURITY_GROUPS)
498 505
499 506 for name in sorted(purge):
500 507 group = existing[name]
501 508 print('removing legacy security group: %s' % group.group_name)
502 509 group.delete()
503 510
504 511 security_groups = {}
505 512
506 513 for name, group in sorted(SECURITY_GROUPS.items()):
507 514 if name in existing:
508 515 security_groups[name] = existing[name]
509 516 continue
510 517
511 518 actual = '%s%s' % (prefix, name)
512 519 print('adding security group %s' % actual)
513 520
514 521 group_res = ec2resource.create_security_group(
515 522 Description=group['description'], GroupName=actual,
516 523 )
517 524
518 525 group_res.authorize_ingress(IpPermissions=group['ingress'],)
519 526
520 527 security_groups[name] = group_res
521 528
522 529 return security_groups
523 530
524 531
525 532 def terminate_ec2_instances(ec2resource, prefix='hg-'):
526 533 """Terminate all EC2 instances managed by us."""
527 534 waiting = []
528 535
529 536 for instance in ec2resource.instances.all():
530 537 if instance.state['Name'] == 'terminated':
531 538 continue
532 539
533 540 for tag in instance.tags or []:
534 541 if tag['Key'] == 'Name' and tag['Value'].startswith(prefix):
535 542 print('terminating %s' % instance.id)
536 543 instance.terminate()
537 544 waiting.append(instance)
538 545
539 546 for instance in waiting:
540 547 instance.wait_until_terminated()
541 548
542 549
543 550 def remove_resources(c, prefix='hg-'):
544 551 """Purge all of our resources in this EC2 region."""
545 552 ec2resource = c.ec2resource
546 553 iamresource = c.iamresource
547 554
548 555 terminate_ec2_instances(ec2resource, prefix=prefix)
549 556
550 557 for image in ec2resource.images.filter(Owners=['self']):
551 558 if image.name.startswith(prefix):
552 559 remove_ami(ec2resource, image)
553 560
554 561 for group in ec2resource.security_groups.all():
555 562 if group.group_name.startswith(prefix):
556 563 print('removing security group %s' % group.group_name)
557 564 group.delete()
558 565
559 566 for profile in iamresource.instance_profiles.all():
560 567 if profile.name.startswith(prefix):
561 568 delete_instance_profile(profile)
562 569
563 570 for role in iamresource.roles.all():
564 571 if role.name.startswith(prefix):
565 572 for p in role.attached_policies.all():
566 573 print('detaching policy %s from %s' % (p.arn, role.name))
567 574 role.detach_policy(PolicyArn=p.arn)
568 575
569 576 print('removing role %s' % role.name)
570 577 role.delete()
571 578
572 579
573 580 def wait_for_ip_addresses(instances):
574 581 """Wait for the public IP addresses of an iterable of instances."""
575 582 for instance in instances:
576 583 while True:
577 584 if not instance.public_ip_address:
578 585 time.sleep(2)
579 586 instance.reload()
580 587 continue
581 588
582 589 print(
583 590 'public IP address for %s: %s'
584 591 % (instance.id, instance.public_ip_address)
585 592 )
586 593 break
587 594
588 595
589 596 def remove_ami(ec2resource, image):
590 597 """Remove an AMI and its underlying snapshots."""
591 598 snapshots = []
592 599
593 600 for device in image.block_device_mappings:
594 601 if 'Ebs' in device:
595 602 snapshots.append(ec2resource.Snapshot(device['Ebs']['SnapshotId']))
596 603
597 604 print('deregistering %s' % image.id)
598 605 image.deregister()
599 606
600 607 for snapshot in snapshots:
601 608 print('deleting snapshot %s' % snapshot.id)
602 609 snapshot.delete()
603 610
604 611
605 612 def wait_for_ssm(ssmclient, instances):
606 613 """Wait for SSM to come online for an iterable of instance IDs."""
607 614 while True:
608 615 res = ssmclient.describe_instance_information(
609 616 Filters=[
610 617 {'Key': 'InstanceIds', 'Values': [i.id for i in instances],},
611 618 ],
612 619 )
613 620
614 621 available = len(res['InstanceInformationList'])
615 622 wanted = len(instances)
616 623
617 624 print('%d/%d instances available in SSM' % (available, wanted))
618 625
619 626 if available == wanted:
620 627 return
621 628
622 629 time.sleep(2)
623 630
624 631
625 632 def run_ssm_command(ssmclient, instances, document_name, parameters):
626 633 """Run a PowerShell script on an EC2 instance."""
627 634
628 635 res = ssmclient.send_command(
629 636 InstanceIds=[i.id for i in instances],
630 637 DocumentName=document_name,
631 638 Parameters=parameters,
632 639 CloudWatchOutputConfig={'CloudWatchOutputEnabled': True,},
633 640 )
634 641
635 642 command_id = res['Command']['CommandId']
636 643
637 644 for instance in instances:
638 645 while True:
639 646 try:
640 647 res = ssmclient.get_command_invocation(
641 648 CommandId=command_id, InstanceId=instance.id,
642 649 )
643 650 except botocore.exceptions.ClientError as e:
644 651 if e.response['Error']['Code'] == 'InvocationDoesNotExist':
645 652 print('could not find SSM command invocation; waiting')
646 653 time.sleep(1)
647 654 continue
648 655 else:
649 656 raise
650 657
651 658 if res['Status'] == 'Success':
652 659 break
653 660 elif res['Status'] in ('Pending', 'InProgress', 'Delayed'):
654 661 time.sleep(2)
655 662 else:
656 663 raise Exception(
657 664 'command failed on %s: %s' % (instance.id, res['Status'])
658 665 )
659 666
660 667
661 668 @contextlib.contextmanager
662 669 def temporary_ec2_instances(ec2resource, config):
663 670 """Create temporary EC2 instances.
664 671
665 672 This is a proxy to ``ec2client.run_instances(**config)`` that takes care of
666 673 managing the lifecycle of the instances.
667 674
668 675 When the context manager exits, the instances are terminated.
669 676
670 677 The context manager evaluates to the list of data structures
671 678 describing each created instance. The instances may not be available
672 679 for work immediately: it is up to the caller to wait for the instance
673 680 to start responding.
674 681 """
675 682
676 683 ids = None
677 684
678 685 try:
679 686 res = ec2resource.create_instances(**config)
680 687
681 688 ids = [i.id for i in res]
682 689 print('started instances: %s' % ' '.join(ids))
683 690
684 691 yield res
685 692 finally:
686 693 if ids:
687 694 print('terminating instances: %s' % ' '.join(ids))
688 695 for instance in res:
689 696 instance.terminate()
690 697 print('terminated %d instances' % len(ids))
691 698
692 699
693 700 @contextlib.contextmanager
694 701 def create_temp_windows_ec2_instances(
695 702 c: AWSConnection, config, bootstrap: bool = False
696 703 ):
697 704 """Create temporary Windows EC2 instances.
698 705
699 706 This is a higher-level wrapper around ``create_temp_ec2_instances()`` that
700 707 configures the Windows instance for Windows Remote Management. The emitted
701 708 instances will have a ``winrm_client`` attribute containing a
702 709 ``pypsrp.client.Client`` instance bound to the instance.
703 710 """
704 711 if 'IamInstanceProfile' in config:
705 712 raise ValueError('IamInstanceProfile cannot be provided in config')
706 713 if 'UserData' in config:
707 714 raise ValueError('UserData cannot be provided in config')
708 715
709 716 password = c.automation.default_password()
710 717
711 718 config = copy.deepcopy(config)
712 719 config['IamInstanceProfile'] = {
713 720 'Name': 'hg-ephemeral-ec2-1',
714 721 }
715 722 config.setdefault('TagSpecifications', []).append(
716 723 {
717 724 'ResourceType': 'instance',
718 725 'Tags': [{'Key': 'Name', 'Value': 'hg-temp-windows'}],
719 726 }
720 727 )
721 728
722 729 if bootstrap:
723 730 config['UserData'] = WINDOWS_USER_DATA % password
724 731
725 732 with temporary_ec2_instances(c.ec2resource, config) as instances:
726 733 wait_for_ip_addresses(instances)
727 734
728 735 print('waiting for Windows Remote Management service...')
729 736
730 737 for instance in instances:
731 738 client = wait_for_winrm(
732 739 instance.public_ip_address, 'Administrator', password
733 740 )
734 741 print('established WinRM connection to %s' % instance.id)
735 742 instance.winrm_client = client
736 743
737 744 yield instances
738 745
739 746
740 747 def resolve_fingerprint(fingerprint):
741 748 fingerprint = json.dumps(fingerprint, sort_keys=True)
742 749 return hashlib.sha256(fingerprint.encode('utf-8')).hexdigest()
743 750
744 751
745 752 def find_and_reconcile_image(ec2resource, name, fingerprint):
746 753 """Attempt to find an existing EC2 AMI with a name and fingerprint.
747 754
748 755 If an image with the specified fingerprint is found, it is returned.
749 756 Otherwise None is returned.
750 757
751 758 Existing images for the specified name that don't have the specified
752 759 fingerprint or are missing required metadata or deleted.
753 760 """
754 761 # Find existing AMIs with this name and delete the ones that are invalid.
755 762 # Store a reference to a good image so it can be returned one the
756 763 # image state is reconciled.
757 764 images = ec2resource.images.filter(
758 765 Filters=[{'Name': 'name', 'Values': [name]}]
759 766 )
760 767
761 768 existing_image = None
762 769
763 770 for image in images:
764 771 if image.tags is None:
765 772 print(
766 773 'image %s for %s lacks required tags; removing'
767 774 % (image.id, image.name)
768 775 )
769 776 remove_ami(ec2resource, image)
770 777 else:
771 778 tags = {t['Key']: t['Value'] for t in image.tags}
772 779
773 780 if tags.get('HGIMAGEFINGERPRINT') == fingerprint:
774 781 existing_image = image
775 782 else:
776 783 print(
777 784 'image %s for %s has wrong fingerprint; removing'
778 785 % (image.id, image.name)
779 786 )
780 787 remove_ami(ec2resource, image)
781 788
782 789 return existing_image
783 790
784 791
785 792 def create_ami_from_instance(
786 793 ec2client, instance, name, description, fingerprint
787 794 ):
788 795 """Create an AMI from a running instance.
789 796
790 797 Returns the ``ec2resource.Image`` representing the created AMI.
791 798 """
792 799 instance.stop()
793 800
794 801 ec2client.get_waiter('instance_stopped').wait(
795 802 InstanceIds=[instance.id], WaiterConfig={'Delay': 5,}
796 803 )
797 804 print('%s is stopped' % instance.id)
798 805
799 806 image = instance.create_image(Name=name, Description=description,)
800 807
801 808 image.create_tags(
802 809 Tags=[{'Key': 'HGIMAGEFINGERPRINT', 'Value': fingerprint,},]
803 810 )
804 811
805 812 print('waiting for image %s' % image.id)
806 813
807 814 ec2client.get_waiter('image_available').wait(ImageIds=[image.id],)
808 815
809 816 print('image %s available as %s' % (image.id, image.name))
810 817
811 818 return image
812 819
813 820
814 821 def ensure_linux_dev_ami(c: AWSConnection, distro='debian10', prefix='hg-'):
815 822 """Ensures a Linux development AMI is available and up-to-date.
816 823
817 824 Returns an ``ec2.Image`` of either an existing AMI or a newly-built one.
818 825 """
819 826 ec2client = c.ec2client
820 827 ec2resource = c.ec2resource
821 828
822 829 name = '%s%s-%s' % (prefix, 'linux-dev', distro)
823 830
824 831 if distro == 'debian9':
825 832 image = find_image(
826 833 ec2resource,
827 834 DEBIAN_ACCOUNT_ID,
828 835 'debian-stretch-hvm-x86_64-gp2-2019-09-08-17994',
829 836 )
830 837 ssh_username = 'admin'
831 838 elif distro == 'debian10':
832 839 image = find_image(
833 840 ec2resource, DEBIAN_ACCOUNT_ID_2, 'debian-10-amd64-20190909-10',
834 841 )
835 842 ssh_username = 'admin'
836 843 elif distro == 'ubuntu18.04':
837 844 image = find_image(
838 845 ec2resource,
839 846 UBUNTU_ACCOUNT_ID,
840 847 'ubuntu/images/hvm-ssd/ubuntu-bionic-18.04-amd64-server-20190918',
841 848 )
842 849 ssh_username = 'ubuntu'
843 850 elif distro == 'ubuntu19.04':
844 851 image = find_image(
845 852 ec2resource,
846 853 UBUNTU_ACCOUNT_ID,
847 854 'ubuntu/images/hvm-ssd/ubuntu-disco-19.04-amd64-server-20190918',
848 855 )
849 856 ssh_username = 'ubuntu'
850 857 else:
851 858 raise ValueError('unsupported Linux distro: %s' % distro)
852 859
853 860 config = {
854 861 'BlockDeviceMappings': [
855 862 {
856 863 'DeviceName': image.block_device_mappings[0]['DeviceName'],
857 864 'Ebs': {
858 865 'DeleteOnTermination': True,
859 866 'VolumeSize': 10,
860 867 'VolumeType': 'gp2',
861 868 },
862 869 },
863 870 ],
864 871 'EbsOptimized': True,
865 872 'ImageId': image.id,
866 873 'InstanceInitiatedShutdownBehavior': 'stop',
867 874 # 8 VCPUs for compiling Python.
868 875 'InstanceType': 't3.2xlarge',
869 876 'KeyName': '%sautomation' % prefix,
870 877 'MaxCount': 1,
871 878 'MinCount': 1,
872 879 'SecurityGroupIds': [c.security_groups['linux-dev-1'].id],
873 880 }
874 881
875 882 requirements2_path = (
876 883 pathlib.Path(__file__).parent.parent / 'linux-requirements-py2.txt'
877 884 )
878 885 requirements3_path = (
879 886 pathlib.Path(__file__).parent.parent / 'linux-requirements-py3.txt'
880 887 )
881 888 with requirements2_path.open('r', encoding='utf-8') as fh:
882 889 requirements2 = fh.read()
883 890 with requirements3_path.open('r', encoding='utf-8') as fh:
884 891 requirements3 = fh.read()
885 892
886 893 # Compute a deterministic fingerprint to determine whether image needs to
887 894 # be regenerated.
888 895 fingerprint = resolve_fingerprint(
889 896 {
890 897 'instance_config': config,
891 898 'bootstrap_script': BOOTSTRAP_DEBIAN,
892 899 'requirements_py2': requirements2,
893 900 'requirements_py3': requirements3,
894 901 }
895 902 )
896 903
897 904 existing_image = find_and_reconcile_image(ec2resource, name, fingerprint)
898 905
899 906 if existing_image:
900 907 return existing_image
901 908
902 909 print('no suitable %s image found; creating one...' % name)
903 910
904 911 with temporary_ec2_instances(ec2resource, config) as instances:
905 912 wait_for_ip_addresses(instances)
906 913
907 914 instance = instances[0]
908 915
909 916 client = wait_for_ssh(
910 917 instance.public_ip_address,
911 918 22,
912 919 username=ssh_username,
913 920 key_filename=str(c.key_pair_path_private('automation')),
914 921 )
915 922
916 923 home = '/home/%s' % ssh_username
917 924
918 925 with client:
919 926 print('connecting to SSH server')
920 927 sftp = client.open_sftp()
921 928
922 929 print('uploading bootstrap files')
923 930 with sftp.open('%s/bootstrap' % home, 'wb') as fh:
924 931 fh.write(BOOTSTRAP_DEBIAN)
925 932 fh.chmod(0o0700)
926 933
927 934 with sftp.open('%s/requirements-py2.txt' % home, 'wb') as fh:
928 935 fh.write(requirements2)
929 936 fh.chmod(0o0700)
930 937
931 938 with sftp.open('%s/requirements-py3.txt' % home, 'wb') as fh:
932 939 fh.write(requirements3)
933 940 fh.chmod(0o0700)
934 941
935 942 print('executing bootstrap')
936 943 chan, stdin, stdout = ssh_exec_command(
937 944 client, '%s/bootstrap' % home
938 945 )
939 946 stdin.close()
940 947
941 948 for line in stdout:
942 949 print(line, end='')
943 950
944 951 res = chan.recv_exit_status()
945 952 if res:
946 953 raise Exception('non-0 exit from bootstrap: %d' % res)
947 954
948 955 print(
949 956 'bootstrap completed; stopping %s to create %s'
950 957 % (instance.id, name)
951 958 )
952 959
953 960 return create_ami_from_instance(
954 961 ec2client,
955 962 instance,
956 963 name,
957 964 'Mercurial Linux development environment',
958 965 fingerprint,
959 966 )
960 967
961 968
962 969 @contextlib.contextmanager
963 970 def temporary_linux_dev_instances(
964 971 c: AWSConnection,
965 972 image,
966 973 instance_type,
967 974 prefix='hg-',
968 975 ensure_extra_volume=False,
969 976 ):
970 977 """Create temporary Linux development EC2 instances.
971 978
972 979 Context manager resolves to a list of ``ec2.Instance`` that were created
973 980 and are running.
974 981
975 982 ``ensure_extra_volume`` can be set to ``True`` to require that instances
976 983 have a 2nd storage volume available other than the primary AMI volume.
977 984 For instance types with instance storage, this does nothing special.
978 985 But for instance types without instance storage, an additional EBS volume
979 986 will be added to the instance.
980 987
981 988 Instances have an ``ssh_client`` attribute containing a paramiko SSHClient
982 989 instance bound to the instance.
983 990
984 991 Instances have an ``ssh_private_key_path`` attributing containing the
985 992 str path to the SSH private key to connect to the instance.
986 993 """
987 994
988 995 block_device_mappings = [
989 996 {
990 997 'DeviceName': image.block_device_mappings[0]['DeviceName'],
991 998 'Ebs': {
992 999 'DeleteOnTermination': True,
993 1000 'VolumeSize': 12,
994 1001 'VolumeType': 'gp2',
995 1002 },
996 1003 }
997 1004 ]
998 1005
999 1006 # This is not an exhaustive list of instance types having instance storage.
1000 1007 # But
1001 1008 if ensure_extra_volume and not instance_type.startswith(
1002 1009 tuple(INSTANCE_TYPES_WITH_STORAGE)
1003 1010 ):
1004 1011 main_device = block_device_mappings[0]['DeviceName']
1005 1012
1006 1013 if main_device == 'xvda':
1007 1014 second_device = 'xvdb'
1008 1015 elif main_device == '/dev/sda1':
1009 1016 second_device = '/dev/sdb'
1010 1017 else:
1011 1018 raise ValueError(
1012 1019 'unhandled primary EBS device name: %s' % main_device
1013 1020 )
1014 1021
1015 1022 block_device_mappings.append(
1016 1023 {
1017 1024 'DeviceName': second_device,
1018 1025 'Ebs': {
1019 1026 'DeleteOnTermination': True,
1020 1027 'VolumeSize': 8,
1021 1028 'VolumeType': 'gp2',
1022 1029 },
1023 1030 }
1024 1031 )
1025 1032
1026 1033 config = {
1027 1034 'BlockDeviceMappings': block_device_mappings,
1028 1035 'EbsOptimized': True,
1029 1036 'ImageId': image.id,
1030 1037 'InstanceInitiatedShutdownBehavior': 'terminate',
1031 1038 'InstanceType': instance_type,
1032 1039 'KeyName': '%sautomation' % prefix,
1033 1040 'MaxCount': 1,
1034 1041 'MinCount': 1,
1035 1042 'SecurityGroupIds': [c.security_groups['linux-dev-1'].id],
1036 1043 }
1037 1044
1038 1045 with temporary_ec2_instances(c.ec2resource, config) as instances:
1039 1046 wait_for_ip_addresses(instances)
1040 1047
1041 1048 ssh_private_key_path = str(c.key_pair_path_private('automation'))
1042 1049
1043 1050 for instance in instances:
1044 1051 client = wait_for_ssh(
1045 1052 instance.public_ip_address,
1046 1053 22,
1047 1054 username='hg',
1048 1055 key_filename=ssh_private_key_path,
1049 1056 )
1050 1057
1051 1058 instance.ssh_client = client
1052 1059 instance.ssh_private_key_path = ssh_private_key_path
1053 1060
1054 1061 try:
1055 1062 yield instances
1056 1063 finally:
1057 1064 for instance in instances:
1058 1065 instance.ssh_client.close()
1059 1066
1060 1067
1061 1068 def ensure_windows_dev_ami(
1062 c: AWSConnection, prefix='hg-', base_image_name=WINDOWS_BASE_IMAGE_NAME
1069 c: AWSConnection, prefix='hg-', base_image_name=WINDOWS_BASE_IMAGE_NAME,
1063 1070 ):
1064 1071 """Ensure Windows Development AMI is available and up-to-date.
1065 1072
1066 1073 If necessary, a modern AMI will be built by starting a temporary EC2
1067 1074 instance and bootstrapping it.
1068 1075
1069 1076 Obsolete AMIs will be deleted so there is only a single AMI having the
1070 1077 desired name.
1071 1078
1072 1079 Returns an ``ec2.Image`` of either an existing AMI or a newly-built
1073 1080 one.
1074 1081 """
1075 1082 ec2client = c.ec2client
1076 1083 ec2resource = c.ec2resource
1077 1084 ssmclient = c.session.client('ssm')
1078 1085
1079 1086 name = '%s%s' % (prefix, 'windows-dev')
1080 1087
1081 image = find_image(ec2resource, AMAZON_ACCOUNT_ID, base_image_name)
1088 image = find_image(
1089 ec2resource,
1090 AMAZON_ACCOUNT_ID,
1091 base_image_name,
1092 reverse_sort_field="name",
1093 )
1082 1094
1083 1095 config = {
1084 1096 'BlockDeviceMappings': [
1085 1097 {
1086 1098 'DeviceName': '/dev/sda1',
1087 1099 'Ebs': {
1088 1100 'DeleteOnTermination': True,
1089 1101 'VolumeSize': 32,
1090 1102 'VolumeType': 'gp2',
1091 1103 },
1092 1104 }
1093 1105 ],
1094 1106 'ImageId': image.id,
1095 1107 'InstanceInitiatedShutdownBehavior': 'stop',
1096 1108 'InstanceType': 't3.medium',
1097 1109 'KeyName': '%sautomation' % prefix,
1098 1110 'MaxCount': 1,
1099 1111 'MinCount': 1,
1100 1112 'SecurityGroupIds': [c.security_groups['windows-dev-1'].id],
1101 1113 }
1102 1114
1103 1115 commands = [
1104 1116 # Need to start the service so sshd_config is generated.
1105 1117 'Start-Service sshd',
1106 1118 'Write-Output "modifying sshd_config"',
1107 1119 r'$content = Get-Content C:\ProgramData\ssh\sshd_config',
1108 1120 '$content = $content -replace "Match Group administrators","" -replace "AuthorizedKeysFile __PROGRAMDATA__/ssh/administrators_authorized_keys",""',
1109 1121 r'$content | Set-Content C:\ProgramData\ssh\sshd_config',
1110 1122 'Import-Module OpenSSHUtils',
1111 1123 r'Repair-SshdConfigPermission C:\ProgramData\ssh\sshd_config -Confirm:$false',
1112 1124 'Restart-Service sshd',
1113 1125 'Write-Output "installing OpenSSL client"',
1114 1126 'Add-WindowsCapability -Online -Name OpenSSH.Client~~~~0.0.1.0',
1115 1127 'Set-Service -Name sshd -StartupType "Automatic"',
1116 1128 'Write-Output "OpenSSH server running"',
1117 1129 ]
1118 1130
1119 1131 with INSTALL_WINDOWS_DEPENDENCIES.open('r', encoding='utf-8') as fh:
1120 1132 commands.extend(l.rstrip() for l in fh)
1121 1133
1122 1134 # Schedule run of EC2Launch on next boot. This ensures that UserData
1123 1135 # is executed.
1124 1136 # We disable setComputerName because it forces a reboot.
1125 1137 # We set an explicit admin password because this causes UserData to run
1126 1138 # as Administrator instead of System.
1127 1139 commands.extend(
1128 1140 [
1129 1141 r'''Set-Content -Path C:\ProgramData\Amazon\EC2-Windows\Launch\Config\LaunchConfig.json '''
1130 1142 r'''-Value '{"setComputerName": false, "setWallpaper": true, "addDnsSuffixList": true, '''
1131 1143 r'''"extendBootVolumeSize": true, "handleUserData": true, '''
1132 1144 r'''"adminPasswordType": "Specify", "adminPassword": "%s"}' '''
1133 1145 % c.automation.default_password(),
1134 1146 r'C:\ProgramData\Amazon\EC2-Windows\Launch\Scripts\InitializeInstance.ps1 '
1135 1147 r'–Schedule',
1136 1148 ]
1137 1149 )
1138 1150
1139 1151 # Disable Windows Defender when bootstrapping because it just slows
1140 1152 # things down.
1141 1153 commands.insert(0, 'Set-MpPreference -DisableRealtimeMonitoring $true')
1142 1154 commands.append('Set-MpPreference -DisableRealtimeMonitoring $false')
1143 1155
1144 1156 # Compute a deterministic fingerprint to determine whether image needs
1145 1157 # to be regenerated.
1146 1158 fingerprint = resolve_fingerprint(
1147 1159 {
1148 1160 'instance_config': config,
1149 1161 'user_data': WINDOWS_USER_DATA,
1150 1162 'initial_bootstrap': WINDOWS_BOOTSTRAP_POWERSHELL,
1151 1163 'bootstrap_commands': commands,
1152 1164 'base_image_name': base_image_name,
1153 1165 }
1154 1166 )
1155 1167
1156 1168 existing_image = find_and_reconcile_image(ec2resource, name, fingerprint)
1157 1169
1158 1170 if existing_image:
1159 1171 return existing_image
1160 1172
1161 1173 print('no suitable Windows development image found; creating one...')
1162 1174
1163 1175 with create_temp_windows_ec2_instances(
1164 1176 c, config, bootstrap=True
1165 1177 ) as instances:
1166 1178 assert len(instances) == 1
1167 1179 instance = instances[0]
1168 1180
1169 1181 wait_for_ssm(ssmclient, [instance])
1170 1182
1171 1183 # On first boot, install various Windows updates.
1172 1184 # We would ideally use PowerShell Remoting for this. However, there are
1173 1185 # trust issues that make it difficult to invoke Windows Update
1174 1186 # remotely. So we use SSM, which has a mechanism for running Windows
1175 1187 # Update.
1176 1188 print('installing Windows features...')
1177 1189 run_ssm_command(
1178 1190 ssmclient,
1179 1191 [instance],
1180 1192 'AWS-RunPowerShellScript',
1181 1193 {'commands': WINDOWS_BOOTSTRAP_POWERSHELL.split('\n'),},
1182 1194 )
1183 1195
1184 1196 # Reboot so all updates are fully applied.
1185 1197 #
1186 1198 # We don't use instance.reboot() here because it is asynchronous and
1187 1199 # we don't know when exactly the instance has rebooted. It could take
1188 1200 # a while to stop and we may start trying to interact with the instance
1189 1201 # before it has rebooted.
1190 1202 print('rebooting instance %s' % instance.id)
1191 1203 instance.stop()
1192 1204 ec2client.get_waiter('instance_stopped').wait(
1193 1205 InstanceIds=[instance.id], WaiterConfig={'Delay': 5,}
1194 1206 )
1195 1207
1196 1208 instance.start()
1197 1209 wait_for_ip_addresses([instance])
1198 1210
1199 1211 # There is a race condition here between the User Data PS script running
1200 1212 # and us connecting to WinRM. This can manifest as
1201 1213 # "AuthorizationManager check failed" failures during run_powershell().
1202 1214 # TODO figure out a workaround.
1203 1215
1204 1216 print('waiting for Windows Remote Management to come back...')
1205 1217 client = wait_for_winrm(
1206 1218 instance.public_ip_address,
1207 1219 'Administrator',
1208 1220 c.automation.default_password(),
1209 1221 )
1210 1222 print('established WinRM connection to %s' % instance.id)
1211 1223 instance.winrm_client = client
1212 1224
1213 1225 print('bootstrapping instance...')
1214 1226 run_powershell(instance.winrm_client, '\n'.join(commands))
1215 1227
1216 1228 print('bootstrap completed; stopping %s to create image' % instance.id)
1217 1229 return create_ami_from_instance(
1218 1230 ec2client,
1219 1231 instance,
1220 1232 name,
1221 1233 'Mercurial Windows development environment',
1222 1234 fingerprint,
1223 1235 )
1224 1236
1225 1237
1226 1238 @contextlib.contextmanager
1227 1239 def temporary_windows_dev_instances(
1228 1240 c: AWSConnection,
1229 1241 image,
1230 1242 instance_type,
1231 1243 prefix='hg-',
1232 1244 disable_antivirus=False,
1233 1245 ):
1234 1246 """Create a temporary Windows development EC2 instance.
1235 1247
1236 1248 Context manager resolves to the list of ``EC2.Instance`` that were created.
1237 1249 """
1238 1250 config = {
1239 1251 'BlockDeviceMappings': [
1240 1252 {
1241 1253 'DeviceName': '/dev/sda1',
1242 1254 'Ebs': {
1243 1255 'DeleteOnTermination': True,
1244 1256 'VolumeSize': 32,
1245 1257 'VolumeType': 'gp2',
1246 1258 },
1247 1259 }
1248 1260 ],
1249 1261 'ImageId': image.id,
1250 1262 'InstanceInitiatedShutdownBehavior': 'stop',
1251 1263 'InstanceType': instance_type,
1252 1264 'KeyName': '%sautomation' % prefix,
1253 1265 'MaxCount': 1,
1254 1266 'MinCount': 1,
1255 1267 'SecurityGroupIds': [c.security_groups['windows-dev-1'].id],
1256 1268 }
1257 1269
1258 1270 with create_temp_windows_ec2_instances(c, config) as instances:
1259 1271 if disable_antivirus:
1260 1272 for instance in instances:
1261 1273 run_powershell(
1262 1274 instance.winrm_client,
1263 1275 'Set-MpPreference -DisableRealtimeMonitoring $true',
1264 1276 )
1265 1277
1266 1278 yield instances
General Comments 0
You need to be logged in to leave comments. Login now