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