##// END OF EJS Templates
branching: merge stable into default
Raphaël Gomès -
r49646:834c9382 merge default
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': 'gp3',
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': 'gp3',
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': 'gp3',
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': 'gp3',
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': 'gp3',
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", "3.10"):
161 for py_version in ("2.7", "3.7", "3.8", "3.9", "3.10"):
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', '3.10'},
380 choices={'2.7', '3.7', '3.8', '3.9', '3.10'},
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', '3.10'},
504 choices={'2.7', '3.5', '3.6', '3.7', '3.8', '3.9', '3.10'},
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)
@@ -1,827 +1,837 b''
1 use crate::dirstate::entry::TruncatedTimestamp;
1 use crate::dirstate::entry::TruncatedTimestamp;
2 use crate::dirstate::status::IgnoreFnType;
2 use crate::dirstate::status::IgnoreFnType;
3 use crate::dirstate::status::StatusPath;
3 use crate::dirstate::status::StatusPath;
4 use crate::dirstate_tree::dirstate_map::BorrowedPath;
4 use crate::dirstate_tree::dirstate_map::BorrowedPath;
5 use crate::dirstate_tree::dirstate_map::ChildNodesRef;
5 use crate::dirstate_tree::dirstate_map::ChildNodesRef;
6 use crate::dirstate_tree::dirstate_map::DirstateMap;
6 use crate::dirstate_tree::dirstate_map::DirstateMap;
7 use crate::dirstate_tree::dirstate_map::NodeData;
7 use crate::dirstate_tree::dirstate_map::NodeData;
8 use crate::dirstate_tree::dirstate_map::NodeRef;
8 use crate::dirstate_tree::dirstate_map::NodeRef;
9 use crate::dirstate_tree::on_disk::DirstateV2ParseError;
9 use crate::dirstate_tree::on_disk::DirstateV2ParseError;
10 use crate::matchers::get_ignore_function;
10 use crate::matchers::get_ignore_function;
11 use crate::matchers::Matcher;
11 use crate::matchers::Matcher;
12 use crate::utils::files::get_bytes_from_os_string;
12 use crate::utils::files::get_bytes_from_os_string;
13 use crate::utils::files::get_path_from_bytes;
13 use crate::utils::files::get_path_from_bytes;
14 use crate::utils::hg_path::HgPath;
14 use crate::utils::hg_path::HgPath;
15 use crate::BadMatch;
15 use crate::BadMatch;
16 use crate::DirstateStatus;
16 use crate::DirstateStatus;
17 use crate::EntryState;
17 use crate::EntryState;
18 use crate::HgPathBuf;
18 use crate::HgPathBuf;
19 use crate::HgPathCow;
19 use crate::HgPathCow;
20 use crate::PatternFileWarning;
20 use crate::PatternFileWarning;
21 use crate::StatusError;
21 use crate::StatusError;
22 use crate::StatusOptions;
22 use crate::StatusOptions;
23 use micro_timer::timed;
23 use micro_timer::timed;
24 use rayon::prelude::*;
24 use rayon::prelude::*;
25 use sha1::{Digest, Sha1};
25 use sha1::{Digest, Sha1};
26 use std::borrow::Cow;
26 use std::borrow::Cow;
27 use std::io;
27 use std::io;
28 use std::path::Path;
28 use std::path::Path;
29 use std::path::PathBuf;
29 use std::path::PathBuf;
30 use std::sync::Mutex;
30 use std::sync::Mutex;
31 use std::time::SystemTime;
31 use std::time::SystemTime;
32
32
33 /// Returns the status of the working directory compared to its parent
33 /// Returns the status of the working directory compared to its parent
34 /// changeset.
34 /// changeset.
35 ///
35 ///
36 /// This algorithm is based on traversing the filesystem tree (`fs` in function
36 /// This algorithm is based on traversing the filesystem tree (`fs` in function
37 /// and variable names) and dirstate tree at the same time. The core of this
37 /// and variable names) and dirstate tree at the same time. The core of this
38 /// traversal is the recursive `traverse_fs_directory_and_dirstate` function
38 /// traversal is the recursive `traverse_fs_directory_and_dirstate` function
39 /// and its use of `itertools::merge_join_by`. When reaching a path that only
39 /// and its use of `itertools::merge_join_by`. When reaching a path that only
40 /// exists in one of the two trees, depending on information requested by
40 /// exists in one of the two trees, depending on information requested by
41 /// `options` we may need to traverse the remaining subtree.
41 /// `options` we may need to traverse the remaining subtree.
42 #[timed]
42 #[timed]
43 pub fn status<'tree, 'on_disk: 'tree>(
43 pub fn status<'tree, 'on_disk: 'tree>(
44 dmap: &'tree mut DirstateMap<'on_disk>,
44 dmap: &'tree mut DirstateMap<'on_disk>,
45 matcher: &(dyn Matcher + Sync),
45 matcher: &(dyn Matcher + Sync),
46 root_dir: PathBuf,
46 root_dir: PathBuf,
47 ignore_files: Vec<PathBuf>,
47 ignore_files: Vec<PathBuf>,
48 options: StatusOptions,
48 options: StatusOptions,
49 ) -> Result<(DirstateStatus<'on_disk>, Vec<PatternFileWarning>), StatusError> {
49 ) -> Result<(DirstateStatus<'on_disk>, Vec<PatternFileWarning>), StatusError> {
50 let (ignore_fn, warnings, patterns_changed): (IgnoreFnType, _, _) =
50 let (ignore_fn, warnings, patterns_changed): (IgnoreFnType, _, _) =
51 if options.list_ignored || options.list_unknown {
51 if options.list_ignored || options.list_unknown {
52 let mut hasher = Sha1::new();
52 let mut hasher = Sha1::new();
53 let (ignore_fn, warnings) = get_ignore_function(
53 let (ignore_fn, warnings) = get_ignore_function(
54 ignore_files,
54 ignore_files,
55 &root_dir,
55 &root_dir,
56 &mut |pattern_bytes| hasher.update(pattern_bytes),
56 &mut |pattern_bytes| hasher.update(pattern_bytes),
57 )?;
57 )?;
58 let new_hash = *hasher.finalize().as_ref();
58 let new_hash = *hasher.finalize().as_ref();
59 let changed = new_hash != dmap.ignore_patterns_hash;
59 let changed = new_hash != dmap.ignore_patterns_hash;
60 dmap.ignore_patterns_hash = new_hash;
60 dmap.ignore_patterns_hash = new_hash;
61 (ignore_fn, warnings, Some(changed))
61 (ignore_fn, warnings, Some(changed))
62 } else {
62 } else {
63 (Box::new(|&_| true), vec![], None)
63 (Box::new(|&_| true), vec![], None)
64 };
64 };
65
65
66 let filesystem_time_at_status_start =
66 let filesystem_time_at_status_start =
67 filesystem_now(&root_dir).ok().map(TruncatedTimestamp::from);
67 filesystem_now(&root_dir).ok().map(TruncatedTimestamp::from);
68
68
69 // If the repository is under the current directory, prefer using a
69 // If the repository is under the current directory, prefer using a
70 // relative path, so the kernel needs to traverse fewer directory in every
70 // relative path, so the kernel needs to traverse fewer directory in every
71 // call to `read_dir` or `symlink_metadata`.
71 // call to `read_dir` or `symlink_metadata`.
72 // This is effective in the common case where the current directory is the
72 // This is effective in the common case where the current directory is the
73 // repository root.
73 // repository root.
74
74
75 // TODO: Better yet would be to use libc functions like `openat` and
75 // TODO: Better yet would be to use libc functions like `openat` and
76 // `fstatat` to remove such repeated traversals entirely, but the standard
76 // `fstatat` to remove such repeated traversals entirely, but the standard
77 // library does not provide APIs based on those.
77 // library does not provide APIs based on those.
78 // Maybe with a crate like https://crates.io/crates/openat instead?
78 // Maybe with a crate like https://crates.io/crates/openat instead?
79 let root_dir = if let Some(relative) = std::env::current_dir()
79 let root_dir = if let Some(relative) = std::env::current_dir()
80 .ok()
80 .ok()
81 .and_then(|cwd| root_dir.strip_prefix(cwd).ok())
81 .and_then(|cwd| root_dir.strip_prefix(cwd).ok())
82 {
82 {
83 relative
83 relative
84 } else {
84 } else {
85 &root_dir
85 &root_dir
86 };
86 };
87
87
88 let outcome = DirstateStatus {
88 let outcome = DirstateStatus {
89 filesystem_time_at_status_start,
89 filesystem_time_at_status_start,
90 ..Default::default()
90 ..Default::default()
91 };
91 };
92 let common = StatusCommon {
92 let common = StatusCommon {
93 dmap,
93 dmap,
94 options,
94 options,
95 matcher,
95 matcher,
96 ignore_fn,
96 ignore_fn,
97 outcome: Mutex::new(outcome),
97 outcome: Mutex::new(outcome),
98 ignore_patterns_have_changed: patterns_changed,
98 ignore_patterns_have_changed: patterns_changed,
99 new_cachable_directories: Default::default(),
99 new_cachable_directories: Default::default(),
100 outated_cached_directories: Default::default(),
100 outated_cached_directories: Default::default(),
101 filesystem_time_at_status_start,
101 filesystem_time_at_status_start,
102 };
102 };
103 let is_at_repo_root = true;
103 let is_at_repo_root = true;
104 let hg_path = &BorrowedPath::OnDisk(HgPath::new(""));
104 let hg_path = &BorrowedPath::OnDisk(HgPath::new(""));
105 let has_ignored_ancestor = false;
105 let has_ignored_ancestor = false;
106 let root_cached_mtime = None;
106 let root_cached_mtime = None;
107 let root_dir_metadata = None;
107 let root_dir_metadata = None;
108 // If the path we have for the repository root is a symlink, do follow it.
108 // If the path we have for the repository root is a symlink, do follow it.
109 // (As opposed to symlinks within the working directory which are not
109 // (As opposed to symlinks within the working directory which are not
110 // followed, using `std::fs::symlink_metadata`.)
110 // followed, using `std::fs::symlink_metadata`.)
111 common.traverse_fs_directory_and_dirstate(
111 common.traverse_fs_directory_and_dirstate(
112 has_ignored_ancestor,
112 has_ignored_ancestor,
113 dmap.root.as_ref(),
113 dmap.root.as_ref(),
114 hg_path,
114 hg_path,
115 &root_dir,
115 &root_dir,
116 root_dir_metadata,
116 root_dir_metadata,
117 root_cached_mtime,
117 root_cached_mtime,
118 is_at_repo_root,
118 is_at_repo_root,
119 )?;
119 )?;
120 let mut outcome = common.outcome.into_inner().unwrap();
120 let mut outcome = common.outcome.into_inner().unwrap();
121 let new_cachable = common.new_cachable_directories.into_inner().unwrap();
121 let new_cachable = common.new_cachable_directories.into_inner().unwrap();
122 let outdated = common.outated_cached_directories.into_inner().unwrap();
122 let outdated = common.outated_cached_directories.into_inner().unwrap();
123
123
124 outcome.dirty = common.ignore_patterns_have_changed == Some(true)
124 outcome.dirty = common.ignore_patterns_have_changed == Some(true)
125 || !outdated.is_empty()
125 || !outdated.is_empty()
126 || !new_cachable.is_empty();
126 || !new_cachable.is_empty();
127
127
128 // Remove outdated mtimes before adding new mtimes, in case a given
128 // Remove outdated mtimes before adding new mtimes, in case a given
129 // directory is both
129 // directory is both
130 for path in &outdated {
130 for path in &outdated {
131 let node = dmap.get_or_insert(path)?;
131 let node = dmap.get_or_insert(path)?;
132 if let NodeData::CachedDirectory { .. } = &node.data {
132 if let NodeData::CachedDirectory { .. } = &node.data {
133 node.data = NodeData::None
133 node.data = NodeData::None
134 }
134 }
135 }
135 }
136 for (path, mtime) in &new_cachable {
136 for (path, mtime) in &new_cachable {
137 let node = dmap.get_or_insert(path)?;
137 let node = dmap.get_or_insert(path)?;
138 match &node.data {
138 match &node.data {
139 NodeData::Entry(_) => {} // Don’t overwrite an entry
139 NodeData::Entry(_) => {} // Don’t overwrite an entry
140 NodeData::CachedDirectory { .. } | NodeData::None => {
140 NodeData::CachedDirectory { .. } | NodeData::None => {
141 node.data = NodeData::CachedDirectory { mtime: *mtime }
141 node.data = NodeData::CachedDirectory { mtime: *mtime }
142 }
142 }
143 }
143 }
144 }
144 }
145
145
146 Ok((outcome, warnings))
146 Ok((outcome, warnings))
147 }
147 }
148
148
149 /// Bag of random things needed by various parts of the algorithm. Reduces the
149 /// Bag of random things needed by various parts of the algorithm. Reduces the
150 /// number of parameters passed to functions.
150 /// number of parameters passed to functions.
151 struct StatusCommon<'a, 'tree, 'on_disk: 'tree> {
151 struct StatusCommon<'a, 'tree, 'on_disk: 'tree> {
152 dmap: &'tree DirstateMap<'on_disk>,
152 dmap: &'tree DirstateMap<'on_disk>,
153 options: StatusOptions,
153 options: StatusOptions,
154 matcher: &'a (dyn Matcher + Sync),
154 matcher: &'a (dyn Matcher + Sync),
155 ignore_fn: IgnoreFnType<'a>,
155 ignore_fn: IgnoreFnType<'a>,
156 outcome: Mutex<DirstateStatus<'on_disk>>,
156 outcome: Mutex<DirstateStatus<'on_disk>>,
157 new_cachable_directories:
157 new_cachable_directories:
158 Mutex<Vec<(Cow<'on_disk, HgPath>, TruncatedTimestamp)>>,
158 Mutex<Vec<(Cow<'on_disk, HgPath>, TruncatedTimestamp)>>,
159 outated_cached_directories: Mutex<Vec<Cow<'on_disk, HgPath>>>,
159 outated_cached_directories: Mutex<Vec<Cow<'on_disk, HgPath>>>,
160
160
161 /// Whether ignore files like `.hgignore` have changed since the previous
161 /// Whether ignore files like `.hgignore` have changed since the previous
162 /// time a `status()` call wrote their hash to the dirstate. `None` means
162 /// time a `status()` call wrote their hash to the dirstate. `None` means
163 /// we don’t know as this run doesn’t list either ignored or uknown files
163 /// we don’t know as this run doesn’t list either ignored or uknown files
164 /// and therefore isn’t reading `.hgignore`.
164 /// and therefore isn’t reading `.hgignore`.
165 ignore_patterns_have_changed: Option<bool>,
165 ignore_patterns_have_changed: Option<bool>,
166
166
167 /// The current time at the start of the `status()` algorithm, as measured
167 /// The current time at the start of the `status()` algorithm, as measured
168 /// and possibly truncated by the filesystem.
168 /// and possibly truncated by the filesystem.
169 filesystem_time_at_status_start: Option<TruncatedTimestamp>,
169 filesystem_time_at_status_start: Option<TruncatedTimestamp>,
170 }
170 }
171
171
172 enum Outcome {
172 enum Outcome {
173 Modified,
173 Modified,
174 Added,
174 Added,
175 Removed,
175 Removed,
176 Deleted,
176 Deleted,
177 Clean,
177 Clean,
178 Ignored,
178 Ignored,
179 Unknown,
179 Unknown,
180 Unsure,
180 Unsure,
181 }
181 }
182
182
183 impl<'a, 'tree, 'on_disk> StatusCommon<'a, 'tree, 'on_disk> {
183 impl<'a, 'tree, 'on_disk> StatusCommon<'a, 'tree, 'on_disk> {
184 fn push_outcome(
184 fn push_outcome(
185 &self,
185 &self,
186 which: Outcome,
186 which: Outcome,
187 dirstate_node: &NodeRef<'tree, 'on_disk>,
187 dirstate_node: &NodeRef<'tree, 'on_disk>,
188 ) -> Result<(), DirstateV2ParseError> {
188 ) -> Result<(), DirstateV2ParseError> {
189 let path = dirstate_node
189 let path = dirstate_node
190 .full_path_borrowed(self.dmap.on_disk)?
190 .full_path_borrowed(self.dmap.on_disk)?
191 .detach_from_tree();
191 .detach_from_tree();
192 let copy_source = if self.options.list_copies {
192 let copy_source = if self.options.list_copies {
193 dirstate_node
193 dirstate_node
194 .copy_source_borrowed(self.dmap.on_disk)?
194 .copy_source_borrowed(self.dmap.on_disk)?
195 .map(|source| source.detach_from_tree())
195 .map(|source| source.detach_from_tree())
196 } else {
196 } else {
197 None
197 None
198 };
198 };
199 self.push_outcome_common(which, path, copy_source);
199 self.push_outcome_common(which, path, copy_source);
200 Ok(())
200 Ok(())
201 }
201 }
202
202
203 fn push_outcome_without_copy_source(
203 fn push_outcome_without_copy_source(
204 &self,
204 &self,
205 which: Outcome,
205 which: Outcome,
206 path: &BorrowedPath<'_, 'on_disk>,
206 path: &BorrowedPath<'_, 'on_disk>,
207 ) {
207 ) {
208 self.push_outcome_common(which, path.detach_from_tree(), None)
208 self.push_outcome_common(which, path.detach_from_tree(), None)
209 }
209 }
210
210
211 fn push_outcome_common(
211 fn push_outcome_common(
212 &self,
212 &self,
213 which: Outcome,
213 which: Outcome,
214 path: HgPathCow<'on_disk>,
214 path: HgPathCow<'on_disk>,
215 copy_source: Option<HgPathCow<'on_disk>>,
215 copy_source: Option<HgPathCow<'on_disk>>,
216 ) {
216 ) {
217 let mut outcome = self.outcome.lock().unwrap();
217 let mut outcome = self.outcome.lock().unwrap();
218 let vec = match which {
218 let vec = match which {
219 Outcome::Modified => &mut outcome.modified,
219 Outcome::Modified => &mut outcome.modified,
220 Outcome::Added => &mut outcome.added,
220 Outcome::Added => &mut outcome.added,
221 Outcome::Removed => &mut outcome.removed,
221 Outcome::Removed => &mut outcome.removed,
222 Outcome::Deleted => &mut outcome.deleted,
222 Outcome::Deleted => &mut outcome.deleted,
223 Outcome::Clean => &mut outcome.clean,
223 Outcome::Clean => &mut outcome.clean,
224 Outcome::Ignored => &mut outcome.ignored,
224 Outcome::Ignored => &mut outcome.ignored,
225 Outcome::Unknown => &mut outcome.unknown,
225 Outcome::Unknown => &mut outcome.unknown,
226 Outcome::Unsure => &mut outcome.unsure,
226 Outcome::Unsure => &mut outcome.unsure,
227 };
227 };
228 vec.push(StatusPath { path, copy_source });
228 vec.push(StatusPath { path, copy_source });
229 }
229 }
230
230
231 fn read_dir(
231 fn read_dir(
232 &self,
232 &self,
233 hg_path: &HgPath,
233 hg_path: &HgPath,
234 fs_path: &Path,
234 fs_path: &Path,
235 is_at_repo_root: bool,
235 is_at_repo_root: bool,
236 ) -> Result<Vec<DirEntry>, ()> {
236 ) -> Result<Vec<DirEntry>, ()> {
237 DirEntry::read_dir(fs_path, is_at_repo_root)
237 DirEntry::read_dir(fs_path, is_at_repo_root)
238 .map_err(|error| self.io_error(error, hg_path))
238 .map_err(|error| self.io_error(error, hg_path))
239 }
239 }
240
240
241 fn io_error(&self, error: std::io::Error, hg_path: &HgPath) {
241 fn io_error(&self, error: std::io::Error, hg_path: &HgPath) {
242 let errno = error.raw_os_error().expect("expected real OS error");
242 let errno = error.raw_os_error().expect("expected real OS error");
243 self.outcome
243 self.outcome
244 .lock()
244 .lock()
245 .unwrap()
245 .unwrap()
246 .bad
246 .bad
247 .push((hg_path.to_owned().into(), BadMatch::OsError(errno)))
247 .push((hg_path.to_owned().into(), BadMatch::OsError(errno)))
248 }
248 }
249
249
250 fn check_for_outdated_directory_cache(
250 fn check_for_outdated_directory_cache(
251 &self,
251 &self,
252 dirstate_node: &NodeRef<'tree, 'on_disk>,
252 dirstate_node: &NodeRef<'tree, 'on_disk>,
253 ) -> Result<(), DirstateV2ParseError> {
253 ) -> Result<(), DirstateV2ParseError> {
254 if self.ignore_patterns_have_changed == Some(true)
254 if self.ignore_patterns_have_changed == Some(true)
255 && dirstate_node.cached_directory_mtime()?.is_some()
255 && dirstate_node.cached_directory_mtime()?.is_some()
256 {
256 {
257 self.outated_cached_directories.lock().unwrap().push(
257 self.outated_cached_directories.lock().unwrap().push(
258 dirstate_node
258 dirstate_node
259 .full_path_borrowed(self.dmap.on_disk)?
259 .full_path_borrowed(self.dmap.on_disk)?
260 .detach_from_tree(),
260 .detach_from_tree(),
261 )
261 )
262 }
262 }
263 Ok(())
263 Ok(())
264 }
264 }
265
265
266 /// If this returns true, we can get accurate results by only using
266 /// If this returns true, we can get accurate results by only using
267 /// `symlink_metadata` for child nodes that exist in the dirstate and don’t
267 /// `symlink_metadata` for child nodes that exist in the dirstate and don’t
268 /// need to call `read_dir`.
268 /// need to call `read_dir`.
269 fn can_skip_fs_readdir(
269 fn can_skip_fs_readdir(
270 &self,
270 &self,
271 directory_metadata: Option<&std::fs::Metadata>,
271 directory_metadata: Option<&std::fs::Metadata>,
272 cached_directory_mtime: Option<TruncatedTimestamp>,
272 cached_directory_mtime: Option<TruncatedTimestamp>,
273 ) -> bool {
273 ) -> bool {
274 if !self.options.list_unknown && !self.options.list_ignored {
274 if !self.options.list_unknown && !self.options.list_ignored {
275 // All states that we care about listing have corresponding
275 // All states that we care about listing have corresponding
276 // dirstate entries.
276 // dirstate entries.
277 // This happens for example with `hg status -mard`.
277 // This happens for example with `hg status -mard`.
278 return true;
278 return true;
279 }
279 }
280 if !self.options.list_ignored
280 if !self.options.list_ignored
281 && self.ignore_patterns_have_changed == Some(false)
281 && self.ignore_patterns_have_changed == Some(false)
282 {
282 {
283 if let Some(cached_mtime) = cached_directory_mtime {
283 if let Some(cached_mtime) = cached_directory_mtime {
284 // The dirstate contains a cached mtime for this directory, set
284 // The dirstate contains a cached mtime for this directory, set
285 // by a previous run of the `status` algorithm which found this
285 // by a previous run of the `status` algorithm which found this
286 // directory eligible for `read_dir` caching.
286 // directory eligible for `read_dir` caching.
287 if let Some(meta) = directory_metadata {
287 if let Some(meta) = directory_metadata {
288 if cached_mtime
288 if cached_mtime
289 .likely_equal_to_mtime_of(meta)
289 .likely_equal_to_mtime_of(meta)
290 .unwrap_or(false)
290 .unwrap_or(false)
291 {
291 {
292 // The mtime of that directory has not changed
292 // The mtime of that directory has not changed
293 // since then, which means that the results of
293 // since then, which means that the results of
294 // `read_dir` should also be unchanged.
294 // `read_dir` should also be unchanged.
295 return true;
295 return true;
296 }
296 }
297 }
297 }
298 }
298 }
299 }
299 }
300 false
300 false
301 }
301 }
302
302
303 /// Returns whether all child entries of the filesystem directory have a
303 /// Returns whether all child entries of the filesystem directory have a
304 /// corresponding dirstate node or are ignored.
304 /// corresponding dirstate node or are ignored.
305 fn traverse_fs_directory_and_dirstate(
305 fn traverse_fs_directory_and_dirstate(
306 &self,
306 &self,
307 has_ignored_ancestor: bool,
307 has_ignored_ancestor: bool,
308 dirstate_nodes: ChildNodesRef<'tree, 'on_disk>,
308 dirstate_nodes: ChildNodesRef<'tree, 'on_disk>,
309 directory_hg_path: &BorrowedPath<'tree, 'on_disk>,
309 directory_hg_path: &BorrowedPath<'tree, 'on_disk>,
310 directory_fs_path: &Path,
310 directory_fs_path: &Path,
311 directory_metadata: Option<&std::fs::Metadata>,
311 directory_metadata: Option<&std::fs::Metadata>,
312 cached_directory_mtime: Option<TruncatedTimestamp>,
312 cached_directory_mtime: Option<TruncatedTimestamp>,
313 is_at_repo_root: bool,
313 is_at_repo_root: bool,
314 ) -> Result<bool, DirstateV2ParseError> {
314 ) -> Result<bool, DirstateV2ParseError> {
315 if self.can_skip_fs_readdir(directory_metadata, cached_directory_mtime)
315 if self.can_skip_fs_readdir(directory_metadata, cached_directory_mtime)
316 {
316 {
317 dirstate_nodes
317 dirstate_nodes
318 .par_iter()
318 .par_iter()
319 .map(|dirstate_node| {
319 .map(|dirstate_node| {
320 let fs_path = directory_fs_path.join(get_path_from_bytes(
320 let fs_path = directory_fs_path.join(get_path_from_bytes(
321 dirstate_node.base_name(self.dmap.on_disk)?.as_bytes(),
321 dirstate_node.base_name(self.dmap.on_disk)?.as_bytes(),
322 ));
322 ));
323 match std::fs::symlink_metadata(&fs_path) {
323 match std::fs::symlink_metadata(&fs_path) {
324 Ok(fs_metadata) => self.traverse_fs_and_dirstate(
324 Ok(fs_metadata) => self.traverse_fs_and_dirstate(
325 &fs_path,
325 &fs_path,
326 &fs_metadata,
326 &fs_metadata,
327 dirstate_node,
327 dirstate_node,
328 has_ignored_ancestor,
328 has_ignored_ancestor,
329 ),
329 ),
330 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
330 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
331 self.traverse_dirstate_only(dirstate_node)
331 self.traverse_dirstate_only(dirstate_node)
332 }
332 }
333 Err(error) => {
333 Err(error) => {
334 let hg_path =
334 let hg_path =
335 dirstate_node.full_path(self.dmap.on_disk)?;
335 dirstate_node.full_path(self.dmap.on_disk)?;
336 Ok(self.io_error(error, hg_path))
336 Ok(self.io_error(error, hg_path))
337 }
337 }
338 }
338 }
339 })
339 })
340 .collect::<Result<_, _>>()?;
340 .collect::<Result<_, _>>()?;
341
341
342 // We don’t know, so conservatively say this isn’t the case
342 // We don’t know, so conservatively say this isn’t the case
343 let children_all_have_dirstate_node_or_are_ignored = false;
343 let children_all_have_dirstate_node_or_are_ignored = false;
344
344
345 return Ok(children_all_have_dirstate_node_or_are_ignored);
345 return Ok(children_all_have_dirstate_node_or_are_ignored);
346 }
346 }
347
347
348 let mut fs_entries = if let Ok(entries) = self.read_dir(
348 let mut fs_entries = if let Ok(entries) = self.read_dir(
349 directory_hg_path,
349 directory_hg_path,
350 directory_fs_path,
350 directory_fs_path,
351 is_at_repo_root,
351 is_at_repo_root,
352 ) {
352 ) {
353 entries
353 entries
354 } else {
354 } else {
355 // Treat an unreadable directory (typically because of insufficient
355 // Treat an unreadable directory (typically because of insufficient
356 // permissions) like an empty directory. `self.read_dir` has
356 // permissions) like an empty directory. `self.read_dir` has
357 // already called `self.io_error` so a warning will be emitted.
357 // already called `self.io_error` so a warning will be emitted.
358 Vec::new()
358 Vec::new()
359 };
359 };
360
360
361 // `merge_join_by` requires both its input iterators to be sorted:
361 // `merge_join_by` requires both its input iterators to be sorted:
362
362
363 let dirstate_nodes = dirstate_nodes.sorted();
363 let dirstate_nodes = dirstate_nodes.sorted();
364 // `sort_unstable_by_key` doesn’t allow keys borrowing from the value:
364 // `sort_unstable_by_key` doesn’t allow keys borrowing from the value:
365 // https://github.com/rust-lang/rust/issues/34162
365 // https://github.com/rust-lang/rust/issues/34162
366 fs_entries.sort_unstable_by(|e1, e2| e1.base_name.cmp(&e2.base_name));
366 fs_entries.sort_unstable_by(|e1, e2| e1.base_name.cmp(&e2.base_name));
367
367
368 // Propagate here any error that would happen inside the comparison
368 // Propagate here any error that would happen inside the comparison
369 // callback below
369 // callback below
370 for dirstate_node in &dirstate_nodes {
370 for dirstate_node in &dirstate_nodes {
371 dirstate_node.base_name(self.dmap.on_disk)?;
371 dirstate_node.base_name(self.dmap.on_disk)?;
372 }
372 }
373 itertools::merge_join_by(
373 itertools::merge_join_by(
374 dirstate_nodes,
374 dirstate_nodes,
375 &fs_entries,
375 &fs_entries,
376 |dirstate_node, fs_entry| {
376 |dirstate_node, fs_entry| {
377 // This `unwrap` never panics because we already propagated
377 // This `unwrap` never panics because we already propagated
378 // those errors above
378 // those errors above
379 dirstate_node
379 dirstate_node
380 .base_name(self.dmap.on_disk)
380 .base_name(self.dmap.on_disk)
381 .unwrap()
381 .unwrap()
382 .cmp(&fs_entry.base_name)
382 .cmp(&fs_entry.base_name)
383 },
383 },
384 )
384 )
385 .par_bridge()
385 .par_bridge()
386 .map(|pair| {
386 .map(|pair| {
387 use itertools::EitherOrBoth::*;
387 use itertools::EitherOrBoth::*;
388 let has_dirstate_node_or_is_ignored;
388 let has_dirstate_node_or_is_ignored;
389 match pair {
389 match pair {
390 Both(dirstate_node, fs_entry) => {
390 Both(dirstate_node, fs_entry) => {
391 self.traverse_fs_and_dirstate(
391 self.traverse_fs_and_dirstate(
392 &fs_entry.full_path,
392 &fs_entry.full_path,
393 &fs_entry.metadata,
393 &fs_entry.metadata,
394 dirstate_node,
394 dirstate_node,
395 has_ignored_ancestor,
395 has_ignored_ancestor,
396 )?;
396 )?;
397 has_dirstate_node_or_is_ignored = true
397 has_dirstate_node_or_is_ignored = true
398 }
398 }
399 Left(dirstate_node) => {
399 Left(dirstate_node) => {
400 self.traverse_dirstate_only(dirstate_node)?;
400 self.traverse_dirstate_only(dirstate_node)?;
401 has_dirstate_node_or_is_ignored = true;
401 has_dirstate_node_or_is_ignored = true;
402 }
402 }
403 Right(fs_entry) => {
403 Right(fs_entry) => {
404 has_dirstate_node_or_is_ignored = self.traverse_fs_only(
404 has_dirstate_node_or_is_ignored = self.traverse_fs_only(
405 has_ignored_ancestor,
405 has_ignored_ancestor,
406 directory_hg_path,
406 directory_hg_path,
407 fs_entry,
407 fs_entry,
408 )
408 )
409 }
409 }
410 }
410 }
411 Ok(has_dirstate_node_or_is_ignored)
411 Ok(has_dirstate_node_or_is_ignored)
412 })
412 })
413 .try_reduce(|| true, |a, b| Ok(a && b))
413 .try_reduce(|| true, |a, b| Ok(a && b))
414 }
414 }
415
415
416 fn traverse_fs_and_dirstate(
416 fn traverse_fs_and_dirstate(
417 &self,
417 &self,
418 fs_path: &Path,
418 fs_path: &Path,
419 fs_metadata: &std::fs::Metadata,
419 fs_metadata: &std::fs::Metadata,
420 dirstate_node: NodeRef<'tree, 'on_disk>,
420 dirstate_node: NodeRef<'tree, 'on_disk>,
421 has_ignored_ancestor: bool,
421 has_ignored_ancestor: bool,
422 ) -> Result<(), DirstateV2ParseError> {
422 ) -> Result<(), DirstateV2ParseError> {
423 self.check_for_outdated_directory_cache(&dirstate_node)?;
423 self.check_for_outdated_directory_cache(&dirstate_node)?;
424 let hg_path = &dirstate_node.full_path_borrowed(self.dmap.on_disk)?;
424 let hg_path = &dirstate_node.full_path_borrowed(self.dmap.on_disk)?;
425 let file_type = fs_metadata.file_type();
425 let file_type = fs_metadata.file_type();
426 let file_or_symlink = file_type.is_file() || file_type.is_symlink();
426 let file_or_symlink = file_type.is_file() || file_type.is_symlink();
427 if !file_or_symlink {
427 if !file_or_symlink {
428 // If we previously had a file here, it was removed (with
428 // If we previously had a file here, it was removed (with
429 // `hg rm` or similar) or deleted before it could be
429 // `hg rm` or similar) or deleted before it could be
430 // replaced by a directory or something else.
430 // replaced by a directory or something else.
431 self.mark_removed_or_deleted_if_file(&dirstate_node)?;
431 self.mark_removed_or_deleted_if_file(&dirstate_node)?;
432 }
432 }
433 if file_type.is_dir() {
433 if file_type.is_dir() {
434 if self.options.collect_traversed_dirs {
434 if self.options.collect_traversed_dirs {
435 self.outcome
435 self.outcome
436 .lock()
436 .lock()
437 .unwrap()
437 .unwrap()
438 .traversed
438 .traversed
439 .push(hg_path.detach_from_tree())
439 .push(hg_path.detach_from_tree())
440 }
440 }
441 let is_ignored = has_ignored_ancestor || (self.ignore_fn)(hg_path);
441 let is_ignored = has_ignored_ancestor || (self.ignore_fn)(hg_path);
442 let is_at_repo_root = false;
442 let is_at_repo_root = false;
443 let children_all_have_dirstate_node_or_are_ignored = self
443 let children_all_have_dirstate_node_or_are_ignored = self
444 .traverse_fs_directory_and_dirstate(
444 .traverse_fs_directory_and_dirstate(
445 is_ignored,
445 is_ignored,
446 dirstate_node.children(self.dmap.on_disk)?,
446 dirstate_node.children(self.dmap.on_disk)?,
447 hg_path,
447 hg_path,
448 fs_path,
448 fs_path,
449 Some(fs_metadata),
449 Some(fs_metadata),
450 dirstate_node.cached_directory_mtime()?,
450 dirstate_node.cached_directory_mtime()?,
451 is_at_repo_root,
451 is_at_repo_root,
452 )?;
452 )?;
453 self.maybe_save_directory_mtime(
453 self.maybe_save_directory_mtime(
454 children_all_have_dirstate_node_or_are_ignored,
454 children_all_have_dirstate_node_or_are_ignored,
455 fs_metadata,
455 fs_metadata,
456 dirstate_node,
456 dirstate_node,
457 )?
457 )?
458 } else {
458 } else {
459 if file_or_symlink && self.matcher.matches(hg_path) {
459 if file_or_symlink && self.matcher.matches(hg_path) {
460 if let Some(state) = dirstate_node.state()? {
460 if let Some(state) = dirstate_node.state()? {
461 match state {
461 match state {
462 EntryState::Added => {
462 EntryState::Added => {
463 self.push_outcome(Outcome::Added, &dirstate_node)?
463 self.push_outcome(Outcome::Added, &dirstate_node)?
464 }
464 }
465 EntryState::Removed => self
465 EntryState::Removed => self
466 .push_outcome(Outcome::Removed, &dirstate_node)?,
466 .push_outcome(Outcome::Removed, &dirstate_node)?,
467 EntryState::Merged => self
467 EntryState::Merged => self
468 .push_outcome(Outcome::Modified, &dirstate_node)?,
468 .push_outcome(Outcome::Modified, &dirstate_node)?,
469 EntryState::Normal => self
469 EntryState::Normal => self
470 .handle_normal_file(&dirstate_node, fs_metadata)?,
470 .handle_normal_file(&dirstate_node, fs_metadata)?,
471 }
471 }
472 } else {
472 } else {
473 // `node.entry.is_none()` indicates a "directory"
473 // `node.entry.is_none()` indicates a "directory"
474 // node, but the filesystem has a file
474 // node, but the filesystem has a file
475 self.mark_unknown_or_ignored(
475 self.mark_unknown_or_ignored(
476 has_ignored_ancestor,
476 has_ignored_ancestor,
477 hg_path,
477 hg_path,
478 );
478 );
479 }
479 }
480 }
480 }
481
481
482 for child_node in dirstate_node.children(self.dmap.on_disk)?.iter()
482 for child_node in dirstate_node.children(self.dmap.on_disk)?.iter()
483 {
483 {
484 self.traverse_dirstate_only(child_node)?
484 self.traverse_dirstate_only(child_node)?
485 }
485 }
486 }
486 }
487 Ok(())
487 Ok(())
488 }
488 }
489
489
490 fn maybe_save_directory_mtime(
490 fn maybe_save_directory_mtime(
491 &self,
491 &self,
492 children_all_have_dirstate_node_or_are_ignored: bool,
492 children_all_have_dirstate_node_or_are_ignored: bool,
493 directory_metadata: &std::fs::Metadata,
493 directory_metadata: &std::fs::Metadata,
494 dirstate_node: NodeRef<'tree, 'on_disk>,
494 dirstate_node: NodeRef<'tree, 'on_disk>,
495 ) -> Result<(), DirstateV2ParseError> {
495 ) -> Result<(), DirstateV2ParseError> {
496 if !children_all_have_dirstate_node_or_are_ignored {
496 if !children_all_have_dirstate_node_or_are_ignored {
497 return Ok(());
497 return Ok(());
498 }
498 }
499 // All filesystem directory entries from `read_dir` have a
499 // All filesystem directory entries from `read_dir` have a
500 // corresponding node in the dirstate, so we can reconstitute the
500 // corresponding node in the dirstate, so we can reconstitute the
501 // names of those entries without calling `read_dir` again.
501 // names of those entries without calling `read_dir` again.
502
502
503 // TODO: use let-else here and below when available:
503 // TODO: use let-else here and below when available:
504 // https://github.com/rust-lang/rust/issues/87335
504 // https://github.com/rust-lang/rust/issues/87335
505 let status_start = if let Some(status_start) =
505 let status_start = if let Some(status_start) =
506 &self.filesystem_time_at_status_start
506 &self.filesystem_time_at_status_start
507 {
507 {
508 status_start
508 status_start
509 } else {
509 } else {
510 return Ok(());
510 return Ok(());
511 };
511 };
512
512
513 // Although the Rust standard library’s `SystemTime` type
513 // Although the Rust standard library’s `SystemTime` type
514 // has nanosecond precision, the times reported for a
514 // has nanosecond precision, the times reported for a
515 // directory’s (or file’s) modified time may have lower
515 // directory’s (or file’s) modified time may have lower
516 // resolution based on the filesystem (for example ext3
516 // resolution based on the filesystem (for example ext3
517 // only stores integer seconds), kernel (see
517 // only stores integer seconds), kernel (see
518 // https://stackoverflow.com/a/14393315/1162888), etc.
518 // https://stackoverflow.com/a/14393315/1162888), etc.
519 let directory_mtime = if let Ok(option) =
519 let directory_mtime = if let Ok(option) =
520 TruncatedTimestamp::for_reliable_mtime_of(
520 TruncatedTimestamp::for_reliable_mtime_of(
521 directory_metadata,
521 directory_metadata,
522 status_start,
522 status_start,
523 ) {
523 ) {
524 if let Some(directory_mtime) = option {
524 if let Some(directory_mtime) = option {
525 directory_mtime
525 directory_mtime
526 } else {
526 } else {
527 // The directory was modified too recently,
527 // The directory was modified too recently,
528 // don’t cache its `read_dir` results.
528 // don’t cache its `read_dir` results.
529 //
529 //
530 // 1. A change to this directory (direct child was
530 // 1. A change to this directory (direct child was
531 // added or removed) cause its mtime to be set
531 // added or removed) cause its mtime to be set
532 // (possibly truncated) to `directory_mtime`
532 // (possibly truncated) to `directory_mtime`
533 // 2. This `status` algorithm calls `read_dir`
533 // 2. This `status` algorithm calls `read_dir`
534 // 3. An other change is made to the same directory is
534 // 3. An other change is made to the same directory is
535 // made so that calling `read_dir` agin would give
535 // made so that calling `read_dir` agin would give
536 // different results, but soon enough after 1. that
536 // different results, but soon enough after 1. that
537 // the mtime stays the same
537 // the mtime stays the same
538 //
538 //
539 // On a system where the time resolution poor, this
539 // On a system where the time resolution poor, this
540 // scenario is not unlikely if all three steps are caused
540 // scenario is not unlikely if all three steps are caused
541 // by the same script.
541 // by the same script.
542 return Ok(());
542 return Ok(());
543 }
543 }
544 } else {
544 } else {
545 // OS/libc does not support mtime?
545 // OS/libc does not support mtime?
546 return Ok(());
546 return Ok(());
547 };
547 };
548 // We’ve observed (through `status_start`) that time has
548 // We’ve observed (through `status_start`) that time has
549 // “progressed” since `directory_mtime`, so any further
549 // “progressed” since `directory_mtime`, so any further
550 // change to this directory is extremely likely to cause a
550 // change to this directory is extremely likely to cause a
551 // different mtime.
551 // different mtime.
552 //
552 //
553 // Having the same mtime again is not entirely impossible
553 // Having the same mtime again is not entirely impossible
554 // since the system clock is not monotonous. It could jump
554 // since the system clock is not monotonous. It could jump
555 // backward to some point before `directory_mtime`, then a
555 // backward to some point before `directory_mtime`, then a
556 // directory change could potentially happen during exactly
556 // directory change could potentially happen during exactly
557 // the wrong tick.
557 // the wrong tick.
558 //
558 //
559 // We deem this scenario (unlike the previous one) to be
559 // We deem this scenario (unlike the previous one) to be
560 // unlikely enough in practice.
560 // unlikely enough in practice.
561
561
562 let is_up_to_date =
562 let is_up_to_date =
563 if let Some(cached) = dirstate_node.cached_directory_mtime()? {
563 if let Some(cached) = dirstate_node.cached_directory_mtime()? {
564 cached.likely_equal(directory_mtime)
564 cached.likely_equal(directory_mtime)
565 } else {
565 } else {
566 false
566 false
567 };
567 };
568 if !is_up_to_date {
568 if !is_up_to_date {
569 let hg_path = dirstate_node
569 let hg_path = dirstate_node
570 .full_path_borrowed(self.dmap.on_disk)?
570 .full_path_borrowed(self.dmap.on_disk)?
571 .detach_from_tree();
571 .detach_from_tree();
572 self.new_cachable_directories
572 self.new_cachable_directories
573 .lock()
573 .lock()
574 .unwrap()
574 .unwrap()
575 .push((hg_path, directory_mtime))
575 .push((hg_path, directory_mtime))
576 }
576 }
577 Ok(())
577 Ok(())
578 }
578 }
579
579
580 /// A file with `EntryState::Normal` in the dirstate was found in the
580 /// A file with `EntryState::Normal` in the dirstate was found in the
581 /// filesystem
581 /// filesystem
582 fn handle_normal_file(
582 fn handle_normal_file(
583 &self,
583 &self,
584 dirstate_node: &NodeRef<'tree, 'on_disk>,
584 dirstate_node: &NodeRef<'tree, 'on_disk>,
585 fs_metadata: &std::fs::Metadata,
585 fs_metadata: &std::fs::Metadata,
586 ) -> Result<(), DirstateV2ParseError> {
586 ) -> Result<(), DirstateV2ParseError> {
587 // Keep the low 31 bits
587 // Keep the low 31 bits
588 fn truncate_u64(value: u64) -> i32 {
588 fn truncate_u64(value: u64) -> i32 {
589 (value & 0x7FFF_FFFF) as i32
589 (value & 0x7FFF_FFFF) as i32
590 }
590 }
591
591
592 let entry = dirstate_node
592 let entry = dirstate_node
593 .entry()?
593 .entry()?
594 .expect("handle_normal_file called with entry-less node");
594 .expect("handle_normal_file called with entry-less node");
595 let mode_changed =
595 let mode_changed =
596 || self.options.check_exec && entry.mode_changed(fs_metadata);
596 || self.options.check_exec && entry.mode_changed(fs_metadata);
597 let size = entry.size();
597 let size = entry.size();
598 let size_changed = size != truncate_u64(fs_metadata.len());
598 let size_changed = size != truncate_u64(fs_metadata.len());
599 if size >= 0 && size_changed && fs_metadata.file_type().is_symlink() {
599 if size >= 0 && size_changed && fs_metadata.file_type().is_symlink() {
600 // issue6456: Size returned may be longer due to encryption
600 // issue6456: Size returned may be longer due to encryption
601 // on EXT-4 fscrypt. TODO maybe only do it on EXT4?
601 // on EXT-4 fscrypt. TODO maybe only do it on EXT4?
602 self.push_outcome(Outcome::Unsure, dirstate_node)?
602 self.push_outcome(Outcome::Unsure, dirstate_node)?
603 } else if dirstate_node.has_copy_source()
603 } else if dirstate_node.has_copy_source()
604 || entry.is_from_other_parent()
604 || entry.is_from_other_parent()
605 || (size >= 0 && (size_changed || mode_changed()))
605 || (size >= 0 && (size_changed || mode_changed()))
606 {
606 {
607 self.push_outcome(Outcome::Modified, dirstate_node)?
607 self.push_outcome(Outcome::Modified, dirstate_node)?
608 } else {
608 } else {
609 let mtime_looks_clean;
609 let mtime_looks_clean;
610 if let Some(dirstate_mtime) = entry.truncated_mtime() {
610 if let Some(dirstate_mtime) = entry.truncated_mtime() {
611 let fs_mtime = TruncatedTimestamp::for_mtime_of(fs_metadata)
611 let fs_mtime = TruncatedTimestamp::for_mtime_of(fs_metadata)
612 .expect("OS/libc does not support mtime?");
612 .expect("OS/libc does not support mtime?");
613 // There might be a change in the future if for example the
613 // There might be a change in the future if for example the
614 // internal clock become off while process run, but this is a
614 // internal clock become off while process run, but this is a
615 // case where the issues the user would face
615 // case where the issues the user would face
616 // would be a lot worse and there is nothing we
616 // would be a lot worse and there is nothing we
617 // can really do.
617 // can really do.
618 mtime_looks_clean = fs_mtime.likely_equal(dirstate_mtime)
618 mtime_looks_clean = fs_mtime.likely_equal(dirstate_mtime)
619 } else {
619 } else {
620 // No mtime in the dirstate entry
620 // No mtime in the dirstate entry
621 mtime_looks_clean = false
621 mtime_looks_clean = false
622 };
622 };
623 if !mtime_looks_clean {
623 if !mtime_looks_clean {
624 self.push_outcome(Outcome::Unsure, dirstate_node)?
624 self.push_outcome(Outcome::Unsure, dirstate_node)?
625 } else if self.options.list_clean {
625 } else if self.options.list_clean {
626 self.push_outcome(Outcome::Clean, dirstate_node)?
626 self.push_outcome(Outcome::Clean, dirstate_node)?
627 }
627 }
628 }
628 }
629 Ok(())
629 Ok(())
630 }
630 }
631
631
632 /// A node in the dirstate tree has no corresponding filesystem entry
632 /// A node in the dirstate tree has no corresponding filesystem entry
633 fn traverse_dirstate_only(
633 fn traverse_dirstate_only(
634 &self,
634 &self,
635 dirstate_node: NodeRef<'tree, 'on_disk>,
635 dirstate_node: NodeRef<'tree, 'on_disk>,
636 ) -> Result<(), DirstateV2ParseError> {
636 ) -> Result<(), DirstateV2ParseError> {
637 self.check_for_outdated_directory_cache(&dirstate_node)?;
637 self.check_for_outdated_directory_cache(&dirstate_node)?;
638 self.mark_removed_or_deleted_if_file(&dirstate_node)?;
638 self.mark_removed_or_deleted_if_file(&dirstate_node)?;
639 dirstate_node
639 dirstate_node
640 .children(self.dmap.on_disk)?
640 .children(self.dmap.on_disk)?
641 .par_iter()
641 .par_iter()
642 .map(|child_node| self.traverse_dirstate_only(child_node))
642 .map(|child_node| self.traverse_dirstate_only(child_node))
643 .collect()
643 .collect()
644 }
644 }
645
645
646 /// A node in the dirstate tree has no corresponding *file* on the
646 /// A node in the dirstate tree has no corresponding *file* on the
647 /// filesystem
647 /// filesystem
648 ///
648 ///
649 /// Does nothing on a "directory" node
649 /// Does nothing on a "directory" node
650 fn mark_removed_or_deleted_if_file(
650 fn mark_removed_or_deleted_if_file(
651 &self,
651 &self,
652 dirstate_node: &NodeRef<'tree, 'on_disk>,
652 dirstate_node: &NodeRef<'tree, 'on_disk>,
653 ) -> Result<(), DirstateV2ParseError> {
653 ) -> Result<(), DirstateV2ParseError> {
654 if let Some(state) = dirstate_node.state()? {
654 if let Some(state) = dirstate_node.state()? {
655 let path = dirstate_node.full_path(self.dmap.on_disk)?;
655 let path = dirstate_node.full_path(self.dmap.on_disk)?;
656 if self.matcher.matches(path) {
656 if self.matcher.matches(path) {
657 if let EntryState::Removed = state {
657 if let EntryState::Removed = state {
658 self.push_outcome(Outcome::Removed, dirstate_node)?
658 self.push_outcome(Outcome::Removed, dirstate_node)?
659 } else {
659 } else {
660 self.push_outcome(Outcome::Deleted, &dirstate_node)?
660 self.push_outcome(Outcome::Deleted, &dirstate_node)?
661 }
661 }
662 }
662 }
663 }
663 }
664 Ok(())
664 Ok(())
665 }
665 }
666
666
667 /// Something in the filesystem has no corresponding dirstate node
667 /// Something in the filesystem has no corresponding dirstate node
668 ///
668 ///
669 /// Returns whether that path is ignored
669 /// Returns whether that path is ignored
670 fn traverse_fs_only(
670 fn traverse_fs_only(
671 &self,
671 &self,
672 has_ignored_ancestor: bool,
672 has_ignored_ancestor: bool,
673 directory_hg_path: &HgPath,
673 directory_hg_path: &HgPath,
674 fs_entry: &DirEntry,
674 fs_entry: &DirEntry,
675 ) -> bool {
675 ) -> bool {
676 let hg_path = directory_hg_path.join(&fs_entry.base_name);
676 let hg_path = directory_hg_path.join(&fs_entry.base_name);
677 let file_type = fs_entry.metadata.file_type();
677 let file_type = fs_entry.metadata.file_type();
678 let file_or_symlink = file_type.is_file() || file_type.is_symlink();
678 let file_or_symlink = file_type.is_file() || file_type.is_symlink();
679 if file_type.is_dir() {
679 if file_type.is_dir() {
680 let is_ignored =
680 let is_ignored =
681 has_ignored_ancestor || (self.ignore_fn)(&hg_path);
681 has_ignored_ancestor || (self.ignore_fn)(&hg_path);
682 let traverse_children = if is_ignored {
682 let traverse_children = if is_ignored {
683 // Descendants of an ignored directory are all ignored
683 // Descendants of an ignored directory are all ignored
684 self.options.list_ignored
684 self.options.list_ignored
685 } else {
685 } else {
686 // Descendants of an unknown directory may be either unknown or
686 // Descendants of an unknown directory may be either unknown or
687 // ignored
687 // ignored
688 self.options.list_unknown || self.options.list_ignored
688 self.options.list_unknown || self.options.list_ignored
689 };
689 };
690 if traverse_children {
690 if traverse_children {
691 let is_at_repo_root = false;
691 let is_at_repo_root = false;
692 if let Ok(children_fs_entries) = self.read_dir(
692 if let Ok(children_fs_entries) = self.read_dir(
693 &hg_path,
693 &hg_path,
694 &fs_entry.full_path,
694 &fs_entry.full_path,
695 is_at_repo_root,
695 is_at_repo_root,
696 ) {
696 ) {
697 children_fs_entries.par_iter().for_each(|child_fs_entry| {
697 children_fs_entries.par_iter().for_each(|child_fs_entry| {
698 self.traverse_fs_only(
698 self.traverse_fs_only(
699 is_ignored,
699 is_ignored,
700 &hg_path,
700 &hg_path,
701 child_fs_entry,
701 child_fs_entry,
702 );
702 );
703 })
703 })
704 }
704 }
705 }
705 }
706 if self.options.collect_traversed_dirs {
706 if self.options.collect_traversed_dirs {
707 self.outcome.lock().unwrap().traversed.push(hg_path.into())
707 self.outcome.lock().unwrap().traversed.push(hg_path.into())
708 }
708 }
709 is_ignored
709 is_ignored
710 } else {
710 } else {
711 if file_or_symlink {
711 if file_or_symlink {
712 if self.matcher.matches(&hg_path) {
712 if self.matcher.matches(&hg_path) {
713 self.mark_unknown_or_ignored(
713 self.mark_unknown_or_ignored(
714 has_ignored_ancestor,
714 has_ignored_ancestor,
715 &BorrowedPath::InMemory(&hg_path),
715 &BorrowedPath::InMemory(&hg_path),
716 )
716 )
717 } else {
717 } else {
718 // We haven’t computed whether this path is ignored. It
718 // We haven’t computed whether this path is ignored. It
719 // might not be, and a future run of status might have a
719 // might not be, and a future run of status might have a
720 // different matcher that matches it. So treat it as not
720 // different matcher that matches it. So treat it as not
721 // ignored. That is, inhibit readdir caching of the parent
721 // ignored. That is, inhibit readdir caching of the parent
722 // directory.
722 // directory.
723 false
723 false
724 }
724 }
725 } else {
725 } else {
726 // This is neither a directory, a plain file, or a symlink.
726 // This is neither a directory, a plain file, or a symlink.
727 // Treat it like an ignored file.
727 // Treat it like an ignored file.
728 true
728 true
729 }
729 }
730 }
730 }
731 }
731 }
732
732
733 /// Returns whether that path is ignored
733 /// Returns whether that path is ignored
734 fn mark_unknown_or_ignored(
734 fn mark_unknown_or_ignored(
735 &self,
735 &self,
736 has_ignored_ancestor: bool,
736 has_ignored_ancestor: bool,
737 hg_path: &BorrowedPath<'_, 'on_disk>,
737 hg_path: &BorrowedPath<'_, 'on_disk>,
738 ) -> bool {
738 ) -> bool {
739 let is_ignored = has_ignored_ancestor || (self.ignore_fn)(&hg_path);
739 let is_ignored = has_ignored_ancestor || (self.ignore_fn)(&hg_path);
740 if is_ignored {
740 if is_ignored {
741 if self.options.list_ignored {
741 if self.options.list_ignored {
742 self.push_outcome_without_copy_source(
742 self.push_outcome_without_copy_source(
743 Outcome::Ignored,
743 Outcome::Ignored,
744 hg_path,
744 hg_path,
745 )
745 )
746 }
746 }
747 } else {
747 } else {
748 if self.options.list_unknown {
748 if self.options.list_unknown {
749 self.push_outcome_without_copy_source(
749 self.push_outcome_without_copy_source(
750 Outcome::Unknown,
750 Outcome::Unknown,
751 hg_path,
751 hg_path,
752 )
752 )
753 }
753 }
754 }
754 }
755 is_ignored
755 is_ignored
756 }
756 }
757 }
757 }
758
758
759 struct DirEntry {
759 struct DirEntry {
760 base_name: HgPathBuf,
760 base_name: HgPathBuf,
761 full_path: PathBuf,
761 full_path: PathBuf,
762 metadata: std::fs::Metadata,
762 metadata: std::fs::Metadata,
763 }
763 }
764
764
765 impl DirEntry {
765 impl DirEntry {
766 /// Returns **unsorted** entries in the given directory, with name and
766 /// Returns **unsorted** entries in the given directory, with name and
767 /// metadata.
767 /// metadata.
768 ///
768 ///
769 /// If a `.hg` sub-directory is encountered:
769 /// If a `.hg` sub-directory is encountered:
770 ///
770 ///
771 /// * At the repository root, ignore that sub-directory
771 /// * At the repository root, ignore that sub-directory
772 /// * Elsewhere, we’re listing the content of a sub-repo. Return an empty
772 /// * Elsewhere, we’re listing the content of a sub-repo. Return an empty
773 /// list instead.
773 /// list instead.
774 fn read_dir(path: &Path, is_at_repo_root: bool) -> io::Result<Vec<Self>> {
774 fn read_dir(path: &Path, is_at_repo_root: bool) -> io::Result<Vec<Self>> {
775 // `read_dir` returns a "not found" error for the empty path
775 // `read_dir` returns a "not found" error for the empty path
776 let at_cwd = path == Path::new("");
776 let at_cwd = path == Path::new("");
777 let read_dir_path = if at_cwd { Path::new(".") } else { path };
777 let read_dir_path = if at_cwd { Path::new(".") } else { path };
778 let mut results = Vec::new();
778 let mut results = Vec::new();
779 for entry in read_dir_path.read_dir()? {
779 for entry in read_dir_path.read_dir()? {
780 let entry = entry?;
780 let entry = entry?;
781 let metadata = entry.metadata()?;
781 let metadata = match entry.metadata() {
782 Ok(v) => v,
783 Err(e) => {
784 // race with file deletion?
785 if e.kind() == std::io::ErrorKind::NotFound {
786 continue;
787 } else {
788 return Err(e);
789 }
790 }
791 };
782 let file_name = entry.file_name();
792 let file_name = entry.file_name();
783 // FIXME don't do this when cached
793 // FIXME don't do this when cached
784 if file_name == ".hg" {
794 if file_name == ".hg" {
785 if is_at_repo_root {
795 if is_at_repo_root {
786 // Skip the repo’s own .hg (might be a symlink)
796 // Skip the repo’s own .hg (might be a symlink)
787 continue;
797 continue;
788 } else if metadata.is_dir() {
798 } else if metadata.is_dir() {
789 // A .hg sub-directory at another location means a subrepo,
799 // A .hg sub-directory at another location means a subrepo,
790 // skip it entirely.
800 // skip it entirely.
791 return Ok(Vec::new());
801 return Ok(Vec::new());
792 }
802 }
793 }
803 }
794 let full_path = if at_cwd {
804 let full_path = if at_cwd {
795 file_name.clone().into()
805 file_name.clone().into()
796 } else {
806 } else {
797 entry.path()
807 entry.path()
798 };
808 };
799 let base_name = get_bytes_from_os_string(file_name).into();
809 let base_name = get_bytes_from_os_string(file_name).into();
800 results.push(DirEntry {
810 results.push(DirEntry {
801 base_name,
811 base_name,
802 full_path,
812 full_path,
803 metadata,
813 metadata,
804 })
814 })
805 }
815 }
806 Ok(results)
816 Ok(results)
807 }
817 }
808 }
818 }
809
819
810 /// Return the `mtime` of a temporary file newly-created in the `.hg` directory
820 /// Return the `mtime` of a temporary file newly-created in the `.hg` directory
811 /// of the give repository.
821 /// of the give repository.
812 ///
822 ///
813 /// This is similar to `SystemTime::now()`, with the result truncated to the
823 /// This is similar to `SystemTime::now()`, with the result truncated to the
814 /// same time resolution as other files’ modification times. Using `.hg`
824 /// same time resolution as other files’ modification times. Using `.hg`
815 /// instead of the system’s default temporary directory (such as `/tmp`) makes
825 /// instead of the system’s default temporary directory (such as `/tmp`) makes
816 /// it more likely the temporary file is in the same disk partition as contents
826 /// it more likely the temporary file is in the same disk partition as contents
817 /// of the working directory, which can matter since different filesystems may
827 /// of the working directory, which can matter since different filesystems may
818 /// store timestamps with different resolutions.
828 /// store timestamps with different resolutions.
819 ///
829 ///
820 /// This may fail, typically if we lack write permissions. In that case we
830 /// This may fail, typically if we lack write permissions. In that case we
821 /// should continue the `status()` algoritm anyway and consider the current
831 /// should continue the `status()` algoritm anyway and consider the current
822 /// date/time to be unknown.
832 /// date/time to be unknown.
823 fn filesystem_now(repo_root: &Path) -> Result<SystemTime, io::Error> {
833 fn filesystem_now(repo_root: &Path) -> Result<SystemTime, io::Error> {
824 tempfile::tempfile_in(repo_root.join(".hg"))?
834 tempfile::tempfile_in(repo_root.join(".hg"))?
825 .metadata()?
835 .metadata()?
826 .modified()
836 .modified()
827 }
837 }
General Comments 0
You need to be logged in to leave comments. Login now