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