##// END OF EJS Templates
narrow: add progress-reporting when looking for local changes in `hg tracked`...
narrow: add progress-reporting when looking for local changes in `hg tracked` Looking for local changes (changes not on the given remote) can take a long time, so we should have progress-reporting for it. Differential Revision: https://phab.mercurial-scm.org/D10501

File last commit:

r46554:89a2afe3 default
r47789:1e761c1c default
Show More
aws.py
1325 lines | 40.1 KiB | text/x-python | PythonLexer
Gregory Szorc
automation: perform tasks on remote machines...
r42191 # aws.py - Automation code for Amazon Web Services
#
# Copyright 2019 Gregory Szorc <gregory.szorc@gmail.com>
#
# This software may be used and distributed according to the terms of the
# GNU General Public License version 2 or any later version.
# no-check-code because Python 3 native.
import contextlib
import copy
import hashlib
import json
import os
import pathlib
import subprocess
import time
import boto3
import botocore.exceptions
Augie Fackler
formatting: blacken the codebase...
r43346 from .linux import BOOTSTRAP_DEBIAN
Gregory Szorc
automation: initial support for running Linux tests...
r42471 from .ssh import (
exec_command as ssh_exec_command,
wait_for_ssh,
)
Gregory Szorc
automation: perform tasks on remote machines...
r42191 from .winrm import (
run_powershell,
wait_for_winrm,
)
Augie Fackler
formatting: blacken the codebase...
r43346 SOURCE_ROOT = pathlib.Path(
os.path.abspath(__file__)
).parent.parent.parent.parent
Gregory Szorc
automation: perform tasks on remote machines...
r42191
Augie Fackler
formatting: blacken the codebase...
r43346 INSTALL_WINDOWS_DEPENDENCIES = (
SOURCE_ROOT / 'contrib' / 'install-windows-dependencies.ps1'
)
Gregory Szorc
automation: perform tasks on remote machines...
r42191
Gregory Szorc
automation: initial support for running Linux tests...
r42471 INSTANCE_TYPES_WITH_STORAGE = {
'c5d',
'd2',
'h1',
'i3',
'm5ad',
'm5d',
'r5d',
'r5ad',
'x1',
'z1d',
}
Gregory Szorc
automation: extract strings to constants...
r42870 AMAZON_ACCOUNT_ID = '801119661308'
Gregory Szorc
automation: initial support for running Linux tests...
r42471 DEBIAN_ACCOUNT_ID = '379101102735'
Gregory Szorc
automation: support and use Debian Buster by default...
r43288 DEBIAN_ACCOUNT_ID_2 = '136693071363'
Gregory Szorc
automation: initial support for running Linux tests...
r42471 UBUNTU_ACCOUNT_ID = '099720109477'
Gregory Szorc
automation: always use latest Windows AMI...
r45241 WINDOWS_BASE_IMAGE_NAME = 'Windows_Server-2019-English-Full-Base-*'
Gregory Szorc
automation: extract strings to constants...
r42870
Gregory Szorc
automation: perform tasks on remote machines...
r42191 KEY_PAIRS = {
'automation',
}
SECURITY_GROUPS = {
Gregory Szorc
automation: initial support for running Linux tests...
r42471 'linux-dev-1': {
'description': 'Mercurial Linux instances that perform build/test automation',
'ingress': [
{
'FromPort': 22,
'ToPort': 22,
'IpProtocol': 'tcp',
'IpRanges': [
{
'CidrIp': '0.0.0.0/0',
'Description': 'SSH from entire Internet',
},
],
},
],
},
Gregory Szorc
automation: perform tasks on remote machines...
r42191 'windows-dev-1': {
'description': 'Mercurial Windows instances that perform build automation',
'ingress': [
{
'FromPort': 22,
'ToPort': 22,
'IpProtocol': 'tcp',
'IpRanges': [
{
'CidrIp': '0.0.0.0/0',
'Description': 'SSH from entire Internet',
},
],
},
{
'FromPort': 3389,
'ToPort': 3389,
'IpProtocol': 'tcp',
'IpRanges': [
{
'CidrIp': '0.0.0.0/0',
'Description': 'RDP from entire Internet',
},
],
},
{
'FromPort': 5985,
'ToPort': 5986,
'IpProtocol': 'tcp',
'IpRanges': [
{
'CidrIp': '0.0.0.0/0',
'Description': 'PowerShell Remoting (Windows Remote Management)',
},
],
Augie Fackler
formatting: blacken the codebase...
r43346 },
Gregory Szorc
automation: perform tasks on remote machines...
r42191 ],
},
}
IAM_ROLES = {
'ephemeral-ec2-role-1': {
'description': 'Mercurial temporary EC2 instances',
'policy_arns': [
'arn:aws:iam::aws:policy/service-role/AmazonEC2RoleforSSM',
],
},
}
ASSUME_ROLE_POLICY_DOCUMENT = '''
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Service": "ec2.amazonaws.com"
},
"Action": "sts:AssumeRole"
}
]
}
'''.strip()
IAM_INSTANCE_PROFILES = {
Augie Fackler
formating: upgrade to black 20.8b1...
r46554 'ephemeral-ec2-1': {
'roles': [
'ephemeral-ec2-role-1',
],
}
Gregory Szorc
automation: perform tasks on remote machines...
r42191 }
# User Data for Windows EC2 instance. Mainly used to set the password
# and configure WinRM.
# Inspired by the User Data script used by Packer
# (from https://www.packer.io/intro/getting-started/build-image.html).
Gregory Szorc
automation: use raw strings when there are backslashes...
r42231 WINDOWS_USER_DATA = r'''
Gregory Szorc
automation: perform tasks on remote machines...
r42191 <powershell>
# TODO enable this once we figure out what is failing.
#$ErrorActionPreference = "stop"
# Set administrator password
net user Administrator "%s"
wmic useraccount where "name='Administrator'" set PasswordExpires=FALSE
# First, make sure WinRM can't be connected to
netsh advfirewall firewall set rule name="Windows Remote Management (HTTP-In)" new enable=yes action=block
# Delete any existing WinRM listeners
winrm delete winrm/config/listener?Address=*+Transport=HTTP 2>$Null
winrm delete winrm/config/listener?Address=*+Transport=HTTPS 2>$Null
# Create a new WinRM listener and configure
winrm create winrm/config/listener?Address=*+Transport=HTTP
winrm set winrm/config/winrs '@{MaxMemoryPerShellMB="0"}'
winrm set winrm/config '@{MaxTimeoutms="7200000"}'
winrm set winrm/config/service '@{AllowUnencrypted="true"}'
winrm set winrm/config/service '@{MaxConcurrentOperationsPerUser="12000"}'
winrm set winrm/config/service/auth '@{Basic="true"}'
winrm set winrm/config/client/auth '@{Basic="true"}'
# Configure UAC to allow privilege elevation in remote shells
$Key = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System'
$Setting = 'LocalAccountTokenFilterPolicy'
Set-ItemProperty -Path $Key -Name $Setting -Value 1 -Force
Matt Harbison
automation: avoid '~' in the temp directory on Windows...
r43729 # Avoid long usernames in the temp directory path because the '~' causes extra quoting in ssh output
[System.Environment]::SetEnvironmentVariable('TMP', 'C:\Temp', [System.EnvironmentVariableTarget]::User)
[System.Environment]::SetEnvironmentVariable('TEMP', 'C:\Temp', [System.EnvironmentVariableTarget]::User)
Gregory Szorc
automation: perform tasks on remote machines...
r42191 # Configure and restart the WinRM Service; Enable the required firewall exception
Stop-Service -Name WinRM
Set-Service -Name WinRM -StartupType Automatic
netsh advfirewall firewall set rule name="Windows Remote Management (HTTP-In)" new action=allow localip=any remoteip=any
Start-Service -Name WinRM
# Disable firewall on private network interfaces so prompts don't appear.
Set-NetFirewallProfile -Name private -Enabled false
</powershell>
'''.lstrip()
WINDOWS_BOOTSTRAP_POWERSHELL = '''
Write-Output "installing PowerShell dependencies"
Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force
Set-PSRepository -Name PSGallery -InstallationPolicy Trusted
Install-Module -Name OpenSSHUtils -RequiredVersion 0.0.2.0
Write-Output "installing OpenSSL server"
Add-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0
# Various tools will attempt to use older versions of .NET. So we enable
# the feature that provides them so it doesn't have to be auto-enabled
# later.
Write-Output "enabling .NET Framework feature"
Install-WindowsFeature -Name Net-Framework-Core
'''
class AWSConnection:
"""Manages the state of a connection with AWS."""
Augie Fackler
formatting: blacken the codebase...
r43346 def __init__(self, automation, region: str, ensure_ec2_state: bool = True):
Gregory Szorc
automation: perform tasks on remote machines...
r42191 self.automation = automation
self.local_state_path = automation.state_path
self.prefix = 'hg-'
self.session = boto3.session.Session(region_name=region)
self.ec2client = self.session.client('ec2')
self.ec2resource = self.session.resource('ec2')
self.iamclient = self.session.client('iam')
self.iamresource = self.session.resource('iam')
Gregory Szorc
automation: don't create resources when deleting things...
r42463 self.security_groups = {}
Gregory Szorc
automation: perform tasks on remote machines...
r42191
Gregory Szorc
automation: don't create resources when deleting things...
r42463 if ensure_ec2_state:
ensure_key_pairs(automation.state_path, self.ec2resource)
self.security_groups = ensure_security_groups(self.ec2resource)
Gregory Szorc
automation: wait for instance profiles and roles...
r42464 ensure_iam_state(self.iamclient, self.iamresource)
Gregory Szorc
automation: perform tasks on remote machines...
r42191
def key_pair_path_private(self, name):
"""Path to a key pair private key file."""
return self.local_state_path / 'keys' / ('keypair-%s' % name)
def key_pair_path_public(self, name):
return self.local_state_path / 'keys' / ('keypair-%s.pub' % name)
def rsa_key_fingerprint(p: pathlib.Path):
"""Compute the fingerprint of an RSA private key."""
# TODO use rsa package.
res = subprocess.run(
Augie Fackler
formatting: blacken the codebase...
r43346 [
'openssl',
'pkcs8',
'-in',
str(p),
'-nocrypt',
'-topk8',
'-outform',
'DER',
],
Gregory Szorc
automation: perform tasks on remote machines...
r42191 capture_output=True,
Augie Fackler
formatting: blacken the codebase...
r43346 check=True,
)
Gregory Szorc
automation: perform tasks on remote machines...
r42191
sha1 = hashlib.sha1(res.stdout).hexdigest()
return ':'.join(a + b for a, b in zip(sha1[::2], sha1[1::2]))
def ensure_key_pairs(state_path: pathlib.Path, ec2resource, prefix='hg-'):
remote_existing = {}
for kpi in ec2resource.key_pairs.all():
if kpi.name.startswith(prefix):
Augie Fackler
formatting: blacken the codebase...
r43346 remote_existing[kpi.name[len(prefix) :]] = kpi.key_fingerprint
Gregory Szorc
automation: perform tasks on remote machines...
r42191
# Validate that we have these keys locally.
key_path = state_path / 'keys'
key_path.mkdir(exist_ok=True, mode=0o700)
def remove_remote(name):
print('deleting key pair %s' % name)
key = ec2resource.KeyPair(name)
key.delete()
def remove_local(name):
pub_full = key_path / ('keypair-%s.pub' % name)
priv_full = key_path / ('keypair-%s' % name)
print('removing %s' % pub_full)
pub_full.unlink()
print('removing %s' % priv_full)
priv_full.unlink()
local_existing = {}
for f in sorted(os.listdir(key_path)):
if not f.startswith('keypair-') or not f.endswith('.pub'):
continue
Augie Fackler
formatting: blacken the codebase...
r43346 name = f[len('keypair-') : -len('.pub')]
Gregory Szorc
automation: perform tasks on remote machines...
r42191
pub_full = key_path / f
priv_full = key_path / ('keypair-%s' % name)
with open(pub_full, 'r', encoding='ascii') as fh:
data = fh.read()
if not data.startswith('ssh-rsa '):
Augie Fackler
formatting: blacken the codebase...
r43346 print(
'unexpected format for key pair file: %s; removing' % pub_full
)
Gregory Szorc
automation: perform tasks on remote machines...
r42191 pub_full.unlink()
priv_full.unlink()
continue
local_existing[name] = rsa_key_fingerprint(priv_full)
for name in sorted(set(remote_existing) | set(local_existing)):
if name not in local_existing:
actual = '%s%s' % (prefix, name)
print('remote key %s does not exist locally' % name)
remove_remote(actual)
del remote_existing[name]
elif name not in remote_existing:
print('local key %s does not exist remotely' % name)
remove_local(name)
del local_existing[name]
elif remote_existing[name] != local_existing[name]:
Augie Fackler
formatting: blacken the codebase...
r43346 print(
'key fingerprint mismatch for %s; '
'removing from local and remote' % name
)
Gregory Szorc
automation: perform tasks on remote machines...
r42191 remove_local(name)
remove_remote('%s%s' % (prefix, name))
del local_existing[name]
del remote_existing[name]
missing = KEY_PAIRS - set(remote_existing)
for name in sorted(missing):
actual = '%s%s' % (prefix, name)
print('creating key pair %s' % actual)
priv_full = key_path / ('keypair-%s' % name)
pub_full = key_path / ('keypair-%s.pub' % name)
kp = ec2resource.create_key_pair(KeyName=actual)
with priv_full.open('w', encoding='ascii') as fh:
fh.write(kp.key_material)
fh.write('\n')
priv_full.chmod(0o0600)
# SSH public key can be extracted via `ssh-keygen`.
with pub_full.open('w', encoding='ascii') as fh:
subprocess.run(
['ssh-keygen', '-y', '-f', str(priv_full)],
stdout=fh,
Augie Fackler
formatting: blacken the codebase...
r43346 check=True,
)
Gregory Szorc
automation: perform tasks on remote machines...
r42191
pub_full.chmod(0o0600)
def delete_instance_profile(profile):
for role in profile.roles:
Augie Fackler
formatting: blacken the codebase...
r43346 print(
'removing role %s from instance profile %s'
% (role.name, profile.name)
)
Gregory Szorc
automation: perform tasks on remote machines...
r42191 profile.remove_role(RoleName=role.name)
print('deleting instance profile %s' % profile.name)
profile.delete()
Gregory Szorc
automation: wait for instance profiles and roles...
r42464 def ensure_iam_state(iamclient, iamresource, prefix='hg-'):
Gregory Szorc
automation: perform tasks on remote machines...
r42191 """Ensure IAM state is in sync with our canonical definition."""
remote_profiles = {}
for profile in iamresource.instance_profiles.all():
if profile.name.startswith(prefix):
Augie Fackler
formatting: blacken the codebase...
r43346 remote_profiles[profile.name[len(prefix) :]] = profile
Gregory Szorc
automation: perform tasks on remote machines...
r42191
for name in sorted(set(remote_profiles) - set(IAM_INSTANCE_PROFILES)):
delete_instance_profile(remote_profiles[name])
del remote_profiles[name]
remote_roles = {}
for role in iamresource.roles.all():
if role.name.startswith(prefix):
Augie Fackler
formatting: blacken the codebase...
r43346 remote_roles[role.name[len(prefix) :]] = role
Gregory Szorc
automation: perform tasks on remote machines...
r42191
for name in sorted(set(remote_roles) - set(IAM_ROLES)):
role = remote_roles[name]
print('removing role %s' % role.name)
role.delete()
del remote_roles[name]
# We've purged remote state that doesn't belong. Create missing
# instance profiles and roles.
for name in sorted(set(IAM_INSTANCE_PROFILES) - set(remote_profiles)):
actual = '%s%s' % (prefix, name)
print('creating IAM instance profile %s' % actual)
profile = iamresource.create_instance_profile(
Augie Fackler
formatting: blacken the codebase...
r43346 InstanceProfileName=actual
)
Gregory Szorc
automation: perform tasks on remote machines...
r42191 remote_profiles[name] = profile
Gregory Szorc
automation: wait for instance profiles and roles...
r42464 waiter = iamclient.get_waiter('instance_profile_exists')
waiter.wait(InstanceProfileName=actual)
print('IAM instance profile %s is available' % actual)
Gregory Szorc
automation: perform tasks on remote machines...
r42191 for name in sorted(set(IAM_ROLES) - set(remote_roles)):
entry = IAM_ROLES[name]
actual = '%s%s' % (prefix, name)
print('creating IAM role %s' % actual)
role = iamresource.create_role(
RoleName=actual,
Description=entry['description'],
AssumeRolePolicyDocument=ASSUME_ROLE_POLICY_DOCUMENT,
)
Gregory Szorc
automation: wait for instance profiles and roles...
r42464 waiter = iamclient.get_waiter('role_exists')
waiter.wait(RoleName=actual)
print('IAM role %s is available' % actual)
Gregory Szorc
automation: perform tasks on remote machines...
r42191 remote_roles[name] = role
for arn in entry['policy_arns']:
print('attaching policy %s to %s' % (arn, role.name))
role.attach_policy(PolicyArn=arn)
# Now reconcile state of profiles.
for name, meta in sorted(IAM_INSTANCE_PROFILES.items()):
profile = remote_profiles[name]
wanted = {'%s%s' % (prefix, role) for role in meta['roles']}
have = {role.name for role in profile.roles}
for role in sorted(have - wanted):
print('removing role %s from %s' % (role, profile.name))
profile.remove_role(RoleName=role)
for role in sorted(wanted - have):
print('adding role %s to %s' % (role, profile.name))
profile.add_role(RoleName=role)
Gregory Szorc
automation: always use latest Windows AMI...
r45241 def find_image(ec2resource, owner_id, name, reverse_sort_field=None):
Gregory Szorc
automation: move image operations to own functions...
r42470 """Find an AMI by its owner ID and name."""
Gregory Szorc
automation: perform tasks on remote machines...
r42191
images = ec2resource.images.filter(
Filters=[
Augie Fackler
formating: upgrade to black 20.8b1...
r46554 {
'Name': 'owner-id',
'Values': [owner_id],
},
{
'Name': 'state',
'Values': ['available'],
},
{
'Name': 'image-type',
'Values': ['machine'],
},
{
'Name': 'name',
'Values': [name],
},
Augie Fackler
formatting: blacken the codebase...
r43346 ]
)
Gregory Szorc
automation: perform tasks on remote machines...
r42191
Gregory Szorc
automation: always use latest Windows AMI...
r45241 if reverse_sort_field:
images = sorted(
images,
key=lambda image: getattr(image, reverse_sort_field),
reverse=True,
)
Gregory Szorc
automation: perform tasks on remote machines...
r42191 for image in images:
return image
Gregory Szorc
automation: move image operations to own functions...
r42470 raise Exception('unable to find image for %s' % name)
Gregory Szorc
automation: perform tasks on remote machines...
r42191
def ensure_security_groups(ec2resource, prefix='hg-'):
"""Ensure all necessary Mercurial security groups are present.
All security groups are prefixed with ``hg-`` by default. Any security
groups having this prefix but aren't in our list are deleted.
"""
existing = {}
for group in ec2resource.security_groups.all():
if group.group_name.startswith(prefix):
Augie Fackler
formatting: blacken the codebase...
r43346 existing[group.group_name[len(prefix) :]] = group
Gregory Szorc
automation: perform tasks on remote machines...
r42191
purge = set(existing) - set(SECURITY_GROUPS)
for name in sorted(purge):
group = existing[name]
print('removing legacy security group: %s' % group.group_name)
group.delete()
security_groups = {}
for name, group in sorted(SECURITY_GROUPS.items()):
if name in existing:
security_groups[name] = existing[name]
continue
actual = '%s%s' % (prefix, name)
print('adding security group %s' % actual)
group_res = ec2resource.create_security_group(
Augie Fackler
formating: upgrade to black 20.8b1...
r46554 Description=group['description'],
GroupName=actual,
Gregory Szorc
automation: perform tasks on remote machines...
r42191 )
Augie Fackler
formating: upgrade to black 20.8b1...
r46554 group_res.authorize_ingress(
IpPermissions=group['ingress'],
)
Gregory Szorc
automation: perform tasks on remote machines...
r42191
security_groups[name] = group_res
return security_groups
def terminate_ec2_instances(ec2resource, prefix='hg-'):
"""Terminate all EC2 instances managed by us."""
waiting = []
for instance in ec2resource.instances.all():
if instance.state['Name'] == 'terminated':
continue
for tag in instance.tags or []:
if tag['Key'] == 'Name' and tag['Value'].startswith(prefix):
print('terminating %s' % instance.id)
instance.terminate()
waiting.append(instance)
for instance in waiting:
instance.wait_until_terminated()
def remove_resources(c, prefix='hg-'):
"""Purge all of our resources in this EC2 region."""
ec2resource = c.ec2resource
iamresource = c.iamresource
terminate_ec2_instances(ec2resource, prefix=prefix)
Gregory Szorc
automation: only iterate over our AMIs...
r42461 for image in ec2resource.images.filter(Owners=['self']):
Gregory Szorc
automation: perform tasks on remote machines...
r42191 if image.name.startswith(prefix):
remove_ami(ec2resource, image)
for group in ec2resource.security_groups.all():
if group.group_name.startswith(prefix):
print('removing security group %s' % group.group_name)
group.delete()
for profile in iamresource.instance_profiles.all():
if profile.name.startswith(prefix):
delete_instance_profile(profile)
for role in iamresource.roles.all():
if role.name.startswith(prefix):
Gregory Szorc
automation: detach policies before deleting role...
r42462 for p in role.attached_policies.all():
print('detaching policy %s from %s' % (p.arn, role.name))
role.detach_policy(PolicyArn=p.arn)
Gregory Szorc
automation: perform tasks on remote machines...
r42191 print('removing role %s' % role.name)
role.delete()
def wait_for_ip_addresses(instances):
"""Wait for the public IP addresses of an iterable of instances."""
for instance in instances:
while True:
if not instance.public_ip_address:
time.sleep(2)
instance.reload()
continue
Augie Fackler
formatting: blacken the codebase...
r43346 print(
'public IP address for %s: %s'
% (instance.id, instance.public_ip_address)
)
Gregory Szorc
automation: perform tasks on remote machines...
r42191 break
def remove_ami(ec2resource, image):
"""Remove an AMI and its underlying snapshots."""
snapshots = []
for device in image.block_device_mappings:
if 'Ebs' in device:
snapshots.append(ec2resource.Snapshot(device['Ebs']['SnapshotId']))
print('deregistering %s' % image.id)
image.deregister()
for snapshot in snapshots:
print('deleting snapshot %s' % snapshot.id)
snapshot.delete()
def wait_for_ssm(ssmclient, instances):
"""Wait for SSM to come online for an iterable of instance IDs."""
while True:
res = ssmclient.describe_instance_information(
Filters=[
Augie Fackler
formating: upgrade to black 20.8b1...
r46554 {
'Key': 'InstanceIds',
'Values': [i.id for i in instances],
},
Gregory Szorc
automation: perform tasks on remote machines...
r42191 ],
)
available = len(res['InstanceInformationList'])
wanted = len(instances)
print('%d/%d instances available in SSM' % (available, wanted))
if available == wanted:
return
time.sleep(2)
def run_ssm_command(ssmclient, instances, document_name, parameters):
"""Run a PowerShell script on an EC2 instance."""
res = ssmclient.send_command(
InstanceIds=[i.id for i in instances],
DocumentName=document_name,
Parameters=parameters,
Augie Fackler
formating: upgrade to black 20.8b1...
r46554 CloudWatchOutputConfig={
'CloudWatchOutputEnabled': True,
},
Gregory Szorc
automation: perform tasks on remote machines...
r42191 )
command_id = res['Command']['CommandId']
for instance in instances:
while True:
try:
res = ssmclient.get_command_invocation(
Augie Fackler
formating: upgrade to black 20.8b1...
r46554 CommandId=command_id,
InstanceId=instance.id,
Gregory Szorc
automation: perform tasks on remote machines...
r42191 )
except botocore.exceptions.ClientError as e:
if e.response['Error']['Code'] == 'InvocationDoesNotExist':
print('could not find SSM command invocation; waiting')
time.sleep(1)
continue
else:
raise
if res['Status'] == 'Success':
break
elif res['Status'] in ('Pending', 'InProgress', 'Delayed'):
time.sleep(2)
else:
Augie Fackler
formatting: blacken the codebase...
r43346 raise Exception(
'command failed on %s: %s' % (instance.id, res['Status'])
)
Gregory Szorc
automation: perform tasks on remote machines...
r42191
@contextlib.contextmanager
def temporary_ec2_instances(ec2resource, config):
"""Create temporary EC2 instances.
This is a proxy to ``ec2client.run_instances(**config)`` that takes care of
managing the lifecycle of the instances.
When the context manager exits, the instances are terminated.
The context manager evaluates to the list of data structures
describing each created instance. The instances may not be available
for work immediately: it is up to the caller to wait for the instance
to start responding.
"""
ids = None
try:
res = ec2resource.create_instances(**config)
ids = [i.id for i in res]
print('started instances: %s' % ' '.join(ids))
yield res
finally:
if ids:
print('terminating instances: %s' % ' '.join(ids))
for instance in res:
instance.terminate()
print('terminated %d instances' % len(ids))
@contextlib.contextmanager
Gregory Szorc
automation: schedule an EC2Launch run on next boot...
r43528 def create_temp_windows_ec2_instances(
c: AWSConnection, config, bootstrap: bool = False
):
Gregory Szorc
automation: perform tasks on remote machines...
r42191 """Create temporary Windows EC2 instances.
This is a higher-level wrapper around ``create_temp_ec2_instances()`` that
configures the Windows instance for Windows Remote Management. The emitted
instances will have a ``winrm_client`` attribute containing a
``pypsrp.client.Client`` instance bound to the instance.
"""
if 'IamInstanceProfile' in config:
raise ValueError('IamInstanceProfile cannot be provided in config')
if 'UserData' in config:
raise ValueError('UserData cannot be provided in config')
password = c.automation.default_password()
config = copy.deepcopy(config)
config['IamInstanceProfile'] = {
'Name': 'hg-ephemeral-ec2-1',
}
Augie Fackler
formatting: blacken the codebase...
r43346 config.setdefault('TagSpecifications', []).append(
{
'ResourceType': 'instance',
'Tags': [{'Key': 'Name', 'Value': 'hg-temp-windows'}],
}
)
Gregory Szorc
automation: schedule an EC2Launch run on next boot...
r43528
if bootstrap:
config['UserData'] = WINDOWS_USER_DATA % password
Gregory Szorc
automation: perform tasks on remote machines...
r42191
with temporary_ec2_instances(c.ec2resource, config) as instances:
wait_for_ip_addresses(instances)
print('waiting for Windows Remote Management service...')
for instance in instances:
Augie Fackler
formatting: blacken the codebase...
r43346 client = wait_for_winrm(
instance.public_ip_address, 'Administrator', password
)
Gregory Szorc
automation: perform tasks on remote machines...
r42191 print('established WinRM connection to %s' % instance.id)
instance.winrm_client = client
yield instances
Gregory Szorc
automation: move image operations to own functions...
r42470 def resolve_fingerprint(fingerprint):
fingerprint = json.dumps(fingerprint, sort_keys=True)
return hashlib.sha256(fingerprint.encode('utf-8')).hexdigest()
def find_and_reconcile_image(ec2resource, name, fingerprint):
"""Attempt to find an existing EC2 AMI with a name and fingerprint.
If an image with the specified fingerprint is found, it is returned.
Otherwise None is returned.
Existing images for the specified name that don't have the specified
fingerprint or are missing required metadata or deleted.
"""
# Find existing AMIs with this name and delete the ones that are invalid.
# Store a reference to a good image so it can be returned one the
# image state is reconciled.
images = ec2resource.images.filter(
Augie Fackler
formatting: blacken the codebase...
r43346 Filters=[{'Name': 'name', 'Values': [name]}]
)
Gregory Szorc
automation: move image operations to own functions...
r42470
existing_image = None
for image in images:
if image.tags is None:
Augie Fackler
formatting: blacken the codebase...
r43346 print(
'image %s for %s lacks required tags; removing'
% (image.id, image.name)
)
Gregory Szorc
automation: move image operations to own functions...
r42470 remove_ami(ec2resource, image)
else:
tags = {t['Key']: t['Value'] for t in image.tags}
if tags.get('HGIMAGEFINGERPRINT') == fingerprint:
existing_image = image
else:
Augie Fackler
formatting: blacken the codebase...
r43346 print(
'image %s for %s has wrong fingerprint; removing'
% (image.id, image.name)
)
Gregory Szorc
automation: move image operations to own functions...
r42470 remove_ami(ec2resource, image)
return existing_image
Augie Fackler
formatting: blacken the codebase...
r43346 def create_ami_from_instance(
ec2client, instance, name, description, fingerprint
):
Gregory Szorc
automation: move image operations to own functions...
r42470 """Create an AMI from a running instance.
Returns the ``ec2resource.Image`` representing the created AMI.
"""
instance.stop()
ec2client.get_waiter('instance_stopped').wait(
Augie Fackler
formating: upgrade to black 20.8b1...
r46554 InstanceIds=[instance.id],
WaiterConfig={
'Delay': 5,
},
Augie Fackler
formatting: blacken the codebase...
r43346 )
Gregory Szorc
automation: move image operations to own functions...
r42470 print('%s is stopped' % instance.id)
Augie Fackler
formating: upgrade to black 20.8b1...
r46554 image = instance.create_image(
Name=name,
Description=description,
)
Gregory Szorc
automation: move image operations to own functions...
r42470
Augie Fackler
formatting: blacken the codebase...
r43346 image.create_tags(
Augie Fackler
formating: upgrade to black 20.8b1...
r46554 Tags=[
{
'Key': 'HGIMAGEFINGERPRINT',
'Value': fingerprint,
},
]
Augie Fackler
formatting: blacken the codebase...
r43346 )
Gregory Szorc
automation: move image operations to own functions...
r42470
print('waiting for image %s' % image.id)
Augie Fackler
formating: upgrade to black 20.8b1...
r46554 ec2client.get_waiter('image_available').wait(
ImageIds=[image.id],
)
Gregory Szorc
automation: move image operations to own functions...
r42470
print('image %s available as %s' % (image.id, image.name))
return image
Gregory Szorc
automation: support and use Debian Buster by default...
r43288 def ensure_linux_dev_ami(c: AWSConnection, distro='debian10', prefix='hg-'):
Gregory Szorc
automation: initial support for running Linux tests...
r42471 """Ensures a Linux development AMI is available and up-to-date.
Returns an ``ec2.Image`` of either an existing AMI or a newly-built one.
"""
ec2client = c.ec2client
ec2resource = c.ec2resource
name = '%s%s-%s' % (prefix, 'linux-dev', distro)
if distro == 'debian9':
image = find_image(
ec2resource,
DEBIAN_ACCOUNT_ID,
Gregory Szorc
automation: use latest AMIs...
r43287 'debian-stretch-hvm-x86_64-gp2-2019-09-08-17994',
Gregory Szorc
automation: initial support for running Linux tests...
r42471 )
ssh_username = 'admin'
Gregory Szorc
automation: support and use Debian Buster by default...
r43288 elif distro == 'debian10':
image = find_image(
Augie Fackler
formating: upgrade to black 20.8b1...
r46554 ec2resource,
DEBIAN_ACCOUNT_ID_2,
'debian-10-amd64-20190909-10',
Gregory Szorc
automation: support and use Debian Buster by default...
r43288 )
ssh_username = 'admin'
Gregory Szorc
automation: initial support for running Linux tests...
r42471 elif distro == 'ubuntu18.04':
image = find_image(
ec2resource,
UBUNTU_ACCOUNT_ID,
Gregory Szorc
automation: use latest AMIs...
r43287 'ubuntu/images/hvm-ssd/ubuntu-bionic-18.04-amd64-server-20190918',
Gregory Szorc
automation: initial support for running Linux tests...
r42471 )
ssh_username = 'ubuntu'
elif distro == 'ubuntu19.04':
image = find_image(
ec2resource,
UBUNTU_ACCOUNT_ID,
Gregory Szorc
automation: use latest AMIs...
r43287 'ubuntu/images/hvm-ssd/ubuntu-disco-19.04-amd64-server-20190918',
Gregory Szorc
automation: initial support for running Linux tests...
r42471 )
ssh_username = 'ubuntu'
else:
raise ValueError('unsupported Linux distro: %s' % distro)
config = {
'BlockDeviceMappings': [
{
'DeviceName': image.block_device_mappings[0]['DeviceName'],
'Ebs': {
'DeleteOnTermination': True,
Gregory Szorc
automation: increase size of Linux AMI build volume...
r43286 'VolumeSize': 10,
Gregory Szorc
automation: initial support for running Linux tests...
r42471 'VolumeType': 'gp2',
},
},
],
'EbsOptimized': True,
'ImageId': image.id,
'InstanceInitiatedShutdownBehavior': 'stop',
# 8 VCPUs for compiling Python.
'InstanceType': 't3.2xlarge',
'KeyName': '%sautomation' % prefix,
'MaxCount': 1,
'MinCount': 1,
'SecurityGroupIds': [c.security_groups['linux-dev-1'].id],
}
Augie Fackler
formatting: blacken the codebase...
r43346 requirements2_path = (
pathlib.Path(__file__).parent.parent / 'linux-requirements-py2.txt'
)
requirements3_path = (
pathlib.Path(__file__).parent.parent / 'linux-requirements-py3.txt'
)
Gregory Szorc
automation: initial support for running Linux tests...
r42471 with requirements2_path.open('r', encoding='utf-8') as fh:
requirements2 = fh.read()
with requirements3_path.open('r', encoding='utf-8') as fh:
requirements3 = fh.read()
# Compute a deterministic fingerprint to determine whether image needs to
# be regenerated.
Augie Fackler
formatting: blacken the codebase...
r43346 fingerprint = resolve_fingerprint(
{
'instance_config': config,
'bootstrap_script': BOOTSTRAP_DEBIAN,
'requirements_py2': requirements2,
'requirements_py3': requirements3,
}
)
Gregory Szorc
automation: initial support for running Linux tests...
r42471
existing_image = find_and_reconcile_image(ec2resource, name, fingerprint)
if existing_image:
return existing_image
print('no suitable %s image found; creating one...' % name)
with temporary_ec2_instances(ec2resource, config) as instances:
wait_for_ip_addresses(instances)
instance = instances[0]
client = wait_for_ssh(
Augie Fackler
formatting: blacken the codebase...
r43346 instance.public_ip_address,
22,
Gregory Szorc
automation: initial support for running Linux tests...
r42471 username=ssh_username,
Augie Fackler
formatting: blacken the codebase...
r43346 key_filename=str(c.key_pair_path_private('automation')),
)
Gregory Szorc
automation: initial support for running Linux tests...
r42471
home = '/home/%s' % ssh_username
with client:
print('connecting to SSH server')
sftp = client.open_sftp()
print('uploading bootstrap files')
with sftp.open('%s/bootstrap' % home, 'wb') as fh:
fh.write(BOOTSTRAP_DEBIAN)
fh.chmod(0o0700)
with sftp.open('%s/requirements-py2.txt' % home, 'wb') as fh:
fh.write(requirements2)
fh.chmod(0o0700)
with sftp.open('%s/requirements-py3.txt' % home, 'wb') as fh:
fh.write(requirements3)
fh.chmod(0o0700)
print('executing bootstrap')
Augie Fackler
formatting: blacken the codebase...
r43346 chan, stdin, stdout = ssh_exec_command(
client, '%s/bootstrap' % home
)
Gregory Szorc
automation: initial support for running Linux tests...
r42471 stdin.close()
for line in stdout:
print(line, end='')
res = chan.recv_exit_status()
if res:
raise Exception('non-0 exit from bootstrap: %d' % res)
Augie Fackler
formatting: blacken the codebase...
r43346 print(
'bootstrap completed; stopping %s to create %s'
% (instance.id, name)
)
Gregory Szorc
automation: initial support for running Linux tests...
r42471
Augie Fackler
formatting: blacken the codebase...
r43346 return create_ami_from_instance(
ec2client,
instance,
name,
'Mercurial Linux development environment',
fingerprint,
)
Gregory Szorc
automation: initial support for running Linux tests...
r42471
@contextlib.contextmanager
Augie Fackler
formatting: blacken the codebase...
r43346 def temporary_linux_dev_instances(
c: AWSConnection,
image,
instance_type,
prefix='hg-',
ensure_extra_volume=False,
):
Gregory Szorc
automation: initial support for running Linux tests...
r42471 """Create temporary Linux development EC2 instances.
Context manager resolves to a list of ``ec2.Instance`` that were created
and are running.
``ensure_extra_volume`` can be set to ``True`` to require that instances
have a 2nd storage volume available other than the primary AMI volume.
For instance types with instance storage, this does nothing special.
But for instance types without instance storage, an additional EBS volume
will be added to the instance.
Instances have an ``ssh_client`` attribute containing a paramiko SSHClient
instance bound to the instance.
Instances have an ``ssh_private_key_path`` attributing containing the
str path to the SSH private key to connect to the instance.
"""
block_device_mappings = [
{
'DeviceName': image.block_device_mappings[0]['DeviceName'],
'Ebs': {
'DeleteOnTermination': True,
Gregory Szorc
automation: increase root volume size on Linux...
r42925 'VolumeSize': 12,
Gregory Szorc
automation: initial support for running Linux tests...
r42471 'VolumeType': 'gp2',
},
}
]
# This is not an exhaustive list of instance types having instance storage.
# But
Augie Fackler
formatting: blacken the codebase...
r43346 if ensure_extra_volume and not instance_type.startswith(
tuple(INSTANCE_TYPES_WITH_STORAGE)
):
Gregory Szorc
automation: initial support for running Linux tests...
r42471 main_device = block_device_mappings[0]['DeviceName']
if main_device == 'xvda':
second_device = 'xvdb'
elif main_device == '/dev/sda1':
second_device = '/dev/sdb'
else:
Augie Fackler
formatting: blacken the codebase...
r43346 raise ValueError(
'unhandled primary EBS device name: %s' % main_device
)
Gregory Szorc
automation: initial support for running Linux tests...
r42471
Augie Fackler
formatting: blacken the codebase...
r43346 block_device_mappings.append(
{
'DeviceName': second_device,
'Ebs': {
'DeleteOnTermination': True,
'VolumeSize': 8,
'VolumeType': 'gp2',
},
Gregory Szorc
automation: initial support for running Linux tests...
r42471 }
Augie Fackler
formatting: blacken the codebase...
r43346 )
Gregory Szorc
automation: initial support for running Linux tests...
r42471
config = {
'BlockDeviceMappings': block_device_mappings,
'EbsOptimized': True,
'ImageId': image.id,
'InstanceInitiatedShutdownBehavior': 'terminate',
'InstanceType': instance_type,
'KeyName': '%sautomation' % prefix,
'MaxCount': 1,
'MinCount': 1,
'SecurityGroupIds': [c.security_groups['linux-dev-1'].id],
}
with temporary_ec2_instances(c.ec2resource, config) as instances:
wait_for_ip_addresses(instances)
ssh_private_key_path = str(c.key_pair_path_private('automation'))
for instance in instances:
client = wait_for_ssh(
Augie Fackler
formatting: blacken the codebase...
r43346 instance.public_ip_address,
22,
Gregory Szorc
automation: initial support for running Linux tests...
r42471 username='hg',
Augie Fackler
formatting: blacken the codebase...
r43346 key_filename=ssh_private_key_path,
)
Gregory Szorc
automation: initial support for running Linux tests...
r42471
instance.ssh_client = client
instance.ssh_private_key_path = ssh_private_key_path
try:
yield instances
finally:
for instance in instances:
instance.ssh_client.close()
Augie Fackler
formatting: blacken the codebase...
r43346 def ensure_windows_dev_ami(
Augie Fackler
formating: upgrade to black 20.8b1...
r46554 c: AWSConnection,
prefix='hg-',
base_image_name=WINDOWS_BASE_IMAGE_NAME,
Augie Fackler
formatting: blacken the codebase...
r43346 ):
Gregory Szorc
automation: perform tasks on remote machines...
r42191 """Ensure Windows Development AMI is available and up-to-date.
If necessary, a modern AMI will be built by starting a temporary EC2
instance and bootstrapping it.
Obsolete AMIs will be deleted so there is only a single AMI having the
desired name.
Returns an ``ec2.Image`` of either an existing AMI or a newly-built
one.
"""
ec2client = c.ec2client
ec2resource = c.ec2resource
ssmclient = c.session.client('ssm')
name = '%s%s' % (prefix, 'windows-dev')
Gregory Szorc
automation: always use latest Windows AMI...
r45241 image = find_image(
ec2resource,
AMAZON_ACCOUNT_ID,
base_image_name,
reverse_sort_field="name",
)
Gregory Szorc
automation: move image operations to own functions...
r42470
Gregory Szorc
automation: perform tasks on remote machines...
r42191 config = {
'BlockDeviceMappings': [
{
'DeviceName': '/dev/sda1',
'Ebs': {
'DeleteOnTermination': True,
'VolumeSize': 32,
'VolumeType': 'gp2',
},
}
],
Gregory Szorc
automation: move image operations to own functions...
r42470 'ImageId': image.id,
Gregory Szorc
automation: perform tasks on remote machines...
r42191 'InstanceInitiatedShutdownBehavior': 'stop',
'InstanceType': 't3.medium',
'KeyName': '%sautomation' % prefix,
'MaxCount': 1,
'MinCount': 1,
'SecurityGroupIds': [c.security_groups['windows-dev-1'].id],
}
commands = [
# Need to start the service so sshd_config is generated.
'Start-Service sshd',
'Write-Output "modifying sshd_config"',
r'$content = Get-Content C:\ProgramData\ssh\sshd_config',
'$content = $content -replace "Match Group administrators","" -replace "AuthorizedKeysFile __PROGRAMDATA__/ssh/administrators_authorized_keys",""',
r'$content | Set-Content C:\ProgramData\ssh\sshd_config',
'Import-Module OpenSSHUtils',
r'Repair-SshdConfigPermission C:\ProgramData\ssh\sshd_config -Confirm:$false',
'Restart-Service sshd',
'Write-Output "installing OpenSSL client"',
'Add-WindowsCapability -Online -Name OpenSSH.Client~~~~0.0.1.0',
'Set-Service -Name sshd -StartupType "Automatic"',
'Write-Output "OpenSSH server running"',
]
with INSTALL_WINDOWS_DEPENDENCIES.open('r', encoding='utf-8') as fh:
commands.extend(l.rstrip() for l in fh)
Gregory Szorc
automation: schedule an EC2Launch run on next boot...
r43528 # Schedule run of EC2Launch on next boot. This ensures that UserData
# is executed.
# We disable setComputerName because it forces a reboot.
# We set an explicit admin password because this causes UserData to run
# as Administrator instead of System.
commands.extend(
[
r'''Set-Content -Path C:\ProgramData\Amazon\EC2-Windows\Launch\Config\LaunchConfig.json '''
r'''-Value '{"setComputerName": false, "setWallpaper": true, "addDnsSuffixList": true, '''
r'''"extendBootVolumeSize": true, "handleUserData": true, '''
r'''"adminPasswordType": "Specify", "adminPassword": "%s"}' '''
% c.automation.default_password(),
r'C:\ProgramData\Amazon\EC2-Windows\Launch\Scripts\InitializeInstance.ps1 '
r'–Schedule',
]
)
Gregory Szorc
automation: perform tasks on remote machines...
r42191 # Disable Windows Defender when bootstrapping because it just slows
# things down.
commands.insert(0, 'Set-MpPreference -DisableRealtimeMonitoring $true')
commands.append('Set-MpPreference -DisableRealtimeMonitoring $false')
# Compute a deterministic fingerprint to determine whether image needs
# to be regenerated.
Augie Fackler
formatting: blacken the codebase...
r43346 fingerprint = resolve_fingerprint(
{
'instance_config': config,
'user_data': WINDOWS_USER_DATA,
'initial_bootstrap': WINDOWS_BOOTSTRAP_POWERSHELL,
'bootstrap_commands': commands,
'base_image_name': base_image_name,
}
)
Gregory Szorc
automation: perform tasks on remote machines...
r42191
Gregory Szorc
automation: move image operations to own functions...
r42470 existing_image = find_and_reconcile_image(ec2resource, name, fingerprint)
Gregory Szorc
automation: perform tasks on remote machines...
r42191
if existing_image:
return existing_image
print('no suitable Windows development image found; creating one...')
Gregory Szorc
automation: schedule an EC2Launch run on next boot...
r43528 with create_temp_windows_ec2_instances(
c, config, bootstrap=True
) as instances:
Gregory Szorc
automation: perform tasks on remote machines...
r42191 assert len(instances) == 1
instance = instances[0]
wait_for_ssm(ssmclient, [instance])
# On first boot, install various Windows updates.
# We would ideally use PowerShell Remoting for this. However, there are
# trust issues that make it difficult to invoke Windows Update
# remotely. So we use SSM, which has a mechanism for running Windows
# Update.
print('installing Windows features...')
run_ssm_command(
ssmclient,
[instance],
'AWS-RunPowerShellScript',
Augie Fackler
formating: upgrade to black 20.8b1...
r46554 {
'commands': WINDOWS_BOOTSTRAP_POWERSHELL.split('\n'),
},
Gregory Szorc
automation: perform tasks on remote machines...
r42191 )
# Reboot so all updates are fully applied.
Gregory Szorc
automation: shore up rebooting behavior...
r42466 #
# We don't use instance.reboot() here because it is asynchronous and
# we don't know when exactly the instance has rebooted. It could take
# a while to stop and we may start trying to interact with the instance
# before it has rebooted.
Gregory Szorc
automation: perform tasks on remote machines...
r42191 print('rebooting instance %s' % instance.id)
Gregory Szorc
automation: shore up rebooting behavior...
r42466 instance.stop()
ec2client.get_waiter('instance_stopped').wait(
Augie Fackler
formating: upgrade to black 20.8b1...
r46554 InstanceIds=[instance.id],
WaiterConfig={
'Delay': 5,
},
Augie Fackler
formatting: blacken the codebase...
r43346 )
Gregory Szorc
automation: perform tasks on remote machines...
r42191
Gregory Szorc
automation: shore up rebooting behavior...
r42466 instance.start()
wait_for_ip_addresses([instance])
# There is a race condition here between the User Data PS script running
# and us connecting to WinRM. This can manifest as
# "AuthorizationManager check failed" failures during run_powershell().
# TODO figure out a workaround.
Gregory Szorc
automation: perform tasks on remote machines...
r42191
print('waiting for Windows Remote Management to come back...')
Augie Fackler
formatting: blacken the codebase...
r43346 client = wait_for_winrm(
instance.public_ip_address,
'Administrator',
c.automation.default_password(),
)
Gregory Szorc
automation: perform tasks on remote machines...
r42191 print('established WinRM connection to %s' % instance.id)
instance.winrm_client = client
print('bootstrapping instance...')
run_powershell(instance.winrm_client, '\n'.join(commands))
print('bootstrap completed; stopping %s to create image' % instance.id)
Augie Fackler
formatting: blacken the codebase...
r43346 return create_ami_from_instance(
ec2client,
instance,
name,
'Mercurial Windows development environment',
fingerprint,
)
Gregory Szorc
automation: perform tasks on remote machines...
r42191
@contextlib.contextmanager
Augie Fackler
formatting: blacken the codebase...
r43346 def temporary_windows_dev_instances(
c: AWSConnection,
image,
instance_type,
prefix='hg-',
disable_antivirus=False,
):
Gregory Szorc
automation: perform tasks on remote machines...
r42191 """Create a temporary Windows development EC2 instance.
Context manager resolves to the list of ``EC2.Instance`` that were created.
"""
config = {
'BlockDeviceMappings': [
{
'DeviceName': '/dev/sda1',
'Ebs': {
'DeleteOnTermination': True,
'VolumeSize': 32,
'VolumeType': 'gp2',
},
}
],
'ImageId': image.id,
'InstanceInitiatedShutdownBehavior': 'stop',
'InstanceType': instance_type,
'KeyName': '%sautomation' % prefix,
'MaxCount': 1,
'MinCount': 1,
'SecurityGroupIds': [c.security_groups['windows-dev-1'].id],
}
with create_temp_windows_ec2_instances(c, config) as instances:
if disable_antivirus:
for instance in instances:
run_powershell(
instance.winrm_client,
Augie Fackler
formatting: blacken the codebase...
r43346 'Set-MpPreference -DisableRealtimeMonitoring $true',
)
Gregory Szorc
automation: perform tasks on remote machines...
r42191
yield instances