##// END OF EJS Templates
chgserver: truncate base address at "." for hash address...
Jun Wu -
r30619:88efb4fb default
parent child Browse files
Show More
@@ -1,633 +1,638
1 1 # chgserver.py - command server extension for cHg
2 2 #
3 3 # Copyright 2011 Yuya Nishihara <yuya@tcha.org>
4 4 #
5 5 # This software may be used and distributed according to the terms of the
6 6 # GNU General Public License version 2 or any later version.
7 7
8 8 """command server extension for cHg
9 9
10 10 'S' channel (read/write)
11 11 propagate ui.system() request to client
12 12
13 13 'attachio' command
14 14 attach client's stdio passed by sendmsg()
15 15
16 16 'chdir' command
17 17 change current directory
18 18
19 19 'getpager' command
20 20 checks if pager is enabled and which pager should be executed
21 21
22 22 'setenv' command
23 23 replace os.environ completely
24 24
25 25 'setumask' command
26 26 set umask
27 27
28 28 'validate' command
29 29 reload the config and check if the server is up to date
30 30
31 31 Config
32 32 ------
33 33
34 34 ::
35 35
36 36 [chgserver]
37 37 idletimeout = 3600 # seconds, after which an idle server will exit
38 38 skiphash = False # whether to skip config or env change checks
39 39 """
40 40
41 41 from __future__ import absolute_import
42 42
43 43 import errno
44 44 import hashlib
45 45 import inspect
46 46 import os
47 47 import re
48 48 import signal
49 49 import struct
50 50 import sys
51 51 import time
52 52
53 53 from .i18n import _
54 54
55 55 from . import (
56 56 cmdutil,
57 57 commandserver,
58 58 error,
59 59 extensions,
60 60 osutil,
61 61 util,
62 62 )
63 63
64 64 _log = commandserver.log
65 65
66 66 def _hashlist(items):
67 67 """return sha1 hexdigest for a list"""
68 68 return hashlib.sha1(str(items)).hexdigest()
69 69
70 70 # sensitive config sections affecting confighash
71 71 _configsections = [
72 72 'alias', # affects global state commands.table
73 73 'extdiff', # uisetup will register new commands
74 74 'extensions',
75 75 ]
76 76
77 77 # sensitive environment variables affecting confighash
78 78 _envre = re.compile(r'''\A(?:
79 79 CHGHG
80 80 |HG(?:[A-Z].*)?
81 81 |LANG(?:UAGE)?
82 82 |LC_.*
83 83 |LD_.*
84 84 |PATH
85 85 |PYTHON.*
86 86 |TERM(?:INFO)?
87 87 |TZ
88 88 )\Z''', re.X)
89 89
90 90 def _confighash(ui):
91 91 """return a quick hash for detecting config/env changes
92 92
93 93 confighash is the hash of sensitive config items and environment variables.
94 94
95 95 for chgserver, it is designed that once confighash changes, the server is
96 96 not qualified to serve its client and should redirect the client to a new
97 97 server. different from mtimehash, confighash change will not mark the
98 98 server outdated and exit since the user can have different configs at the
99 99 same time.
100 100 """
101 101 sectionitems = []
102 102 for section in _configsections:
103 103 sectionitems.append(ui.configitems(section))
104 104 sectionhash = _hashlist(sectionitems)
105 105 envitems = [(k, v) for k, v in os.environ.iteritems() if _envre.match(k)]
106 106 envhash = _hashlist(sorted(envitems))
107 107 return sectionhash[:6] + envhash[:6]
108 108
109 109 def _getmtimepaths(ui):
110 110 """get a list of paths that should be checked to detect change
111 111
112 112 The list will include:
113 113 - extensions (will not cover all files for complex extensions)
114 114 - mercurial/__version__.py
115 115 - python binary
116 116 """
117 117 modules = [m for n, m in extensions.extensions(ui)]
118 118 try:
119 119 from . import __version__
120 120 modules.append(__version__)
121 121 except ImportError:
122 122 pass
123 123 files = [sys.executable]
124 124 for m in modules:
125 125 try:
126 126 files.append(inspect.getabsfile(m))
127 127 except TypeError:
128 128 pass
129 129 return sorted(set(files))
130 130
131 131 def _mtimehash(paths):
132 132 """return a quick hash for detecting file changes
133 133
134 134 mtimehash calls stat on given paths and calculate a hash based on size and
135 135 mtime of each file. mtimehash does not read file content because reading is
136 136 expensive. therefore it's not 100% reliable for detecting content changes.
137 137 it's possible to return different hashes for same file contents.
138 138 it's also possible to return a same hash for different file contents for
139 139 some carefully crafted situation.
140 140
141 141 for chgserver, it is designed that once mtimehash changes, the server is
142 142 considered outdated immediately and should no longer provide service.
143 143
144 144 mtimehash is not included in confighash because we only know the paths of
145 145 extensions after importing them (there is imp.find_module but that faces
146 146 race conditions). We need to calculate confighash without importing.
147 147 """
148 148 def trystat(path):
149 149 try:
150 150 st = os.stat(path)
151 151 return (st.st_mtime, st.st_size)
152 152 except OSError:
153 153 # could be ENOENT, EPERM etc. not fatal in any case
154 154 pass
155 155 return _hashlist(map(trystat, paths))[:12]
156 156
157 157 class hashstate(object):
158 158 """a structure storing confighash, mtimehash, paths used for mtimehash"""
159 159 def __init__(self, confighash, mtimehash, mtimepaths):
160 160 self.confighash = confighash
161 161 self.mtimehash = mtimehash
162 162 self.mtimepaths = mtimepaths
163 163
164 164 @staticmethod
165 165 def fromui(ui, mtimepaths=None):
166 166 if mtimepaths is None:
167 167 mtimepaths = _getmtimepaths(ui)
168 168 confighash = _confighash(ui)
169 169 mtimehash = _mtimehash(mtimepaths)
170 170 _log('confighash = %s mtimehash = %s\n' % (confighash, mtimehash))
171 171 return hashstate(confighash, mtimehash, mtimepaths)
172 172
173 173 # copied from hgext/pager.py:uisetup()
174 174 def _setuppagercmd(ui, options, cmd):
175 175 from . import commands # avoid cycle
176 176
177 177 if not ui.formatted():
178 178 return
179 179
180 180 p = ui.config("pager", "pager", os.environ.get("PAGER"))
181 181 usepager = False
182 182 always = util.parsebool(options['pager'])
183 183 auto = options['pager'] == 'auto'
184 184
185 185 if not p:
186 186 pass
187 187 elif always:
188 188 usepager = True
189 189 elif not auto:
190 190 usepager = False
191 191 else:
192 192 attended = ['annotate', 'cat', 'diff', 'export', 'glog', 'log', 'qdiff']
193 193 attend = ui.configlist('pager', 'attend', attended)
194 194 ignore = ui.configlist('pager', 'ignore')
195 195 cmds, _ = cmdutil.findcmd(cmd, commands.table)
196 196
197 197 for cmd in cmds:
198 198 var = 'attend-%s' % cmd
199 199 if ui.config('pager', var):
200 200 usepager = ui.configbool('pager', var)
201 201 break
202 202 if (cmd in attend or
203 203 (cmd not in ignore and not attend)):
204 204 usepager = True
205 205 break
206 206
207 207 if usepager:
208 208 ui.setconfig('ui', 'formatted', ui.formatted(), 'pager')
209 209 ui.setconfig('ui', 'interactive', False, 'pager')
210 210 return p
211 211
212 212 def _newchgui(srcui, csystem):
213 213 class chgui(srcui.__class__):
214 214 def __init__(self, src=None):
215 215 super(chgui, self).__init__(src)
216 216 if src:
217 217 self._csystem = getattr(src, '_csystem', csystem)
218 218 else:
219 219 self._csystem = csystem
220 220
221 221 def system(self, cmd, environ=None, cwd=None, onerr=None,
222 222 errprefix=None):
223 223 # fallback to the original system method if the output needs to be
224 224 # captured (to self._buffers), or the output stream is not stdout
225 225 # (e.g. stderr, cStringIO), because the chg client is not aware of
226 226 # these situations and will behave differently (write to stdout).
227 227 if (any(s[1] for s in self._bufferstates)
228 228 or not util.safehasattr(self.fout, 'fileno')
229 229 or self.fout.fileno() != util.stdout.fileno()):
230 230 return super(chgui, self).system(cmd, environ, cwd, onerr,
231 231 errprefix)
232 232 # copied from mercurial/util.py:system()
233 233 self.flush()
234 234 def py2shell(val):
235 235 if val is None or val is False:
236 236 return '0'
237 237 if val is True:
238 238 return '1'
239 239 return str(val)
240 240 env = os.environ.copy()
241 241 if environ:
242 242 env.update((k, py2shell(v)) for k, v in environ.iteritems())
243 243 env['HG'] = util.hgexecutable()
244 244 rc = self._csystem(cmd, env, cwd)
245 245 if rc and onerr:
246 246 errmsg = '%s %s' % (os.path.basename(cmd.split(None, 1)[0]),
247 247 util.explainexit(rc)[0])
248 248 if errprefix:
249 249 errmsg = '%s: %s' % (errprefix, errmsg)
250 250 raise onerr(errmsg)
251 251 return rc
252 252
253 253 return chgui(srcui)
254 254
255 255 def _loadnewui(srcui, args):
256 256 from . import dispatch # avoid cycle
257 257
258 258 newui = srcui.__class__.load()
259 259 for a in ['fin', 'fout', 'ferr', 'environ']:
260 260 setattr(newui, a, getattr(srcui, a))
261 261 if util.safehasattr(srcui, '_csystem'):
262 262 newui._csystem = srcui._csystem
263 263
264 264 # command line args
265 265 args = args[:]
266 266 dispatch._parseconfig(newui, dispatch._earlygetopt(['--config'], args))
267 267
268 268 # stolen from tortoisehg.util.copydynamicconfig()
269 269 for section, name, value in srcui.walkconfig():
270 270 source = srcui.configsource(section, name)
271 271 if ':' in source or source == '--config':
272 272 # path:line or command line
273 273 continue
274 274 newui.setconfig(section, name, value, source)
275 275
276 276 # load wd and repo config, copied from dispatch.py
277 277 cwds = dispatch._earlygetopt(['--cwd'], args)
278 278 cwd = cwds and os.path.realpath(cwds[-1]) or None
279 279 rpath = dispatch._earlygetopt(["-R", "--repository", "--repo"], args)
280 280 path, newlui = dispatch._getlocal(newui, rpath, wd=cwd)
281 281
282 282 return (newui, newlui)
283 283
284 284 class channeledsystem(object):
285 285 """Propagate ui.system() request in the following format:
286 286
287 287 payload length (unsigned int),
288 288 cmd, '\0',
289 289 cwd, '\0',
290 290 envkey, '=', val, '\0',
291 291 ...
292 292 envkey, '=', val
293 293
294 294 and waits:
295 295
296 296 exitcode length (unsigned int),
297 297 exitcode (int)
298 298 """
299 299 def __init__(self, in_, out, channel):
300 300 self.in_ = in_
301 301 self.out = out
302 302 self.channel = channel
303 303
304 304 def __call__(self, cmd, environ, cwd):
305 305 args = [util.quotecommand(cmd), os.path.abspath(cwd or '.')]
306 306 args.extend('%s=%s' % (k, v) for k, v in environ.iteritems())
307 307 data = '\0'.join(args)
308 308 self.out.write(struct.pack('>cI', self.channel, len(data)))
309 309 self.out.write(data)
310 310 self.out.flush()
311 311
312 312 length = self.in_.read(4)
313 313 length, = struct.unpack('>I', length)
314 314 if length != 4:
315 315 raise error.Abort(_('invalid response'))
316 316 rc, = struct.unpack('>i', self.in_.read(4))
317 317 return rc
318 318
319 319 _iochannels = [
320 320 # server.ch, ui.fp, mode
321 321 ('cin', 'fin', 'rb'),
322 322 ('cout', 'fout', 'wb'),
323 323 ('cerr', 'ferr', 'wb'),
324 324 ]
325 325
326 326 class chgcmdserver(commandserver.server):
327 327 def __init__(self, ui, repo, fin, fout, sock, hashstate, baseaddress):
328 328 super(chgcmdserver, self).__init__(
329 329 _newchgui(ui, channeledsystem(fin, fout, 'S')), repo, fin, fout)
330 330 self.clientsock = sock
331 331 self._oldios = [] # original (self.ch, ui.fp, fd) before "attachio"
332 332 self.hashstate = hashstate
333 333 self.baseaddress = baseaddress
334 334 if hashstate is not None:
335 335 self.capabilities = self.capabilities.copy()
336 336 self.capabilities['validate'] = chgcmdserver.validate
337 337
338 338 def cleanup(self):
339 339 super(chgcmdserver, self).cleanup()
340 340 # dispatch._runcatch() does not flush outputs if exception is not
341 341 # handled by dispatch._dispatch()
342 342 self.ui.flush()
343 343 self._restoreio()
344 344
345 345 def attachio(self):
346 346 """Attach to client's stdio passed via unix domain socket; all
347 347 channels except cresult will no longer be used
348 348 """
349 349 # tell client to sendmsg() with 1-byte payload, which makes it
350 350 # distinctive from "attachio\n" command consumed by client.read()
351 351 self.clientsock.sendall(struct.pack('>cI', 'I', 1))
352 352 clientfds = osutil.recvfds(self.clientsock.fileno())
353 353 _log('received fds: %r\n' % clientfds)
354 354
355 355 ui = self.ui
356 356 ui.flush()
357 357 first = self._saveio()
358 358 for fd, (cn, fn, mode) in zip(clientfds, _iochannels):
359 359 assert fd > 0
360 360 fp = getattr(ui, fn)
361 361 os.dup2(fd, fp.fileno())
362 362 os.close(fd)
363 363 if not first:
364 364 continue
365 365 # reset buffering mode when client is first attached. as we want
366 366 # to see output immediately on pager, the mode stays unchanged
367 367 # when client re-attached. ferr is unchanged because it should
368 368 # be unbuffered no matter if it is a tty or not.
369 369 if fn == 'ferr':
370 370 newfp = fp
371 371 else:
372 372 # make it line buffered explicitly because the default is
373 373 # decided on first write(), where fout could be a pager.
374 374 if fp.isatty():
375 375 bufsize = 1 # line buffered
376 376 else:
377 377 bufsize = -1 # system default
378 378 newfp = os.fdopen(fp.fileno(), mode, bufsize)
379 379 setattr(ui, fn, newfp)
380 380 setattr(self, cn, newfp)
381 381
382 382 self.cresult.write(struct.pack('>i', len(clientfds)))
383 383
384 384 def _saveio(self):
385 385 if self._oldios:
386 386 return False
387 387 ui = self.ui
388 388 for cn, fn, _mode in _iochannels:
389 389 ch = getattr(self, cn)
390 390 fp = getattr(ui, fn)
391 391 fd = os.dup(fp.fileno())
392 392 self._oldios.append((ch, fp, fd))
393 393 return True
394 394
395 395 def _restoreio(self):
396 396 ui = self.ui
397 397 for (ch, fp, fd), (cn, fn, _mode) in zip(self._oldios, _iochannels):
398 398 newfp = getattr(ui, fn)
399 399 # close newfp while it's associated with client; otherwise it
400 400 # would be closed when newfp is deleted
401 401 if newfp is not fp:
402 402 newfp.close()
403 403 # restore original fd: fp is open again
404 404 os.dup2(fd, fp.fileno())
405 405 os.close(fd)
406 406 setattr(self, cn, ch)
407 407 setattr(ui, fn, fp)
408 408 del self._oldios[:]
409 409
410 410 def validate(self):
411 411 """Reload the config and check if the server is up to date
412 412
413 413 Read a list of '\0' separated arguments.
414 414 Write a non-empty list of '\0' separated instruction strings or '\0'
415 415 if the list is empty.
416 416 An instruction string could be either:
417 417 - "unlink $path", the client should unlink the path to stop the
418 418 outdated server.
419 419 - "redirect $path", the client should attempt to connect to $path
420 420 first. If it does not work, start a new server. It implies
421 421 "reconnect".
422 422 - "exit $n", the client should exit directly with code n.
423 423 This may happen if we cannot parse the config.
424 424 - "reconnect", the client should close the connection and
425 425 reconnect.
426 426 If neither "reconnect" nor "redirect" is included in the instruction
427 427 list, the client can continue with this server after completing all
428 428 the instructions.
429 429 """
430 430 from . import dispatch # avoid cycle
431 431
432 432 args = self._readlist()
433 433 try:
434 434 self.ui, lui = _loadnewui(self.ui, args)
435 435 except error.ParseError as inst:
436 436 dispatch._formatparse(self.ui.warn, inst)
437 437 self.ui.flush()
438 438 self.cresult.write('exit 255')
439 439 return
440 440 newhash = hashstate.fromui(lui, self.hashstate.mtimepaths)
441 441 insts = []
442 442 if newhash.mtimehash != self.hashstate.mtimehash:
443 443 addr = _hashaddress(self.baseaddress, self.hashstate.confighash)
444 444 insts.append('unlink %s' % addr)
445 445 # mtimehash is empty if one or more extensions fail to load.
446 446 # to be compatible with hg, still serve the client this time.
447 447 if self.hashstate.mtimehash:
448 448 insts.append('reconnect')
449 449 if newhash.confighash != self.hashstate.confighash:
450 450 addr = _hashaddress(self.baseaddress, newhash.confighash)
451 451 insts.append('redirect %s' % addr)
452 452 _log('validate: %s\n' % insts)
453 453 self.cresult.write('\0'.join(insts) or '\0')
454 454
455 455 def chdir(self):
456 456 """Change current directory
457 457
458 458 Note that the behavior of --cwd option is bit different from this.
459 459 It does not affect --config parameter.
460 460 """
461 461 path = self._readstr()
462 462 if not path:
463 463 return
464 464 _log('chdir to %r\n' % path)
465 465 os.chdir(path)
466 466
467 467 def setumask(self):
468 468 """Change umask"""
469 469 mask = struct.unpack('>I', self._read(4))[0]
470 470 _log('setumask %r\n' % mask)
471 471 os.umask(mask)
472 472
473 473 def getpager(self):
474 474 """Read cmdargs and write pager command to r-channel if enabled
475 475
476 476 If pager isn't enabled, this writes '\0' because channeledoutput
477 477 does not allow to write empty data.
478 478 """
479 479 from . import dispatch # avoid cycle
480 480
481 481 args = self._readlist()
482 482 try:
483 483 cmd, _func, args, options, _cmdoptions = dispatch._parse(self.ui,
484 484 args)
485 485 except (error.Abort, error.AmbiguousCommand, error.CommandError,
486 486 error.UnknownCommand):
487 487 cmd = None
488 488 options = {}
489 489 if not cmd or 'pager' not in options:
490 490 self.cresult.write('\0')
491 491 return
492 492
493 493 pagercmd = _setuppagercmd(self.ui, options, cmd)
494 494 if pagercmd:
495 495 # Python's SIGPIPE is SIG_IGN by default. change to SIG_DFL so
496 496 # we can exit if the pipe to the pager is closed
497 497 if util.safehasattr(signal, 'SIGPIPE') and \
498 498 signal.getsignal(signal.SIGPIPE) == signal.SIG_IGN:
499 499 signal.signal(signal.SIGPIPE, signal.SIG_DFL)
500 500 self.cresult.write(pagercmd)
501 501 else:
502 502 self.cresult.write('\0')
503 503
504 504 def setenv(self):
505 505 """Clear and update os.environ
506 506
507 507 Note that not all variables can make an effect on the running process.
508 508 """
509 509 l = self._readlist()
510 510 try:
511 511 newenv = dict(s.split('=', 1) for s in l)
512 512 except ValueError:
513 513 raise ValueError('unexpected value in setenv request')
514 514 _log('setenv: %r\n' % sorted(newenv.keys()))
515 515 os.environ.clear()
516 516 os.environ.update(newenv)
517 517
518 518 capabilities = commandserver.server.capabilities.copy()
519 519 capabilities.update({'attachio': attachio,
520 520 'chdir': chdir,
521 521 'getpager': getpager,
522 522 'setenv': setenv,
523 523 'setumask': setumask})
524 524
525 525 def _tempaddress(address):
526 526 return '%s.%d.tmp' % (address, os.getpid())
527 527
528 528 def _hashaddress(address, hashstr):
529 return '%s-%s' % (address, hashstr)
529 # if the basename of address contains '.', use only the left part. this
530 # makes it possible for the client to pass 'server.tmp$PID' and follow by
531 # an atomic rename to avoid locking when spawning new servers.
532 dirname, basename = os.path.split(address)
533 basename = basename.split('.', 1)[0]
534 return '%s-%s' % (os.path.join(dirname, basename), hashstr)
530 535
531 536 class chgunixservicehandler(object):
532 537 """Set of operations for chg services"""
533 538
534 539 pollinterval = 1 # [sec]
535 540
536 541 def __init__(self, ui):
537 542 self.ui = ui
538 543 self._idletimeout = ui.configint('chgserver', 'idletimeout', 3600)
539 544 self._lastactive = time.time()
540 545
541 546 def bindsocket(self, sock, address):
542 547 self._inithashstate(address)
543 548 self._checkextensions()
544 549 self._bind(sock)
545 550 self._createsymlink()
546 551
547 552 def _inithashstate(self, address):
548 553 self._baseaddress = address
549 554 if self.ui.configbool('chgserver', 'skiphash', False):
550 555 self._hashstate = None
551 556 self._realaddress = address
552 557 return
553 558 self._hashstate = hashstate.fromui(self.ui)
554 559 self._realaddress = _hashaddress(address, self._hashstate.confighash)
555 560
556 561 def _checkextensions(self):
557 562 if not self._hashstate:
558 563 return
559 564 if extensions.notloaded():
560 565 # one or more extensions failed to load. mtimehash becomes
561 566 # meaningless because we do not know the paths of those extensions.
562 567 # set mtimehash to an illegal hash value to invalidate the server.
563 568 self._hashstate.mtimehash = ''
564 569
565 570 def _bind(self, sock):
566 571 # use a unique temp address so we can stat the file and do ownership
567 572 # check later
568 573 tempaddress = _tempaddress(self._realaddress)
569 574 util.bindunixsocket(sock, tempaddress)
570 575 self._socketstat = os.stat(tempaddress)
571 576 # rename will replace the old socket file if exists atomically. the
572 577 # old server will detect ownership change and exit.
573 578 util.rename(tempaddress, self._realaddress)
574 579
575 580 def _createsymlink(self):
576 581 if self._baseaddress == self._realaddress:
577 582 return
578 583 tempaddress = _tempaddress(self._baseaddress)
579 584 os.symlink(os.path.basename(self._realaddress), tempaddress)
580 585 util.rename(tempaddress, self._baseaddress)
581 586
582 587 def _issocketowner(self):
583 588 try:
584 589 stat = os.stat(self._realaddress)
585 590 return (stat.st_ino == self._socketstat.st_ino and
586 591 stat.st_mtime == self._socketstat.st_mtime)
587 592 except OSError:
588 593 return False
589 594
590 595 def unlinksocket(self, address):
591 596 if not self._issocketowner():
592 597 return
593 598 # it is possible to have a race condition here that we may
594 599 # remove another server's socket file. but that's okay
595 600 # since that server will detect and exit automatically and
596 601 # the client will start a new server on demand.
597 602 try:
598 603 os.unlink(self._realaddress)
599 604 except OSError as exc:
600 605 if exc.errno != errno.ENOENT:
601 606 raise
602 607
603 608 def printbanner(self, address):
604 609 # no "listening at" message should be printed to simulate hg behavior
605 610 pass
606 611
607 612 def shouldexit(self):
608 613 if not self._issocketowner():
609 614 self.ui.debug('%s is not owned, exiting.\n' % self._realaddress)
610 615 return True
611 616 if time.time() - self._lastactive > self._idletimeout:
612 617 self.ui.debug('being idle too long. exiting.\n')
613 618 return True
614 619 return False
615 620
616 621 def newconnection(self):
617 622 self._lastactive = time.time()
618 623
619 624 def createcmdserver(self, repo, conn, fin, fout):
620 625 return chgcmdserver(self.ui, repo, fin, fout, conn,
621 626 self._hashstate, self._baseaddress)
622 627
623 628 def chgunixservice(ui, repo, opts):
624 629 # CHGINTERNALMARK is temporarily set by chg client to detect if chg will
625 630 # start another chg. drop it to avoid possible side effects.
626 631 if 'CHGINTERNALMARK' in os.environ:
627 632 del os.environ['CHGINTERNALMARK']
628 633
629 634 if repo:
630 635 # one chgserver can serve multiple repos. drop repo information
631 636 ui.setconfig('bundle', 'mainreporoot', '', 'repo')
632 637 h = chgunixservicehandler(ui)
633 638 return commandserver.unixforkingservice(ui, repo=None, opts=opts, handler=h)
General Comments 0
You need to be logged in to leave comments. Login now