##// END OF EJS Templates
automation: detach policies before deleting role...
Gregory Szorc -
r42462:fcb97cb9 default
parent child Browse files
Show More
@@ -1,879 +1,883
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 .winrm import (
22 from .winrm import (
23 run_powershell,
23 run_powershell,
24 wait_for_winrm,
24 wait_for_winrm,
25 )
25 )
26
26
27
27
28 SOURCE_ROOT = pathlib.Path(os.path.abspath(__file__)).parent.parent.parent.parent
28 SOURCE_ROOT = pathlib.Path(os.path.abspath(__file__)).parent.parent.parent.parent
29
29
30 INSTALL_WINDOWS_DEPENDENCIES = (SOURCE_ROOT / 'contrib' /
30 INSTALL_WINDOWS_DEPENDENCIES = (SOURCE_ROOT / 'contrib' /
31 'install-windows-dependencies.ps1')
31 'install-windows-dependencies.ps1')
32
32
33
33
34 KEY_PAIRS = {
34 KEY_PAIRS = {
35 'automation',
35 'automation',
36 }
36 }
37
37
38
38
39 SECURITY_GROUPS = {
39 SECURITY_GROUPS = {
40 'windows-dev-1': {
40 'windows-dev-1': {
41 'description': 'Mercurial Windows instances that perform build automation',
41 'description': 'Mercurial Windows instances that perform build automation',
42 'ingress': [
42 'ingress': [
43 {
43 {
44 'FromPort': 22,
44 'FromPort': 22,
45 'ToPort': 22,
45 'ToPort': 22,
46 'IpProtocol': 'tcp',
46 'IpProtocol': 'tcp',
47 'IpRanges': [
47 'IpRanges': [
48 {
48 {
49 'CidrIp': '0.0.0.0/0',
49 'CidrIp': '0.0.0.0/0',
50 'Description': 'SSH from entire Internet',
50 'Description': 'SSH from entire Internet',
51 },
51 },
52 ],
52 ],
53 },
53 },
54 {
54 {
55 'FromPort': 3389,
55 'FromPort': 3389,
56 'ToPort': 3389,
56 'ToPort': 3389,
57 'IpProtocol': 'tcp',
57 'IpProtocol': 'tcp',
58 'IpRanges': [
58 'IpRanges': [
59 {
59 {
60 'CidrIp': '0.0.0.0/0',
60 'CidrIp': '0.0.0.0/0',
61 'Description': 'RDP from entire Internet',
61 'Description': 'RDP from entire Internet',
62 },
62 },
63 ],
63 ],
64
64
65 },
65 },
66 {
66 {
67 'FromPort': 5985,
67 'FromPort': 5985,
68 'ToPort': 5986,
68 'ToPort': 5986,
69 'IpProtocol': 'tcp',
69 'IpProtocol': 'tcp',
70 'IpRanges': [
70 'IpRanges': [
71 {
71 {
72 'CidrIp': '0.0.0.0/0',
72 'CidrIp': '0.0.0.0/0',
73 'Description': 'PowerShell Remoting (Windows Remote Management)',
73 'Description': 'PowerShell Remoting (Windows Remote Management)',
74 },
74 },
75 ],
75 ],
76 }
76 }
77 ],
77 ],
78 },
78 },
79 }
79 }
80
80
81
81
82 IAM_ROLES = {
82 IAM_ROLES = {
83 'ephemeral-ec2-role-1': {
83 'ephemeral-ec2-role-1': {
84 'description': 'Mercurial temporary EC2 instances',
84 'description': 'Mercurial temporary EC2 instances',
85 'policy_arns': [
85 'policy_arns': [
86 'arn:aws:iam::aws:policy/service-role/AmazonEC2RoleforSSM',
86 'arn:aws:iam::aws:policy/service-role/AmazonEC2RoleforSSM',
87 ],
87 ],
88 },
88 },
89 }
89 }
90
90
91
91
92 ASSUME_ROLE_POLICY_DOCUMENT = '''
92 ASSUME_ROLE_POLICY_DOCUMENT = '''
93 {
93 {
94 "Version": "2012-10-17",
94 "Version": "2012-10-17",
95 "Statement": [
95 "Statement": [
96 {
96 {
97 "Effect": "Allow",
97 "Effect": "Allow",
98 "Principal": {
98 "Principal": {
99 "Service": "ec2.amazonaws.com"
99 "Service": "ec2.amazonaws.com"
100 },
100 },
101 "Action": "sts:AssumeRole"
101 "Action": "sts:AssumeRole"
102 }
102 }
103 ]
103 ]
104 }
104 }
105 '''.strip()
105 '''.strip()
106
106
107
107
108 IAM_INSTANCE_PROFILES = {
108 IAM_INSTANCE_PROFILES = {
109 'ephemeral-ec2-1': {
109 'ephemeral-ec2-1': {
110 'roles': [
110 'roles': [
111 'ephemeral-ec2-role-1',
111 'ephemeral-ec2-role-1',
112 ],
112 ],
113 }
113 }
114 }
114 }
115
115
116
116
117 # User Data for Windows EC2 instance. Mainly used to set the password
117 # User Data for Windows EC2 instance. Mainly used to set the password
118 # and configure WinRM.
118 # and configure WinRM.
119 # Inspired by the User Data script used by Packer
119 # Inspired by the User Data script used by Packer
120 # (from https://www.packer.io/intro/getting-started/build-image.html).
120 # (from https://www.packer.io/intro/getting-started/build-image.html).
121 WINDOWS_USER_DATA = r'''
121 WINDOWS_USER_DATA = r'''
122 <powershell>
122 <powershell>
123
123
124 # TODO enable this once we figure out what is failing.
124 # TODO enable this once we figure out what is failing.
125 #$ErrorActionPreference = "stop"
125 #$ErrorActionPreference = "stop"
126
126
127 # Set administrator password
127 # Set administrator password
128 net user Administrator "%s"
128 net user Administrator "%s"
129 wmic useraccount where "name='Administrator'" set PasswordExpires=FALSE
129 wmic useraccount where "name='Administrator'" set PasswordExpires=FALSE
130
130
131 # First, make sure WinRM can't be connected to
131 # First, make sure WinRM can't be connected to
132 netsh advfirewall firewall set rule name="Windows Remote Management (HTTP-In)" new enable=yes action=block
132 netsh advfirewall firewall set rule name="Windows Remote Management (HTTP-In)" new enable=yes action=block
133
133
134 # Delete any existing WinRM listeners
134 # Delete any existing WinRM listeners
135 winrm delete winrm/config/listener?Address=*+Transport=HTTP 2>$Null
135 winrm delete winrm/config/listener?Address=*+Transport=HTTP 2>$Null
136 winrm delete winrm/config/listener?Address=*+Transport=HTTPS 2>$Null
136 winrm delete winrm/config/listener?Address=*+Transport=HTTPS 2>$Null
137
137
138 # Create a new WinRM listener and configure
138 # Create a new WinRM listener and configure
139 winrm create winrm/config/listener?Address=*+Transport=HTTP
139 winrm create winrm/config/listener?Address=*+Transport=HTTP
140 winrm set winrm/config/winrs '@{MaxMemoryPerShellMB="0"}'
140 winrm set winrm/config/winrs '@{MaxMemoryPerShellMB="0"}'
141 winrm set winrm/config '@{MaxTimeoutms="7200000"}'
141 winrm set winrm/config '@{MaxTimeoutms="7200000"}'
142 winrm set winrm/config/service '@{AllowUnencrypted="true"}'
142 winrm set winrm/config/service '@{AllowUnencrypted="true"}'
143 winrm set winrm/config/service '@{MaxConcurrentOperationsPerUser="12000"}'
143 winrm set winrm/config/service '@{MaxConcurrentOperationsPerUser="12000"}'
144 winrm set winrm/config/service/auth '@{Basic="true"}'
144 winrm set winrm/config/service/auth '@{Basic="true"}'
145 winrm set winrm/config/client/auth '@{Basic="true"}'
145 winrm set winrm/config/client/auth '@{Basic="true"}'
146
146
147 # Configure UAC to allow privilege elevation in remote shells
147 # Configure UAC to allow privilege elevation in remote shells
148 $Key = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System'
148 $Key = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System'
149 $Setting = 'LocalAccountTokenFilterPolicy'
149 $Setting = 'LocalAccountTokenFilterPolicy'
150 Set-ItemProperty -Path $Key -Name $Setting -Value 1 -Force
150 Set-ItemProperty -Path $Key -Name $Setting -Value 1 -Force
151
151
152 # Configure and restart the WinRM Service; Enable the required firewall exception
152 # Configure and restart the WinRM Service; Enable the required firewall exception
153 Stop-Service -Name WinRM
153 Stop-Service -Name WinRM
154 Set-Service -Name WinRM -StartupType Automatic
154 Set-Service -Name WinRM -StartupType Automatic
155 netsh advfirewall firewall set rule name="Windows Remote Management (HTTP-In)" new action=allow localip=any remoteip=any
155 netsh advfirewall firewall set rule name="Windows Remote Management (HTTP-In)" new action=allow localip=any remoteip=any
156 Start-Service -Name WinRM
156 Start-Service -Name WinRM
157
157
158 # Disable firewall on private network interfaces so prompts don't appear.
158 # Disable firewall on private network interfaces so prompts don't appear.
159 Set-NetFirewallProfile -Name private -Enabled false
159 Set-NetFirewallProfile -Name private -Enabled false
160 </powershell>
160 </powershell>
161 '''.lstrip()
161 '''.lstrip()
162
162
163
163
164 WINDOWS_BOOTSTRAP_POWERSHELL = '''
164 WINDOWS_BOOTSTRAP_POWERSHELL = '''
165 Write-Output "installing PowerShell dependencies"
165 Write-Output "installing PowerShell dependencies"
166 Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force
166 Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force
167 Set-PSRepository -Name PSGallery -InstallationPolicy Trusted
167 Set-PSRepository -Name PSGallery -InstallationPolicy Trusted
168 Install-Module -Name OpenSSHUtils -RequiredVersion 0.0.2.0
168 Install-Module -Name OpenSSHUtils -RequiredVersion 0.0.2.0
169
169
170 Write-Output "installing OpenSSL server"
170 Write-Output "installing OpenSSL server"
171 Add-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0
171 Add-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0
172 # Various tools will attempt to use older versions of .NET. So we enable
172 # Various tools will attempt to use older versions of .NET. So we enable
173 # the feature that provides them so it doesn't have to be auto-enabled
173 # the feature that provides them so it doesn't have to be auto-enabled
174 # later.
174 # later.
175 Write-Output "enabling .NET Framework feature"
175 Write-Output "enabling .NET Framework feature"
176 Install-WindowsFeature -Name Net-Framework-Core
176 Install-WindowsFeature -Name Net-Framework-Core
177 '''
177 '''
178
178
179
179
180 class AWSConnection:
180 class AWSConnection:
181 """Manages the state of a connection with AWS."""
181 """Manages the state of a connection with AWS."""
182
182
183 def __init__(self, automation, region: str):
183 def __init__(self, automation, region: str):
184 self.automation = automation
184 self.automation = automation
185 self.local_state_path = automation.state_path
185 self.local_state_path = automation.state_path
186
186
187 self.prefix = 'hg-'
187 self.prefix = 'hg-'
188
188
189 self.session = boto3.session.Session(region_name=region)
189 self.session = boto3.session.Session(region_name=region)
190 self.ec2client = self.session.client('ec2')
190 self.ec2client = self.session.client('ec2')
191 self.ec2resource = self.session.resource('ec2')
191 self.ec2resource = self.session.resource('ec2')
192 self.iamclient = self.session.client('iam')
192 self.iamclient = self.session.client('iam')
193 self.iamresource = self.session.resource('iam')
193 self.iamresource = self.session.resource('iam')
194
194
195 ensure_key_pairs(automation.state_path, self.ec2resource)
195 ensure_key_pairs(automation.state_path, self.ec2resource)
196
196
197 self.security_groups = ensure_security_groups(self.ec2resource)
197 self.security_groups = ensure_security_groups(self.ec2resource)
198 ensure_iam_state(self.iamresource)
198 ensure_iam_state(self.iamresource)
199
199
200 def key_pair_path_private(self, name):
200 def key_pair_path_private(self, name):
201 """Path to a key pair private key file."""
201 """Path to a key pair private key file."""
202 return self.local_state_path / 'keys' / ('keypair-%s' % name)
202 return self.local_state_path / 'keys' / ('keypair-%s' % name)
203
203
204 def key_pair_path_public(self, name):
204 def key_pair_path_public(self, name):
205 return self.local_state_path / 'keys' / ('keypair-%s.pub' % name)
205 return self.local_state_path / 'keys' / ('keypair-%s.pub' % name)
206
206
207
207
208 def rsa_key_fingerprint(p: pathlib.Path):
208 def rsa_key_fingerprint(p: pathlib.Path):
209 """Compute the fingerprint of an RSA private key."""
209 """Compute the fingerprint of an RSA private key."""
210
210
211 # TODO use rsa package.
211 # TODO use rsa package.
212 res = subprocess.run(
212 res = subprocess.run(
213 ['openssl', 'pkcs8', '-in', str(p), '-nocrypt', '-topk8',
213 ['openssl', 'pkcs8', '-in', str(p), '-nocrypt', '-topk8',
214 '-outform', 'DER'],
214 '-outform', 'DER'],
215 capture_output=True,
215 capture_output=True,
216 check=True)
216 check=True)
217
217
218 sha1 = hashlib.sha1(res.stdout).hexdigest()
218 sha1 = hashlib.sha1(res.stdout).hexdigest()
219 return ':'.join(a + b for a, b in zip(sha1[::2], sha1[1::2]))
219 return ':'.join(a + b for a, b in zip(sha1[::2], sha1[1::2]))
220
220
221
221
222 def ensure_key_pairs(state_path: pathlib.Path, ec2resource, prefix='hg-'):
222 def ensure_key_pairs(state_path: pathlib.Path, ec2resource, prefix='hg-'):
223 remote_existing = {}
223 remote_existing = {}
224
224
225 for kpi in ec2resource.key_pairs.all():
225 for kpi in ec2resource.key_pairs.all():
226 if kpi.name.startswith(prefix):
226 if kpi.name.startswith(prefix):
227 remote_existing[kpi.name[len(prefix):]] = kpi.key_fingerprint
227 remote_existing[kpi.name[len(prefix):]] = kpi.key_fingerprint
228
228
229 # Validate that we have these keys locally.
229 # Validate that we have these keys locally.
230 key_path = state_path / 'keys'
230 key_path = state_path / 'keys'
231 key_path.mkdir(exist_ok=True, mode=0o700)
231 key_path.mkdir(exist_ok=True, mode=0o700)
232
232
233 def remove_remote(name):
233 def remove_remote(name):
234 print('deleting key pair %s' % name)
234 print('deleting key pair %s' % name)
235 key = ec2resource.KeyPair(name)
235 key = ec2resource.KeyPair(name)
236 key.delete()
236 key.delete()
237
237
238 def remove_local(name):
238 def remove_local(name):
239 pub_full = key_path / ('keypair-%s.pub' % name)
239 pub_full = key_path / ('keypair-%s.pub' % name)
240 priv_full = key_path / ('keypair-%s' % name)
240 priv_full = key_path / ('keypair-%s' % name)
241
241
242 print('removing %s' % pub_full)
242 print('removing %s' % pub_full)
243 pub_full.unlink()
243 pub_full.unlink()
244 print('removing %s' % priv_full)
244 print('removing %s' % priv_full)
245 priv_full.unlink()
245 priv_full.unlink()
246
246
247 local_existing = {}
247 local_existing = {}
248
248
249 for f in sorted(os.listdir(key_path)):
249 for f in sorted(os.listdir(key_path)):
250 if not f.startswith('keypair-') or not f.endswith('.pub'):
250 if not f.startswith('keypair-') or not f.endswith('.pub'):
251 continue
251 continue
252
252
253 name = f[len('keypair-'):-len('.pub')]
253 name = f[len('keypair-'):-len('.pub')]
254
254
255 pub_full = key_path / f
255 pub_full = key_path / f
256 priv_full = key_path / ('keypair-%s' % name)
256 priv_full = key_path / ('keypair-%s' % name)
257
257
258 with open(pub_full, 'r', encoding='ascii') as fh:
258 with open(pub_full, 'r', encoding='ascii') as fh:
259 data = fh.read()
259 data = fh.read()
260
260
261 if not data.startswith('ssh-rsa '):
261 if not data.startswith('ssh-rsa '):
262 print('unexpected format for key pair file: %s; removing' %
262 print('unexpected format for key pair file: %s; removing' %
263 pub_full)
263 pub_full)
264 pub_full.unlink()
264 pub_full.unlink()
265 priv_full.unlink()
265 priv_full.unlink()
266 continue
266 continue
267
267
268 local_existing[name] = rsa_key_fingerprint(priv_full)
268 local_existing[name] = rsa_key_fingerprint(priv_full)
269
269
270 for name in sorted(set(remote_existing) | set(local_existing)):
270 for name in sorted(set(remote_existing) | set(local_existing)):
271 if name not in local_existing:
271 if name not in local_existing:
272 actual = '%s%s' % (prefix, name)
272 actual = '%s%s' % (prefix, name)
273 print('remote key %s does not exist locally' % name)
273 print('remote key %s does not exist locally' % name)
274 remove_remote(actual)
274 remove_remote(actual)
275 del remote_existing[name]
275 del remote_existing[name]
276
276
277 elif name not in remote_existing:
277 elif name not in remote_existing:
278 print('local key %s does not exist remotely' % name)
278 print('local key %s does not exist remotely' % name)
279 remove_local(name)
279 remove_local(name)
280 del local_existing[name]
280 del local_existing[name]
281
281
282 elif remote_existing[name] != local_existing[name]:
282 elif remote_existing[name] != local_existing[name]:
283 print('key fingerprint mismatch for %s; '
283 print('key fingerprint mismatch for %s; '
284 'removing from local and remote' % name)
284 'removing from local and remote' % name)
285 remove_local(name)
285 remove_local(name)
286 remove_remote('%s%s' % (prefix, name))
286 remove_remote('%s%s' % (prefix, name))
287 del local_existing[name]
287 del local_existing[name]
288 del remote_existing[name]
288 del remote_existing[name]
289
289
290 missing = KEY_PAIRS - set(remote_existing)
290 missing = KEY_PAIRS - set(remote_existing)
291
291
292 for name in sorted(missing):
292 for name in sorted(missing):
293 actual = '%s%s' % (prefix, name)
293 actual = '%s%s' % (prefix, name)
294 print('creating key pair %s' % actual)
294 print('creating key pair %s' % actual)
295
295
296 priv_full = key_path / ('keypair-%s' % name)
296 priv_full = key_path / ('keypair-%s' % name)
297 pub_full = key_path / ('keypair-%s.pub' % name)
297 pub_full = key_path / ('keypair-%s.pub' % name)
298
298
299 kp = ec2resource.create_key_pair(KeyName=actual)
299 kp = ec2resource.create_key_pair(KeyName=actual)
300
300
301 with priv_full.open('w', encoding='ascii') as fh:
301 with priv_full.open('w', encoding='ascii') as fh:
302 fh.write(kp.key_material)
302 fh.write(kp.key_material)
303 fh.write('\n')
303 fh.write('\n')
304
304
305 priv_full.chmod(0o0600)
305 priv_full.chmod(0o0600)
306
306
307 # SSH public key can be extracted via `ssh-keygen`.
307 # SSH public key can be extracted via `ssh-keygen`.
308 with pub_full.open('w', encoding='ascii') as fh:
308 with pub_full.open('w', encoding='ascii') as fh:
309 subprocess.run(
309 subprocess.run(
310 ['ssh-keygen', '-y', '-f', str(priv_full)],
310 ['ssh-keygen', '-y', '-f', str(priv_full)],
311 stdout=fh,
311 stdout=fh,
312 check=True)
312 check=True)
313
313
314 pub_full.chmod(0o0600)
314 pub_full.chmod(0o0600)
315
315
316
316
317 def delete_instance_profile(profile):
317 def delete_instance_profile(profile):
318 for role in profile.roles:
318 for role in profile.roles:
319 print('removing role %s from instance profile %s' % (role.name,
319 print('removing role %s from instance profile %s' % (role.name,
320 profile.name))
320 profile.name))
321 profile.remove_role(RoleName=role.name)
321 profile.remove_role(RoleName=role.name)
322
322
323 print('deleting instance profile %s' % profile.name)
323 print('deleting instance profile %s' % profile.name)
324 profile.delete()
324 profile.delete()
325
325
326
326
327 def ensure_iam_state(iamresource, prefix='hg-'):
327 def ensure_iam_state(iamresource, prefix='hg-'):
328 """Ensure IAM state is in sync with our canonical definition."""
328 """Ensure IAM state is in sync with our canonical definition."""
329
329
330 remote_profiles = {}
330 remote_profiles = {}
331
331
332 for profile in iamresource.instance_profiles.all():
332 for profile in iamresource.instance_profiles.all():
333 if profile.name.startswith(prefix):
333 if profile.name.startswith(prefix):
334 remote_profiles[profile.name[len(prefix):]] = profile
334 remote_profiles[profile.name[len(prefix):]] = profile
335
335
336 for name in sorted(set(remote_profiles) - set(IAM_INSTANCE_PROFILES)):
336 for name in sorted(set(remote_profiles) - set(IAM_INSTANCE_PROFILES)):
337 delete_instance_profile(remote_profiles[name])
337 delete_instance_profile(remote_profiles[name])
338 del remote_profiles[name]
338 del remote_profiles[name]
339
339
340 remote_roles = {}
340 remote_roles = {}
341
341
342 for role in iamresource.roles.all():
342 for role in iamresource.roles.all():
343 if role.name.startswith(prefix):
343 if role.name.startswith(prefix):
344 remote_roles[role.name[len(prefix):]] = role
344 remote_roles[role.name[len(prefix):]] = role
345
345
346 for name in sorted(set(remote_roles) - set(IAM_ROLES)):
346 for name in sorted(set(remote_roles) - set(IAM_ROLES)):
347 role = remote_roles[name]
347 role = remote_roles[name]
348
348
349 print('removing role %s' % role.name)
349 print('removing role %s' % role.name)
350 role.delete()
350 role.delete()
351 del remote_roles[name]
351 del remote_roles[name]
352
352
353 # We've purged remote state that doesn't belong. Create missing
353 # We've purged remote state that doesn't belong. Create missing
354 # instance profiles and roles.
354 # instance profiles and roles.
355 for name in sorted(set(IAM_INSTANCE_PROFILES) - set(remote_profiles)):
355 for name in sorted(set(IAM_INSTANCE_PROFILES) - set(remote_profiles)):
356 actual = '%s%s' % (prefix, name)
356 actual = '%s%s' % (prefix, name)
357 print('creating IAM instance profile %s' % actual)
357 print('creating IAM instance profile %s' % actual)
358
358
359 profile = iamresource.create_instance_profile(
359 profile = iamresource.create_instance_profile(
360 InstanceProfileName=actual)
360 InstanceProfileName=actual)
361 remote_profiles[name] = profile
361 remote_profiles[name] = profile
362
362
363 for name in sorted(set(IAM_ROLES) - set(remote_roles)):
363 for name in sorted(set(IAM_ROLES) - set(remote_roles)):
364 entry = IAM_ROLES[name]
364 entry = IAM_ROLES[name]
365
365
366 actual = '%s%s' % (prefix, name)
366 actual = '%s%s' % (prefix, name)
367 print('creating IAM role %s' % actual)
367 print('creating IAM role %s' % actual)
368
368
369 role = iamresource.create_role(
369 role = iamresource.create_role(
370 RoleName=actual,
370 RoleName=actual,
371 Description=entry['description'],
371 Description=entry['description'],
372 AssumeRolePolicyDocument=ASSUME_ROLE_POLICY_DOCUMENT,
372 AssumeRolePolicyDocument=ASSUME_ROLE_POLICY_DOCUMENT,
373 )
373 )
374
374
375 remote_roles[name] = role
375 remote_roles[name] = role
376
376
377 for arn in entry['policy_arns']:
377 for arn in entry['policy_arns']:
378 print('attaching policy %s to %s' % (arn, role.name))
378 print('attaching policy %s to %s' % (arn, role.name))
379 role.attach_policy(PolicyArn=arn)
379 role.attach_policy(PolicyArn=arn)
380
380
381 # Now reconcile state of profiles.
381 # Now reconcile state of profiles.
382 for name, meta in sorted(IAM_INSTANCE_PROFILES.items()):
382 for name, meta in sorted(IAM_INSTANCE_PROFILES.items()):
383 profile = remote_profiles[name]
383 profile = remote_profiles[name]
384 wanted = {'%s%s' % (prefix, role) for role in meta['roles']}
384 wanted = {'%s%s' % (prefix, role) for role in meta['roles']}
385 have = {role.name for role in profile.roles}
385 have = {role.name for role in profile.roles}
386
386
387 for role in sorted(have - wanted):
387 for role in sorted(have - wanted):
388 print('removing role %s from %s' % (role, profile.name))
388 print('removing role %s from %s' % (role, profile.name))
389 profile.remove_role(RoleName=role)
389 profile.remove_role(RoleName=role)
390
390
391 for role in sorted(wanted - have):
391 for role in sorted(wanted - have):
392 print('adding role %s to %s' % (role, profile.name))
392 print('adding role %s to %s' % (role, profile.name))
393 profile.add_role(RoleName=role)
393 profile.add_role(RoleName=role)
394
394
395
395
396 def find_windows_server_2019_image(ec2resource):
396 def find_windows_server_2019_image(ec2resource):
397 """Find the Amazon published Windows Server 2019 base image."""
397 """Find the Amazon published Windows Server 2019 base image."""
398
398
399 images = ec2resource.images.filter(
399 images = ec2resource.images.filter(
400 Filters=[
400 Filters=[
401 {
401 {
402 'Name': 'owner-alias',
402 'Name': 'owner-alias',
403 'Values': ['amazon'],
403 'Values': ['amazon'],
404 },
404 },
405 {
405 {
406 'Name': 'state',
406 'Name': 'state',
407 'Values': ['available'],
407 'Values': ['available'],
408 },
408 },
409 {
409 {
410 'Name': 'image-type',
410 'Name': 'image-type',
411 'Values': ['machine'],
411 'Values': ['machine'],
412 },
412 },
413 {
413 {
414 'Name': 'name',
414 'Name': 'name',
415 'Values': ['Windows_Server-2019-English-Full-Base-2019.02.13'],
415 'Values': ['Windows_Server-2019-English-Full-Base-2019.02.13'],
416 },
416 },
417 ])
417 ])
418
418
419 for image in images:
419 for image in images:
420 return image
420 return image
421
421
422 raise Exception('unable to find Windows Server 2019 image')
422 raise Exception('unable to find Windows Server 2019 image')
423
423
424
424
425 def ensure_security_groups(ec2resource, prefix='hg-'):
425 def ensure_security_groups(ec2resource, prefix='hg-'):
426 """Ensure all necessary Mercurial security groups are present.
426 """Ensure all necessary Mercurial security groups are present.
427
427
428 All security groups are prefixed with ``hg-`` by default. Any security
428 All security groups are prefixed with ``hg-`` by default. Any security
429 groups having this prefix but aren't in our list are deleted.
429 groups having this prefix but aren't in our list are deleted.
430 """
430 """
431 existing = {}
431 existing = {}
432
432
433 for group in ec2resource.security_groups.all():
433 for group in ec2resource.security_groups.all():
434 if group.group_name.startswith(prefix):
434 if group.group_name.startswith(prefix):
435 existing[group.group_name[len(prefix):]] = group
435 existing[group.group_name[len(prefix):]] = group
436
436
437 purge = set(existing) - set(SECURITY_GROUPS)
437 purge = set(existing) - set(SECURITY_GROUPS)
438
438
439 for name in sorted(purge):
439 for name in sorted(purge):
440 group = existing[name]
440 group = existing[name]
441 print('removing legacy security group: %s' % group.group_name)
441 print('removing legacy security group: %s' % group.group_name)
442 group.delete()
442 group.delete()
443
443
444 security_groups = {}
444 security_groups = {}
445
445
446 for name, group in sorted(SECURITY_GROUPS.items()):
446 for name, group in sorted(SECURITY_GROUPS.items()):
447 if name in existing:
447 if name in existing:
448 security_groups[name] = existing[name]
448 security_groups[name] = existing[name]
449 continue
449 continue
450
450
451 actual = '%s%s' % (prefix, name)
451 actual = '%s%s' % (prefix, name)
452 print('adding security group %s' % actual)
452 print('adding security group %s' % actual)
453
453
454 group_res = ec2resource.create_security_group(
454 group_res = ec2resource.create_security_group(
455 Description=group['description'],
455 Description=group['description'],
456 GroupName=actual,
456 GroupName=actual,
457 )
457 )
458
458
459 group_res.authorize_ingress(
459 group_res.authorize_ingress(
460 IpPermissions=group['ingress'],
460 IpPermissions=group['ingress'],
461 )
461 )
462
462
463 security_groups[name] = group_res
463 security_groups[name] = group_res
464
464
465 return security_groups
465 return security_groups
466
466
467
467
468 def terminate_ec2_instances(ec2resource, prefix='hg-'):
468 def terminate_ec2_instances(ec2resource, prefix='hg-'):
469 """Terminate all EC2 instances managed by us."""
469 """Terminate all EC2 instances managed by us."""
470 waiting = []
470 waiting = []
471
471
472 for instance in ec2resource.instances.all():
472 for instance in ec2resource.instances.all():
473 if instance.state['Name'] == 'terminated':
473 if instance.state['Name'] == 'terminated':
474 continue
474 continue
475
475
476 for tag in instance.tags or []:
476 for tag in instance.tags or []:
477 if tag['Key'] == 'Name' and tag['Value'].startswith(prefix):
477 if tag['Key'] == 'Name' and tag['Value'].startswith(prefix):
478 print('terminating %s' % instance.id)
478 print('terminating %s' % instance.id)
479 instance.terminate()
479 instance.terminate()
480 waiting.append(instance)
480 waiting.append(instance)
481
481
482 for instance in waiting:
482 for instance in waiting:
483 instance.wait_until_terminated()
483 instance.wait_until_terminated()
484
484
485
485
486 def remove_resources(c, prefix='hg-'):
486 def remove_resources(c, prefix='hg-'):
487 """Purge all of our resources in this EC2 region."""
487 """Purge all of our resources in this EC2 region."""
488 ec2resource = c.ec2resource
488 ec2resource = c.ec2resource
489 iamresource = c.iamresource
489 iamresource = c.iamresource
490
490
491 terminate_ec2_instances(ec2resource, prefix=prefix)
491 terminate_ec2_instances(ec2resource, prefix=prefix)
492
492
493 for image in ec2resource.images.filter(Owners=['self']):
493 for image in ec2resource.images.filter(Owners=['self']):
494 if image.name.startswith(prefix):
494 if image.name.startswith(prefix):
495 remove_ami(ec2resource, image)
495 remove_ami(ec2resource, image)
496
496
497 for group in ec2resource.security_groups.all():
497 for group in ec2resource.security_groups.all():
498 if group.group_name.startswith(prefix):
498 if group.group_name.startswith(prefix):
499 print('removing security group %s' % group.group_name)
499 print('removing security group %s' % group.group_name)
500 group.delete()
500 group.delete()
501
501
502 for profile in iamresource.instance_profiles.all():
502 for profile in iamresource.instance_profiles.all():
503 if profile.name.startswith(prefix):
503 if profile.name.startswith(prefix):
504 delete_instance_profile(profile)
504 delete_instance_profile(profile)
505
505
506 for role in iamresource.roles.all():
506 for role in iamresource.roles.all():
507 if role.name.startswith(prefix):
507 if role.name.startswith(prefix):
508 for p in role.attached_policies.all():
509 print('detaching policy %s from %s' % (p.arn, role.name))
510 role.detach_policy(PolicyArn=p.arn)
511
508 print('removing role %s' % role.name)
512 print('removing role %s' % role.name)
509 role.delete()
513 role.delete()
510
514
511
515
512 def wait_for_ip_addresses(instances):
516 def wait_for_ip_addresses(instances):
513 """Wait for the public IP addresses of an iterable of instances."""
517 """Wait for the public IP addresses of an iterable of instances."""
514 for instance in instances:
518 for instance in instances:
515 while True:
519 while True:
516 if not instance.public_ip_address:
520 if not instance.public_ip_address:
517 time.sleep(2)
521 time.sleep(2)
518 instance.reload()
522 instance.reload()
519 continue
523 continue
520
524
521 print('public IP address for %s: %s' % (
525 print('public IP address for %s: %s' % (
522 instance.id, instance.public_ip_address))
526 instance.id, instance.public_ip_address))
523 break
527 break
524
528
525
529
526 def remove_ami(ec2resource, image):
530 def remove_ami(ec2resource, image):
527 """Remove an AMI and its underlying snapshots."""
531 """Remove an AMI and its underlying snapshots."""
528 snapshots = []
532 snapshots = []
529
533
530 for device in image.block_device_mappings:
534 for device in image.block_device_mappings:
531 if 'Ebs' in device:
535 if 'Ebs' in device:
532 snapshots.append(ec2resource.Snapshot(device['Ebs']['SnapshotId']))
536 snapshots.append(ec2resource.Snapshot(device['Ebs']['SnapshotId']))
533
537
534 print('deregistering %s' % image.id)
538 print('deregistering %s' % image.id)
535 image.deregister()
539 image.deregister()
536
540
537 for snapshot in snapshots:
541 for snapshot in snapshots:
538 print('deleting snapshot %s' % snapshot.id)
542 print('deleting snapshot %s' % snapshot.id)
539 snapshot.delete()
543 snapshot.delete()
540
544
541
545
542 def wait_for_ssm(ssmclient, instances):
546 def wait_for_ssm(ssmclient, instances):
543 """Wait for SSM to come online for an iterable of instance IDs."""
547 """Wait for SSM to come online for an iterable of instance IDs."""
544 while True:
548 while True:
545 res = ssmclient.describe_instance_information(
549 res = ssmclient.describe_instance_information(
546 Filters=[
550 Filters=[
547 {
551 {
548 'Key': 'InstanceIds',
552 'Key': 'InstanceIds',
549 'Values': [i.id for i in instances],
553 'Values': [i.id for i in instances],
550 },
554 },
551 ],
555 ],
552 )
556 )
553
557
554 available = len(res['InstanceInformationList'])
558 available = len(res['InstanceInformationList'])
555 wanted = len(instances)
559 wanted = len(instances)
556
560
557 print('%d/%d instances available in SSM' % (available, wanted))
561 print('%d/%d instances available in SSM' % (available, wanted))
558
562
559 if available == wanted:
563 if available == wanted:
560 return
564 return
561
565
562 time.sleep(2)
566 time.sleep(2)
563
567
564
568
565 def run_ssm_command(ssmclient, instances, document_name, parameters):
569 def run_ssm_command(ssmclient, instances, document_name, parameters):
566 """Run a PowerShell script on an EC2 instance."""
570 """Run a PowerShell script on an EC2 instance."""
567
571
568 res = ssmclient.send_command(
572 res = ssmclient.send_command(
569 InstanceIds=[i.id for i in instances],
573 InstanceIds=[i.id for i in instances],
570 DocumentName=document_name,
574 DocumentName=document_name,
571 Parameters=parameters,
575 Parameters=parameters,
572 CloudWatchOutputConfig={
576 CloudWatchOutputConfig={
573 'CloudWatchOutputEnabled': True,
577 'CloudWatchOutputEnabled': True,
574 },
578 },
575 )
579 )
576
580
577 command_id = res['Command']['CommandId']
581 command_id = res['Command']['CommandId']
578
582
579 for instance in instances:
583 for instance in instances:
580 while True:
584 while True:
581 try:
585 try:
582 res = ssmclient.get_command_invocation(
586 res = ssmclient.get_command_invocation(
583 CommandId=command_id,
587 CommandId=command_id,
584 InstanceId=instance.id,
588 InstanceId=instance.id,
585 )
589 )
586 except botocore.exceptions.ClientError as e:
590 except botocore.exceptions.ClientError as e:
587 if e.response['Error']['Code'] == 'InvocationDoesNotExist':
591 if e.response['Error']['Code'] == 'InvocationDoesNotExist':
588 print('could not find SSM command invocation; waiting')
592 print('could not find SSM command invocation; waiting')
589 time.sleep(1)
593 time.sleep(1)
590 continue
594 continue
591 else:
595 else:
592 raise
596 raise
593
597
594 if res['Status'] == 'Success':
598 if res['Status'] == 'Success':
595 break
599 break
596 elif res['Status'] in ('Pending', 'InProgress', 'Delayed'):
600 elif res['Status'] in ('Pending', 'InProgress', 'Delayed'):
597 time.sleep(2)
601 time.sleep(2)
598 else:
602 else:
599 raise Exception('command failed on %s: %s' % (
603 raise Exception('command failed on %s: %s' % (
600 instance.id, res['Status']))
604 instance.id, res['Status']))
601
605
602
606
603 @contextlib.contextmanager
607 @contextlib.contextmanager
604 def temporary_ec2_instances(ec2resource, config):
608 def temporary_ec2_instances(ec2resource, config):
605 """Create temporary EC2 instances.
609 """Create temporary EC2 instances.
606
610
607 This is a proxy to ``ec2client.run_instances(**config)`` that takes care of
611 This is a proxy to ``ec2client.run_instances(**config)`` that takes care of
608 managing the lifecycle of the instances.
612 managing the lifecycle of the instances.
609
613
610 When the context manager exits, the instances are terminated.
614 When the context manager exits, the instances are terminated.
611
615
612 The context manager evaluates to the list of data structures
616 The context manager evaluates to the list of data structures
613 describing each created instance. The instances may not be available
617 describing each created instance. The instances may not be available
614 for work immediately: it is up to the caller to wait for the instance
618 for work immediately: it is up to the caller to wait for the instance
615 to start responding.
619 to start responding.
616 """
620 """
617
621
618 ids = None
622 ids = None
619
623
620 try:
624 try:
621 res = ec2resource.create_instances(**config)
625 res = ec2resource.create_instances(**config)
622
626
623 ids = [i.id for i in res]
627 ids = [i.id for i in res]
624 print('started instances: %s' % ' '.join(ids))
628 print('started instances: %s' % ' '.join(ids))
625
629
626 yield res
630 yield res
627 finally:
631 finally:
628 if ids:
632 if ids:
629 print('terminating instances: %s' % ' '.join(ids))
633 print('terminating instances: %s' % ' '.join(ids))
630 for instance in res:
634 for instance in res:
631 instance.terminate()
635 instance.terminate()
632 print('terminated %d instances' % len(ids))
636 print('terminated %d instances' % len(ids))
633
637
634
638
635 @contextlib.contextmanager
639 @contextlib.contextmanager
636 def create_temp_windows_ec2_instances(c: AWSConnection, config):
640 def create_temp_windows_ec2_instances(c: AWSConnection, config):
637 """Create temporary Windows EC2 instances.
641 """Create temporary Windows EC2 instances.
638
642
639 This is a higher-level wrapper around ``create_temp_ec2_instances()`` that
643 This is a higher-level wrapper around ``create_temp_ec2_instances()`` that
640 configures the Windows instance for Windows Remote Management. The emitted
644 configures the Windows instance for Windows Remote Management. The emitted
641 instances will have a ``winrm_client`` attribute containing a
645 instances will have a ``winrm_client`` attribute containing a
642 ``pypsrp.client.Client`` instance bound to the instance.
646 ``pypsrp.client.Client`` instance bound to the instance.
643 """
647 """
644 if 'IamInstanceProfile' in config:
648 if 'IamInstanceProfile' in config:
645 raise ValueError('IamInstanceProfile cannot be provided in config')
649 raise ValueError('IamInstanceProfile cannot be provided in config')
646 if 'UserData' in config:
650 if 'UserData' in config:
647 raise ValueError('UserData cannot be provided in config')
651 raise ValueError('UserData cannot be provided in config')
648
652
649 password = c.automation.default_password()
653 password = c.automation.default_password()
650
654
651 config = copy.deepcopy(config)
655 config = copy.deepcopy(config)
652 config['IamInstanceProfile'] = {
656 config['IamInstanceProfile'] = {
653 'Name': 'hg-ephemeral-ec2-1',
657 'Name': 'hg-ephemeral-ec2-1',
654 }
658 }
655 config.setdefault('TagSpecifications', []).append({
659 config.setdefault('TagSpecifications', []).append({
656 'ResourceType': 'instance',
660 'ResourceType': 'instance',
657 'Tags': [{'Key': 'Name', 'Value': 'hg-temp-windows'}],
661 'Tags': [{'Key': 'Name', 'Value': 'hg-temp-windows'}],
658 })
662 })
659 config['UserData'] = WINDOWS_USER_DATA % password
663 config['UserData'] = WINDOWS_USER_DATA % password
660
664
661 with temporary_ec2_instances(c.ec2resource, config) as instances:
665 with temporary_ec2_instances(c.ec2resource, config) as instances:
662 wait_for_ip_addresses(instances)
666 wait_for_ip_addresses(instances)
663
667
664 print('waiting for Windows Remote Management service...')
668 print('waiting for Windows Remote Management service...')
665
669
666 for instance in instances:
670 for instance in instances:
667 client = wait_for_winrm(instance.public_ip_address, 'Administrator', password)
671 client = wait_for_winrm(instance.public_ip_address, 'Administrator', password)
668 print('established WinRM connection to %s' % instance.id)
672 print('established WinRM connection to %s' % instance.id)
669 instance.winrm_client = client
673 instance.winrm_client = client
670
674
671 yield instances
675 yield instances
672
676
673
677
674 def ensure_windows_dev_ami(c: AWSConnection, prefix='hg-'):
678 def ensure_windows_dev_ami(c: AWSConnection, prefix='hg-'):
675 """Ensure Windows Development AMI is available and up-to-date.
679 """Ensure Windows Development AMI is available and up-to-date.
676
680
677 If necessary, a modern AMI will be built by starting a temporary EC2
681 If necessary, a modern AMI will be built by starting a temporary EC2
678 instance and bootstrapping it.
682 instance and bootstrapping it.
679
683
680 Obsolete AMIs will be deleted so there is only a single AMI having the
684 Obsolete AMIs will be deleted so there is only a single AMI having the
681 desired name.
685 desired name.
682
686
683 Returns an ``ec2.Image`` of either an existing AMI or a newly-built
687 Returns an ``ec2.Image`` of either an existing AMI or a newly-built
684 one.
688 one.
685 """
689 """
686 ec2client = c.ec2client
690 ec2client = c.ec2client
687 ec2resource = c.ec2resource
691 ec2resource = c.ec2resource
688 ssmclient = c.session.client('ssm')
692 ssmclient = c.session.client('ssm')
689
693
690 name = '%s%s' % (prefix, 'windows-dev')
694 name = '%s%s' % (prefix, 'windows-dev')
691
695
692 config = {
696 config = {
693 'BlockDeviceMappings': [
697 'BlockDeviceMappings': [
694 {
698 {
695 'DeviceName': '/dev/sda1',
699 'DeviceName': '/dev/sda1',
696 'Ebs': {
700 'Ebs': {
697 'DeleteOnTermination': True,
701 'DeleteOnTermination': True,
698 'VolumeSize': 32,
702 'VolumeSize': 32,
699 'VolumeType': 'gp2',
703 'VolumeType': 'gp2',
700 },
704 },
701 }
705 }
702 ],
706 ],
703 'ImageId': find_windows_server_2019_image(ec2resource).id,
707 'ImageId': find_windows_server_2019_image(ec2resource).id,
704 'InstanceInitiatedShutdownBehavior': 'stop',
708 'InstanceInitiatedShutdownBehavior': 'stop',
705 'InstanceType': 't3.medium',
709 'InstanceType': 't3.medium',
706 'KeyName': '%sautomation' % prefix,
710 'KeyName': '%sautomation' % prefix,
707 'MaxCount': 1,
711 'MaxCount': 1,
708 'MinCount': 1,
712 'MinCount': 1,
709 'SecurityGroupIds': [c.security_groups['windows-dev-1'].id],
713 'SecurityGroupIds': [c.security_groups['windows-dev-1'].id],
710 }
714 }
711
715
712 commands = [
716 commands = [
713 # Need to start the service so sshd_config is generated.
717 # Need to start the service so sshd_config is generated.
714 'Start-Service sshd',
718 'Start-Service sshd',
715 'Write-Output "modifying sshd_config"',
719 'Write-Output "modifying sshd_config"',
716 r'$content = Get-Content C:\ProgramData\ssh\sshd_config',
720 r'$content = Get-Content C:\ProgramData\ssh\sshd_config',
717 '$content = $content -replace "Match Group administrators","" -replace "AuthorizedKeysFile __PROGRAMDATA__/ssh/administrators_authorized_keys",""',
721 '$content = $content -replace "Match Group administrators","" -replace "AuthorizedKeysFile __PROGRAMDATA__/ssh/administrators_authorized_keys",""',
718 r'$content | Set-Content C:\ProgramData\ssh\sshd_config',
722 r'$content | Set-Content C:\ProgramData\ssh\sshd_config',
719 'Import-Module OpenSSHUtils',
723 'Import-Module OpenSSHUtils',
720 r'Repair-SshdConfigPermission C:\ProgramData\ssh\sshd_config -Confirm:$false',
724 r'Repair-SshdConfigPermission C:\ProgramData\ssh\sshd_config -Confirm:$false',
721 'Restart-Service sshd',
725 'Restart-Service sshd',
722 'Write-Output "installing OpenSSL client"',
726 'Write-Output "installing OpenSSL client"',
723 'Add-WindowsCapability -Online -Name OpenSSH.Client~~~~0.0.1.0',
727 'Add-WindowsCapability -Online -Name OpenSSH.Client~~~~0.0.1.0',
724 'Set-Service -Name sshd -StartupType "Automatic"',
728 'Set-Service -Name sshd -StartupType "Automatic"',
725 'Write-Output "OpenSSH server running"',
729 'Write-Output "OpenSSH server running"',
726 ]
730 ]
727
731
728 with INSTALL_WINDOWS_DEPENDENCIES.open('r', encoding='utf-8') as fh:
732 with INSTALL_WINDOWS_DEPENDENCIES.open('r', encoding='utf-8') as fh:
729 commands.extend(l.rstrip() for l in fh)
733 commands.extend(l.rstrip() for l in fh)
730
734
731 # Disable Windows Defender when bootstrapping because it just slows
735 # Disable Windows Defender when bootstrapping because it just slows
732 # things down.
736 # things down.
733 commands.insert(0, 'Set-MpPreference -DisableRealtimeMonitoring $true')
737 commands.insert(0, 'Set-MpPreference -DisableRealtimeMonitoring $true')
734 commands.append('Set-MpPreference -DisableRealtimeMonitoring $false')
738 commands.append('Set-MpPreference -DisableRealtimeMonitoring $false')
735
739
736 # Compute a deterministic fingerprint to determine whether image needs
740 # Compute a deterministic fingerprint to determine whether image needs
737 # to be regenerated.
741 # to be regenerated.
738 fingerprint = {
742 fingerprint = {
739 'instance_config': config,
743 'instance_config': config,
740 'user_data': WINDOWS_USER_DATA,
744 'user_data': WINDOWS_USER_DATA,
741 'initial_bootstrap': WINDOWS_BOOTSTRAP_POWERSHELL,
745 'initial_bootstrap': WINDOWS_BOOTSTRAP_POWERSHELL,
742 'bootstrap_commands': commands,
746 'bootstrap_commands': commands,
743 }
747 }
744
748
745 fingerprint = json.dumps(fingerprint, sort_keys=True)
749 fingerprint = json.dumps(fingerprint, sort_keys=True)
746 fingerprint = hashlib.sha256(fingerprint.encode('utf-8')).hexdigest()
750 fingerprint = hashlib.sha256(fingerprint.encode('utf-8')).hexdigest()
747
751
748 # Find existing AMIs with this name and delete the ones that are invalid.
752 # Find existing AMIs with this name and delete the ones that are invalid.
749 # Store a reference to a good image so it can be returned one the
753 # Store a reference to a good image so it can be returned one the
750 # image state is reconciled.
754 # image state is reconciled.
751 images = ec2resource.images.filter(
755 images = ec2resource.images.filter(
752 Filters=[{'Name': 'name', 'Values': [name]}])
756 Filters=[{'Name': 'name', 'Values': [name]}])
753
757
754 existing_image = None
758 existing_image = None
755
759
756 for image in images:
760 for image in images:
757 if image.tags is None:
761 if image.tags is None:
758 print('image %s for %s lacks required tags; removing' % (
762 print('image %s for %s lacks required tags; removing' % (
759 image.id, image.name))
763 image.id, image.name))
760 remove_ami(ec2resource, image)
764 remove_ami(ec2resource, image)
761 else:
765 else:
762 tags = {t['Key']: t['Value'] for t in image.tags}
766 tags = {t['Key']: t['Value'] for t in image.tags}
763
767
764 if tags.get('HGIMAGEFINGERPRINT') == fingerprint:
768 if tags.get('HGIMAGEFINGERPRINT') == fingerprint:
765 existing_image = image
769 existing_image = image
766 else:
770 else:
767 print('image %s for %s has wrong fingerprint; removing' % (
771 print('image %s for %s has wrong fingerprint; removing' % (
768 image.id, image.name))
772 image.id, image.name))
769 remove_ami(ec2resource, image)
773 remove_ami(ec2resource, image)
770
774
771 if existing_image:
775 if existing_image:
772 return existing_image
776 return existing_image
773
777
774 print('no suitable Windows development image found; creating one...')
778 print('no suitable Windows development image found; creating one...')
775
779
776 with create_temp_windows_ec2_instances(c, config) as instances:
780 with create_temp_windows_ec2_instances(c, config) as instances:
777 assert len(instances) == 1
781 assert len(instances) == 1
778 instance = instances[0]
782 instance = instances[0]
779
783
780 wait_for_ssm(ssmclient, [instance])
784 wait_for_ssm(ssmclient, [instance])
781
785
782 # On first boot, install various Windows updates.
786 # On first boot, install various Windows updates.
783 # We would ideally use PowerShell Remoting for this. However, there are
787 # We would ideally use PowerShell Remoting for this. However, there are
784 # trust issues that make it difficult to invoke Windows Update
788 # trust issues that make it difficult to invoke Windows Update
785 # remotely. So we use SSM, which has a mechanism for running Windows
789 # remotely. So we use SSM, which has a mechanism for running Windows
786 # Update.
790 # Update.
787 print('installing Windows features...')
791 print('installing Windows features...')
788 run_ssm_command(
792 run_ssm_command(
789 ssmclient,
793 ssmclient,
790 [instance],
794 [instance],
791 'AWS-RunPowerShellScript',
795 'AWS-RunPowerShellScript',
792 {
796 {
793 'commands': WINDOWS_BOOTSTRAP_POWERSHELL.split('\n'),
797 'commands': WINDOWS_BOOTSTRAP_POWERSHELL.split('\n'),
794 },
798 },
795 )
799 )
796
800
797 # Reboot so all updates are fully applied.
801 # Reboot so all updates are fully applied.
798 print('rebooting instance %s' % instance.id)
802 print('rebooting instance %s' % instance.id)
799 ec2client.reboot_instances(InstanceIds=[instance.id])
803 ec2client.reboot_instances(InstanceIds=[instance.id])
800
804
801 time.sleep(15)
805 time.sleep(15)
802
806
803 print('waiting for Windows Remote Management to come back...')
807 print('waiting for Windows Remote Management to come back...')
804 client = wait_for_winrm(instance.public_ip_address, 'Administrator',
808 client = wait_for_winrm(instance.public_ip_address, 'Administrator',
805 c.automation.default_password())
809 c.automation.default_password())
806 print('established WinRM connection to %s' % instance.id)
810 print('established WinRM connection to %s' % instance.id)
807 instance.winrm_client = client
811 instance.winrm_client = client
808
812
809 print('bootstrapping instance...')
813 print('bootstrapping instance...')
810 run_powershell(instance.winrm_client, '\n'.join(commands))
814 run_powershell(instance.winrm_client, '\n'.join(commands))
811
815
812 print('bootstrap completed; stopping %s to create image' % instance.id)
816 print('bootstrap completed; stopping %s to create image' % instance.id)
813 instance.stop()
817 instance.stop()
814
818
815 ec2client.get_waiter('instance_stopped').wait(
819 ec2client.get_waiter('instance_stopped').wait(
816 InstanceIds=[instance.id],
820 InstanceIds=[instance.id],
817 WaiterConfig={
821 WaiterConfig={
818 'Delay': 5,
822 'Delay': 5,
819 })
823 })
820 print('%s is stopped' % instance.id)
824 print('%s is stopped' % instance.id)
821
825
822 image = instance.create_image(
826 image = instance.create_image(
823 Name=name,
827 Name=name,
824 Description='Mercurial Windows development environment',
828 Description='Mercurial Windows development environment',
825 )
829 )
826
830
827 image.create_tags(Tags=[
831 image.create_tags(Tags=[
828 {
832 {
829 'Key': 'HGIMAGEFINGERPRINT',
833 'Key': 'HGIMAGEFINGERPRINT',
830 'Value': fingerprint,
834 'Value': fingerprint,
831 },
835 },
832 ])
836 ])
833
837
834 print('waiting for image %s' % image.id)
838 print('waiting for image %s' % image.id)
835
839
836 ec2client.get_waiter('image_available').wait(
840 ec2client.get_waiter('image_available').wait(
837 ImageIds=[image.id],
841 ImageIds=[image.id],
838 )
842 )
839
843
840 print('image %s available as %s' % (image.id, image.name))
844 print('image %s available as %s' % (image.id, image.name))
841
845
842 return image
846 return image
843
847
844
848
845 @contextlib.contextmanager
849 @contextlib.contextmanager
846 def temporary_windows_dev_instances(c: AWSConnection, image, instance_type,
850 def temporary_windows_dev_instances(c: AWSConnection, image, instance_type,
847 prefix='hg-', disable_antivirus=False):
851 prefix='hg-', disable_antivirus=False):
848 """Create a temporary Windows development EC2 instance.
852 """Create a temporary Windows development EC2 instance.
849
853
850 Context manager resolves to the list of ``EC2.Instance`` that were created.
854 Context manager resolves to the list of ``EC2.Instance`` that were created.
851 """
855 """
852 config = {
856 config = {
853 'BlockDeviceMappings': [
857 'BlockDeviceMappings': [
854 {
858 {
855 'DeviceName': '/dev/sda1',
859 'DeviceName': '/dev/sda1',
856 'Ebs': {
860 'Ebs': {
857 'DeleteOnTermination': True,
861 'DeleteOnTermination': True,
858 'VolumeSize': 32,
862 'VolumeSize': 32,
859 'VolumeType': 'gp2',
863 'VolumeType': 'gp2',
860 },
864 },
861 }
865 }
862 ],
866 ],
863 'ImageId': image.id,
867 'ImageId': image.id,
864 'InstanceInitiatedShutdownBehavior': 'stop',
868 'InstanceInitiatedShutdownBehavior': 'stop',
865 'InstanceType': instance_type,
869 'InstanceType': instance_type,
866 'KeyName': '%sautomation' % prefix,
870 'KeyName': '%sautomation' % prefix,
867 'MaxCount': 1,
871 'MaxCount': 1,
868 'MinCount': 1,
872 'MinCount': 1,
869 'SecurityGroupIds': [c.security_groups['windows-dev-1'].id],
873 'SecurityGroupIds': [c.security_groups['windows-dev-1'].id],
870 }
874 }
871
875
872 with create_temp_windows_ec2_instances(c, config) as instances:
876 with create_temp_windows_ec2_instances(c, config) as instances:
873 if disable_antivirus:
877 if disable_antivirus:
874 for instance in instances:
878 for instance in instances:
875 run_powershell(
879 run_powershell(
876 instance.winrm_client,
880 instance.winrm_client,
877 'Set-MpPreference -DisableRealtimeMonitoring $true')
881 'Set-MpPreference -DisableRealtimeMonitoring $true')
878
882
879 yield instances
883 yield instances
General Comments 0
You need to be logged in to leave comments. Login now