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