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