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