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