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