##// END OF EJS Templates
automation: add check that hg source directory is a repo...
Gregory Szorc -
r42467:4274b136 default
parent child Browse files
Show More
@@ -1,287 +1,291 b''
1 # windows.py - Automation specific to Windows
1 # windows.py - Automation specific to Windows
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 os
10 import os
11 import pathlib
11 import pathlib
12 import re
12 import re
13 import subprocess
13 import subprocess
14 import tempfile
14 import tempfile
15
15
16 from .winrm import (
16 from .winrm import (
17 run_powershell,
17 run_powershell,
18 )
18 )
19
19
20
20
21 # PowerShell commands to activate a Visual Studio 2008 environment.
21 # PowerShell commands to activate a Visual Studio 2008 environment.
22 # This is essentially a port of vcvarsall.bat to PowerShell.
22 # This is essentially a port of vcvarsall.bat to PowerShell.
23 ACTIVATE_VC9_AMD64 = r'''
23 ACTIVATE_VC9_AMD64 = r'''
24 Write-Output "activating Visual Studio 2008 environment for AMD64"
24 Write-Output "activating Visual Studio 2008 environment for AMD64"
25 $root = "$env:LOCALAPPDATA\Programs\Common\Microsoft\Visual C++ for Python\9.0"
25 $root = "$env:LOCALAPPDATA\Programs\Common\Microsoft\Visual C++ for Python\9.0"
26 $Env:VCINSTALLDIR = "${root}\VC\"
26 $Env:VCINSTALLDIR = "${root}\VC\"
27 $Env:WindowsSdkDir = "${root}\WinSDK\"
27 $Env:WindowsSdkDir = "${root}\WinSDK\"
28 $Env:PATH = "${root}\VC\Bin\amd64;${root}\WinSDK\Bin\x64;${root}\WinSDK\Bin;$Env:PATH"
28 $Env:PATH = "${root}\VC\Bin\amd64;${root}\WinSDK\Bin\x64;${root}\WinSDK\Bin;$Env:PATH"
29 $Env:INCLUDE = "${root}\VC\Include;${root}\WinSDK\Include;$Env:PATH"
29 $Env:INCLUDE = "${root}\VC\Include;${root}\WinSDK\Include;$Env:PATH"
30 $Env:LIB = "${root}\VC\Lib\amd64;${root}\WinSDK\Lib\x64;$Env:LIB"
30 $Env:LIB = "${root}\VC\Lib\amd64;${root}\WinSDK\Lib\x64;$Env:LIB"
31 $Env:LIBPATH = "${root}\VC\Lib\amd64;${root}\WinSDK\Lib\x64;$Env:LIBPATH"
31 $Env:LIBPATH = "${root}\VC\Lib\amd64;${root}\WinSDK\Lib\x64;$Env:LIBPATH"
32 '''.lstrip()
32 '''.lstrip()
33
33
34 ACTIVATE_VC9_X86 = r'''
34 ACTIVATE_VC9_X86 = r'''
35 Write-Output "activating Visual Studio 2008 environment for x86"
35 Write-Output "activating Visual Studio 2008 environment for x86"
36 $root = "$env:LOCALAPPDATA\Programs\Common\Microsoft\Visual C++ for Python\9.0"
36 $root = "$env:LOCALAPPDATA\Programs\Common\Microsoft\Visual C++ for Python\9.0"
37 $Env:VCINSTALLDIR = "${root}\VC\"
37 $Env:VCINSTALLDIR = "${root}\VC\"
38 $Env:WindowsSdkDir = "${root}\WinSDK\"
38 $Env:WindowsSdkDir = "${root}\WinSDK\"
39 $Env:PATH = "${root}\VC\Bin;${root}\WinSDK\Bin;$Env:PATH"
39 $Env:PATH = "${root}\VC\Bin;${root}\WinSDK\Bin;$Env:PATH"
40 $Env:INCLUDE = "${root}\VC\Include;${root}\WinSDK\Include;$Env:INCLUDE"
40 $Env:INCLUDE = "${root}\VC\Include;${root}\WinSDK\Include;$Env:INCLUDE"
41 $Env:LIB = "${root}\VC\Lib;${root}\WinSDK\Lib;$Env:LIB"
41 $Env:LIB = "${root}\VC\Lib;${root}\WinSDK\Lib;$Env:LIB"
42 $Env:LIBPATH = "${root}\VC\lib;${root}\WinSDK\Lib:$Env:LIBPATH"
42 $Env:LIBPATH = "${root}\VC\lib;${root}\WinSDK\Lib:$Env:LIBPATH"
43 '''.lstrip()
43 '''.lstrip()
44
44
45 HG_PURGE = r'''
45 HG_PURGE = r'''
46 $Env:PATH = "C:\hgdev\venv-bootstrap\Scripts;$Env:PATH"
46 $Env:PATH = "C:\hgdev\venv-bootstrap\Scripts;$Env:PATH"
47 Set-Location C:\hgdev\src
47 Set-Location C:\hgdev\src
48 hg.exe --config extensions.purge= purge --all
48 hg.exe --config extensions.purge= purge --all
49 if ($LASTEXITCODE -ne 0) {
49 if ($LASTEXITCODE -ne 0) {
50 throw "process exited non-0: $LASTEXITCODE"
50 throw "process exited non-0: $LASTEXITCODE"
51 }
51 }
52 Write-Output "purged Mercurial repo"
52 Write-Output "purged Mercurial repo"
53 '''
53 '''
54
54
55 HG_UPDATE_CLEAN = r'''
55 HG_UPDATE_CLEAN = r'''
56 $Env:PATH = "C:\hgdev\venv-bootstrap\Scripts;$Env:PATH"
56 $Env:PATH = "C:\hgdev\venv-bootstrap\Scripts;$Env:PATH"
57 Set-Location C:\hgdev\src
57 Set-Location C:\hgdev\src
58 hg.exe --config extensions.purge= purge --all
58 hg.exe --config extensions.purge= purge --all
59 if ($LASTEXITCODE -ne 0) {{
59 if ($LASTEXITCODE -ne 0) {{
60 throw "process exited non-0: $LASTEXITCODE"
60 throw "process exited non-0: $LASTEXITCODE"
61 }}
61 }}
62 hg.exe update -C {revision}
62 hg.exe update -C {revision}
63 if ($LASTEXITCODE -ne 0) {{
63 if ($LASTEXITCODE -ne 0) {{
64 throw "process exited non-0: $LASTEXITCODE"
64 throw "process exited non-0: $LASTEXITCODE"
65 }}
65 }}
66 hg.exe log -r .
66 hg.exe log -r .
67 Write-Output "updated Mercurial working directory to {revision}"
67 Write-Output "updated Mercurial working directory to {revision}"
68 '''.lstrip()
68 '''.lstrip()
69
69
70 BUILD_INNO = r'''
70 BUILD_INNO = r'''
71 Set-Location C:\hgdev\src
71 Set-Location C:\hgdev\src
72 $python = "C:\hgdev\python27-{arch}\python.exe"
72 $python = "C:\hgdev\python27-{arch}\python.exe"
73 C:\hgdev\python37-x64\python.exe contrib\packaging\inno\build.py --python $python
73 C:\hgdev\python37-x64\python.exe contrib\packaging\inno\build.py --python $python
74 if ($LASTEXITCODE -ne 0) {{
74 if ($LASTEXITCODE -ne 0) {{
75 throw "process exited non-0: $LASTEXITCODE"
75 throw "process exited non-0: $LASTEXITCODE"
76 }}
76 }}
77 '''.lstrip()
77 '''.lstrip()
78
78
79 BUILD_WHEEL = r'''
79 BUILD_WHEEL = r'''
80 Set-Location C:\hgdev\src
80 Set-Location C:\hgdev\src
81 C:\hgdev\python27-{arch}\Scripts\pip.exe wheel --wheel-dir dist .
81 C:\hgdev\python27-{arch}\Scripts\pip.exe wheel --wheel-dir dist .
82 if ($LASTEXITCODE -ne 0) {{
82 if ($LASTEXITCODE -ne 0) {{
83 throw "process exited non-0: $LASTEXITCODE"
83 throw "process exited non-0: $LASTEXITCODE"
84 }}
84 }}
85 '''
85 '''
86
86
87 BUILD_WIX = r'''
87 BUILD_WIX = r'''
88 Set-Location C:\hgdev\src
88 Set-Location C:\hgdev\src
89 $python = "C:\hgdev\python27-{arch}\python.exe"
89 $python = "C:\hgdev\python27-{arch}\python.exe"
90 C:\hgdev\python37-x64\python.exe contrib\packaging\wix\build.py --python $python {extra_args}
90 C:\hgdev\python37-x64\python.exe contrib\packaging\wix\build.py --python $python {extra_args}
91 if ($LASTEXITCODE -ne 0) {{
91 if ($LASTEXITCODE -ne 0) {{
92 throw "process exited non-0: $LASTEXITCODE"
92 throw "process exited non-0: $LASTEXITCODE"
93 }}
93 }}
94 '''
94 '''
95
95
96 RUN_TESTS = r'''
96 RUN_TESTS = r'''
97 C:\hgdev\MinGW\msys\1.0\bin\sh.exe --login -c "cd /c/hgdev/src/tests && /c/hgdev/{python_path}/python.exe run-tests.py {test_flags}"
97 C:\hgdev\MinGW\msys\1.0\bin\sh.exe --login -c "cd /c/hgdev/src/tests && /c/hgdev/{python_path}/python.exe run-tests.py {test_flags}"
98 if ($LASTEXITCODE -ne 0) {{
98 if ($LASTEXITCODE -ne 0) {{
99 throw "process exited non-0: $LASTEXITCODE"
99 throw "process exited non-0: $LASTEXITCODE"
100 }}
100 }}
101 '''
101 '''
102
102
103
103
104 def get_vc_prefix(arch):
104 def get_vc_prefix(arch):
105 if arch == 'x86':
105 if arch == 'x86':
106 return ACTIVATE_VC9_X86
106 return ACTIVATE_VC9_X86
107 elif arch == 'x64':
107 elif arch == 'x64':
108 return ACTIVATE_VC9_AMD64
108 return ACTIVATE_VC9_AMD64
109 else:
109 else:
110 raise ValueError('illegal arch: %s; must be x86 or x64' % arch)
110 raise ValueError('illegal arch: %s; must be x86 or x64' % arch)
111
111
112
112
113 def fix_authorized_keys_permissions(winrm_client, path):
113 def fix_authorized_keys_permissions(winrm_client, path):
114 commands = [
114 commands = [
115 '$ErrorActionPreference = "Stop"',
115 '$ErrorActionPreference = "Stop"',
116 'Repair-AuthorizedKeyPermission -FilePath %s -Confirm:$false' % path,
116 'Repair-AuthorizedKeyPermission -FilePath %s -Confirm:$false' % path,
117 r'icacls %s /remove:g "NT Service\sshd"' % path,
117 r'icacls %s /remove:g "NT Service\sshd"' % path,
118 ]
118 ]
119
119
120 run_powershell(winrm_client, '\n'.join(commands))
120 run_powershell(winrm_client, '\n'.join(commands))
121
121
122
122
123 def synchronize_hg(hg_repo: pathlib.Path, revision: str, ec2_instance):
123 def synchronize_hg(hg_repo: pathlib.Path, revision: str, ec2_instance):
124 """Synchronize local Mercurial repo to remote EC2 instance."""
124 """Synchronize local Mercurial repo to remote EC2 instance."""
125
125
126 winrm_client = ec2_instance.winrm_client
126 winrm_client = ec2_instance.winrm_client
127
127
128 with tempfile.TemporaryDirectory() as temp_dir:
128 with tempfile.TemporaryDirectory() as temp_dir:
129 temp_dir = pathlib.Path(temp_dir)
129 temp_dir = pathlib.Path(temp_dir)
130
130
131 ssh_dir = temp_dir / '.ssh'
131 ssh_dir = temp_dir / '.ssh'
132 ssh_dir.mkdir()
132 ssh_dir.mkdir()
133 ssh_dir.chmod(0o0700)
133 ssh_dir.chmod(0o0700)
134
134
135 # Generate SSH key to use for communication.
135 # Generate SSH key to use for communication.
136 subprocess.run([
136 subprocess.run([
137 'ssh-keygen', '-t', 'rsa', '-b', '4096', '-N', '',
137 'ssh-keygen', '-t', 'rsa', '-b', '4096', '-N', '',
138 '-f', str(ssh_dir / 'id_rsa')],
138 '-f', str(ssh_dir / 'id_rsa')],
139 check=True, capture_output=True)
139 check=True, capture_output=True)
140
140
141 # Add it to ~/.ssh/authorized_keys on remote.
141 # Add it to ~/.ssh/authorized_keys on remote.
142 # This assumes the file doesn't already exist.
142 # This assumes the file doesn't already exist.
143 authorized_keys = r'c:\Users\Administrator\.ssh\authorized_keys'
143 authorized_keys = r'c:\Users\Administrator\.ssh\authorized_keys'
144 winrm_client.execute_cmd(r'mkdir c:\Users\Administrator\.ssh')
144 winrm_client.execute_cmd(r'mkdir c:\Users\Administrator\.ssh')
145 winrm_client.copy(str(ssh_dir / 'id_rsa.pub'), authorized_keys)
145 winrm_client.copy(str(ssh_dir / 'id_rsa.pub'), authorized_keys)
146 fix_authorized_keys_permissions(winrm_client, authorized_keys)
146 fix_authorized_keys_permissions(winrm_client, authorized_keys)
147
147
148 public_ip = ec2_instance.public_ip_address
148 public_ip = ec2_instance.public_ip_address
149
149
150 ssh_config = temp_dir / '.ssh' / 'config'
150 ssh_config = temp_dir / '.ssh' / 'config'
151
151
152 with open(ssh_config, 'w', encoding='utf-8') as fh:
152 with open(ssh_config, 'w', encoding='utf-8') as fh:
153 fh.write('Host %s\n' % public_ip)
153 fh.write('Host %s\n' % public_ip)
154 fh.write(' User Administrator\n')
154 fh.write(' User Administrator\n')
155 fh.write(' StrictHostKeyChecking no\n')
155 fh.write(' StrictHostKeyChecking no\n')
156 fh.write(' UserKnownHostsFile %s\n' % (ssh_dir / 'known_hosts'))
156 fh.write(' UserKnownHostsFile %s\n' % (ssh_dir / 'known_hosts'))
157 fh.write(' IdentityFile %s\n' % (ssh_dir / 'id_rsa'))
157 fh.write(' IdentityFile %s\n' % (ssh_dir / 'id_rsa'))
158
158
159 if not (hg_repo / '.hg').is_dir():
160 raise Exception('%s is not a Mercurial repository; '
161 'synchronization not yet supported' % hg_repo)
162
159 env = dict(os.environ)
163 env = dict(os.environ)
160 env['HGPLAIN'] = '1'
164 env['HGPLAIN'] = '1'
161 env['HGENCODING'] = 'utf-8'
165 env['HGENCODING'] = 'utf-8'
162
166
163 hg_bin = hg_repo / 'hg'
167 hg_bin = hg_repo / 'hg'
164
168
165 res = subprocess.run(
169 res = subprocess.run(
166 ['python2.7', str(hg_bin), 'log', '-r', revision, '-T', '{node}'],
170 ['python2.7', str(hg_bin), 'log', '-r', revision, '-T', '{node}'],
167 cwd=str(hg_repo), env=env, check=True, capture_output=True)
171 cwd=str(hg_repo), env=env, check=True, capture_output=True)
168
172
169 full_revision = res.stdout.decode('ascii')
173 full_revision = res.stdout.decode('ascii')
170
174
171 args = [
175 args = [
172 'python2.7', hg_bin,
176 'python2.7', hg_bin,
173 '--config', 'ui.ssh=ssh -F %s' % ssh_config,
177 '--config', 'ui.ssh=ssh -F %s' % ssh_config,
174 '--config', 'ui.remotecmd=c:/hgdev/venv-bootstrap/Scripts/hg.exe',
178 '--config', 'ui.remotecmd=c:/hgdev/venv-bootstrap/Scripts/hg.exe',
175 'push', '-r', full_revision, 'ssh://%s/c:/hgdev/src' % public_ip,
179 'push', '-r', full_revision, 'ssh://%s/c:/hgdev/src' % public_ip,
176 ]
180 ]
177
181
178 subprocess.run(args, cwd=str(hg_repo), env=env, check=True)
182 subprocess.run(args, cwd=str(hg_repo), env=env, check=True)
179
183
180 run_powershell(winrm_client,
184 run_powershell(winrm_client,
181 HG_UPDATE_CLEAN.format(revision=full_revision))
185 HG_UPDATE_CLEAN.format(revision=full_revision))
182
186
183 # TODO detect dirty local working directory and synchronize accordingly.
187 # TODO detect dirty local working directory and synchronize accordingly.
184
188
185
189
186 def purge_hg(winrm_client):
190 def purge_hg(winrm_client):
187 """Purge the Mercurial source repository on an EC2 instance."""
191 """Purge the Mercurial source repository on an EC2 instance."""
188 run_powershell(winrm_client, HG_PURGE)
192 run_powershell(winrm_client, HG_PURGE)
189
193
190
194
191 def find_latest_dist(winrm_client, pattern):
195 def find_latest_dist(winrm_client, pattern):
192 """Find path to newest file in dist/ directory matching a pattern."""
196 """Find path to newest file in dist/ directory matching a pattern."""
193
197
194 res = winrm_client.execute_ps(
198 res = winrm_client.execute_ps(
195 r'$v = Get-ChildItem -Path C:\hgdev\src\dist -Filter "%s" '
199 r'$v = Get-ChildItem -Path C:\hgdev\src\dist -Filter "%s" '
196 '| Sort-Object LastWriteTime -Descending '
200 '| Sort-Object LastWriteTime -Descending '
197 '| Select-Object -First 1\n'
201 '| Select-Object -First 1\n'
198 '$v.name' % pattern
202 '$v.name' % pattern
199 )
203 )
200 return res[0]
204 return res[0]
201
205
202
206
203 def copy_latest_dist(winrm_client, pattern, dest_path):
207 def copy_latest_dist(winrm_client, pattern, dest_path):
204 """Copy latest file matching pattern in dist/ directory.
208 """Copy latest file matching pattern in dist/ directory.
205
209
206 Given a WinRM client and a file pattern, find the latest file on the remote
210 Given a WinRM client and a file pattern, find the latest file on the remote
207 matching that pattern and copy it to the ``dest_path`` directory on the
211 matching that pattern and copy it to the ``dest_path`` directory on the
208 local machine.
212 local machine.
209 """
213 """
210 latest = find_latest_dist(winrm_client, pattern)
214 latest = find_latest_dist(winrm_client, pattern)
211 source = r'C:\hgdev\src\dist\%s' % latest
215 source = r'C:\hgdev\src\dist\%s' % latest
212 dest = dest_path / latest
216 dest = dest_path / latest
213 print('copying %s to %s' % (source, dest))
217 print('copying %s to %s' % (source, dest))
214 winrm_client.fetch(source, str(dest))
218 winrm_client.fetch(source, str(dest))
215
219
216
220
217 def build_inno_installer(winrm_client, arch: str, dest_path: pathlib.Path,
221 def build_inno_installer(winrm_client, arch: str, dest_path: pathlib.Path,
218 version=None):
222 version=None):
219 """Build the Inno Setup installer on a remote machine.
223 """Build the Inno Setup installer on a remote machine.
220
224
221 Using a WinRM client, remote commands are executed to build
225 Using a WinRM client, remote commands are executed to build
222 a Mercurial Inno Setup installer.
226 a Mercurial Inno Setup installer.
223 """
227 """
224 print('building Inno Setup installer for %s' % arch)
228 print('building Inno Setup installer for %s' % arch)
225
229
226 extra_args = []
230 extra_args = []
227 if version:
231 if version:
228 extra_args.extend(['--version', version])
232 extra_args.extend(['--version', version])
229
233
230 ps = get_vc_prefix(arch) + BUILD_INNO.format(arch=arch,
234 ps = get_vc_prefix(arch) + BUILD_INNO.format(arch=arch,
231 extra_args=' '.join(extra_args))
235 extra_args=' '.join(extra_args))
232 run_powershell(winrm_client, ps)
236 run_powershell(winrm_client, ps)
233 copy_latest_dist(winrm_client, '*.exe', dest_path)
237 copy_latest_dist(winrm_client, '*.exe', dest_path)
234
238
235
239
236 def build_wheel(winrm_client, arch: str, dest_path: pathlib.Path):
240 def build_wheel(winrm_client, arch: str, dest_path: pathlib.Path):
237 """Build Python wheels on a remote machine.
241 """Build Python wheels on a remote machine.
238
242
239 Using a WinRM client, remote commands are executed to build a Python wheel
243 Using a WinRM client, remote commands are executed to build a Python wheel
240 for Mercurial.
244 for Mercurial.
241 """
245 """
242 print('Building Windows wheel for %s' % arch)
246 print('Building Windows wheel for %s' % arch)
243 ps = get_vc_prefix(arch) + BUILD_WHEEL.format(arch=arch)
247 ps = get_vc_prefix(arch) + BUILD_WHEEL.format(arch=arch)
244 run_powershell(winrm_client, ps)
248 run_powershell(winrm_client, ps)
245 copy_latest_dist(winrm_client, '*.whl', dest_path)
249 copy_latest_dist(winrm_client, '*.whl', dest_path)
246
250
247
251
248 def build_wix_installer(winrm_client, arch: str, dest_path: pathlib.Path,
252 def build_wix_installer(winrm_client, arch: str, dest_path: pathlib.Path,
249 version=None):
253 version=None):
250 """Build the WiX installer on a remote machine.
254 """Build the WiX installer on a remote machine.
251
255
252 Using a WinRM client, remote commands are executed to build a WiX installer.
256 Using a WinRM client, remote commands are executed to build a WiX installer.
253 """
257 """
254 print('Building WiX installer for %s' % arch)
258 print('Building WiX installer for %s' % arch)
255 extra_args = []
259 extra_args = []
256 if version:
260 if version:
257 extra_args.extend(['--version', version])
261 extra_args.extend(['--version', version])
258
262
259 ps = get_vc_prefix(arch) + BUILD_WIX.format(arch=arch,
263 ps = get_vc_prefix(arch) + BUILD_WIX.format(arch=arch,
260 extra_args=' '.join(extra_args))
264 extra_args=' '.join(extra_args))
261 run_powershell(winrm_client, ps)
265 run_powershell(winrm_client, ps)
262 copy_latest_dist(winrm_client, '*.msi', dest_path)
266 copy_latest_dist(winrm_client, '*.msi', dest_path)
263
267
264
268
265 def run_tests(winrm_client, python_version, arch, test_flags=''):
269 def run_tests(winrm_client, python_version, arch, test_flags=''):
266 """Run tests on a remote Windows machine.
270 """Run tests on a remote Windows machine.
267
271
268 ``python_version`` is a ``X.Y`` string like ``2.7`` or ``3.7``.
272 ``python_version`` is a ``X.Y`` string like ``2.7`` or ``3.7``.
269 ``arch`` is ``x86`` or ``x64``.
273 ``arch`` is ``x86`` or ``x64``.
270 ``test_flags`` is a str representing extra arguments to pass to
274 ``test_flags`` is a str representing extra arguments to pass to
271 ``run-tests.py``.
275 ``run-tests.py``.
272 """
276 """
273 if not re.match(r'\d\.\d', python_version):
277 if not re.match(r'\d\.\d', python_version):
274 raise ValueError(r'python_version must be \d.\d; got %s' %
278 raise ValueError(r'python_version must be \d.\d; got %s' %
275 python_version)
279 python_version)
276
280
277 if arch not in ('x86', 'x64'):
281 if arch not in ('x86', 'x64'):
278 raise ValueError('arch must be x86 or x64; got %s' % arch)
282 raise ValueError('arch must be x86 or x64; got %s' % arch)
279
283
280 python_path = 'python%s-%s' % (python_version.replace('.', ''), arch)
284 python_path = 'python%s-%s' % (python_version.replace('.', ''), arch)
281
285
282 ps = RUN_TESTS.format(
286 ps = RUN_TESTS.format(
283 python_path=python_path,
287 python_path=python_path,
284 test_flags=test_flags or '',
288 test_flags=test_flags or '',
285 )
289 )
286
290
287 run_powershell(winrm_client, ps)
291 run_powershell(winrm_client, ps)
General Comments 0
You need to be logged in to leave comments. Login now