comparison mercurial/wireprotoserver.py @ 37059:bbea991635d0

wireproto: service multiple command requests per HTTP request Now that our new frame-based protocol server can understand how to ingest multiple, possibly interleaved, command requests, let's hook it up to the HTTP server. The code on the HTTP side of things is still a bit hacky. We need a bit of work around error handling, content types, etc. But it's a start. Among the added tests, we demonstrate that a client can send frames for multiple commands iterleaved with each other and that a later issued command can respond before the first one has finished sending. This makes our multi-request model technically superior to the previous "batch" command. Differential Revision: https://phab.mercurial-scm.org/D2871
author Gregory Szorc <gregory.szorc@gmail.com>
date Mon, 19 Mar 2018 16:55:07 -0700
parents 2ec1fb9de638
children 884a0c1604ad
comparison
equal deleted inserted replaced
37058:c5e9c3b47366 37059:bbea991635d0
325 # We have a special endpoint to reflect the request back at the client. 325 # We have a special endpoint to reflect the request back at the client.
326 if command == b'debugreflect': 326 if command == b'debugreflect':
327 _processhttpv2reflectrequest(rctx.repo.ui, rctx.repo, req, res) 327 _processhttpv2reflectrequest(rctx.repo.ui, rctx.repo, req, res)
328 return 328 return
329 329
330 if command not in wireproto.commands: 330 # Extra commands that we handle that aren't really wire protocol
331 # commands. Think extra hard before making this hackery available to
332 # extension.
333 extracommands = {'multirequest'}
334
335 if command not in wireproto.commands and command not in extracommands:
331 res.status = b'404 Not Found' 336 res.status = b'404 Not Found'
332 res.headers[b'Content-Type'] = b'text/plain' 337 res.headers[b'Content-Type'] = b'text/plain'
333 res.setbodybytes(_('unknown wire protocol command: %s\n') % command) 338 res.setbodybytes(_('unknown wire protocol command: %s\n') % command)
334 return 339 return
335 340
336 repo = rctx.repo 341 repo = rctx.repo
337 ui = repo.ui 342 ui = repo.ui
338 343
339 proto = httpv2protocolhandler(req, ui) 344 proto = httpv2protocolhandler(req, ui)
340 345
341 if not wireproto.commands.commandavailable(command, proto): 346 if (not wireproto.commands.commandavailable(command, proto)
347 and command not in extracommands):
342 res.status = b'404 Not Found' 348 res.status = b'404 Not Found'
343 res.headers[b'Content-Type'] = b'text/plain' 349 res.headers[b'Content-Type'] = b'text/plain'
344 res.setbodybytes(_('invalid wire protocol command: %s') % command) 350 res.setbodybytes(_('invalid wire protocol command: %s') % command)
345 return 351 return
346 352
432 438
433 if action == 'wantframe': 439 if action == 'wantframe':
434 # Need more data before we can do anything. 440 # Need more data before we can do anything.
435 continue 441 continue
436 elif action == 'runcommand': 442 elif action == 'runcommand':
437 # We currently only support running a single command per 443 sentoutput = _httpv2runcommand(ui, repo, req, res, authedperm,
438 # HTTP request. 444 reqcommand, reactor, meta,
439 if seencommand: 445 issubsequent=seencommand)
440 # TODO define proper error mechanism. 446
441 res.status = b'200 OK' 447 if sentoutput:
442 res.headers[b'Content-Type'] = b'text/plain'
443 res.setbodybytes(_('support for multiple commands per request '
444 'not yet implemented'))
445 return 448 return
446 449
447 _httpv2runcommand(ui, repo, req, res, authedperm, reqcommand, 450 seencommand = True
448 reactor, meta)
449 451
450 elif action == 'error': 452 elif action == 'error':
451 # TODO define proper error mechanism. 453 # TODO define proper error mechanism.
452 res.status = b'200 OK' 454 res.status = b'200 OK'
453 res.headers[b'Content-Type'] = b'text/plain' 455 res.headers[b'Content-Type'] = b'text/plain'
469 else: 471 else:
470 raise error.ProgrammingError('unhandled action from frame processor: %s' 472 raise error.ProgrammingError('unhandled action from frame processor: %s'
471 % action) 473 % action)
472 474
473 def _httpv2runcommand(ui, repo, req, res, authedperm, reqcommand, reactor, 475 def _httpv2runcommand(ui, repo, req, res, authedperm, reqcommand, reactor,
474 command): 476 command, issubsequent):
475 """Dispatch a wire protocol command made from HTTPv2 requests. 477 """Dispatch a wire protocol command made from HTTPv2 requests.
476 478
477 The authenticated permission (``authedperm``) along with the original 479 The authenticated permission (``authedperm``) along with the original
478 command from the URL (``reqcommand``) are passed in. 480 command from the URL (``reqcommand``) are passed in.
479 """ 481 """
482 # command to run is expressed in a frame. However, the URL also requested 484 # command to run is expressed in a frame. However, the URL also requested
483 # to run a specific command. We need to be careful that the command we 485 # to run a specific command. We need to be careful that the command we
484 # run doesn't have permissions requirements greater than what was granted 486 # run doesn't have permissions requirements greater than what was granted
485 # by ``authedperm``. 487 # by ``authedperm``.
486 # 488 #
487 # For now, this is no big deal, as we only allow a single command per 489 # Our rule for this is we only allow one command per HTTP request and
488 # request and that command must match the command in the URL. But when 490 # that command must match the command in the URL. However, we make
489 # things change, we need to watch out... 491 # an exception for the ``multirequest`` URL. This URL is allowed to
490 if reqcommand != command['command']: 492 # execute multiple commands. We double check permissions of each command
491 # TODO define proper error mechanism 493 # as it is invoked to ensure there is no privilege escalation.
492 res.status = b'200 OK' 494 # TODO consider allowing multiple commands to regular command URLs
493 res.headers[b'Content-Type'] = b'text/plain' 495 # iff each command is the same.
494 res.setbodybytes(_('command in frame must match command in URL'))
495 return
496
497 # TODO once we get rid of the command==URL restriction, we'll need to
498 # revalidate command validity and auth here. checkperm,
499 # wireproto.commands.commandavailable(), etc.
500 496
501 proto = httpv2protocolhandler(req, ui, args=command['args']) 497 proto = httpv2protocolhandler(req, ui, args=command['args'])
502 assert wireproto.commands.commandavailable(command['command'], proto) 498
503 wirecommand = wireproto.commands[command['command']] 499 if reqcommand == b'multirequest':
504 500 if not wireproto.commands.commandavailable(command['command'], proto):
505 assert authedperm in (b'ro', b'rw') 501 # TODO proper error mechanism
506 assert wirecommand.permission in ('push', 'pull') 502 res.status = b'200 OK'
507 503 res.headers[b'Content-Type'] = b'text/plain'
508 # We already checked this as part of the URL==command check, but 504 res.setbodybytes(_('wire protocol command not available: %s') %
509 # permissions are important, so do it again. 505 command['command'])
510 if authedperm == b'ro': 506 return True
511 assert wirecommand.permission == 'pull' 507
512 elif authedperm == b'rw': 508 assert authedperm in (b'ro', b'rw')
513 # We are allowed to access read-only commands under the rw URL. 509 wirecommand = wireproto.commands[command['command']]
514 assert wirecommand.permission in ('push', 'pull') 510 assert wirecommand.permission in ('push', 'pull')
511
512 if authedperm == b'ro' and wirecommand.permission != 'pull':
513 # TODO proper error mechanism
514 res.status = b'403 Forbidden'
515 res.headers[b'Content-Type'] = b'text/plain'
516 res.setbodybytes(_('insufficient permissions to execute '
517 'command: %s') % command['command'])
518 return True
519
520 # TODO should we also call checkperm() here? Maybe not if we're going
521 # to overhaul that API. The granted scope from the URL check should
522 # be good enough.
523
524 else:
525 # Don't allow multiple commands outside of ``multirequest`` URL.
526 if issubsequent:
527 # TODO proper error mechanism
528 res.status = b'200 OK'
529 res.headers[b'Content-Type'] = b'text/plain'
530 res.setbodybytes(_('multiple commands cannot be issued to this '
531 'URL'))
532 return True
533
534 if reqcommand != command['command']:
535 # TODO define proper error mechanism
536 res.status = b'200 OK'
537 res.headers[b'Content-Type'] = b'text/plain'
538 res.setbodybytes(_('command in frame must match command in URL'))
539 return True
515 540
516 rsp = wireproto.dispatch(repo, proto, command['command']) 541 rsp = wireproto.dispatch(repo, proto, command['command'])
517 542
518 res.status = b'200 OK' 543 res.status = b'200 OK'
519 res.headers[b'Content-Type'] = FRAMINGTYPE 544 res.headers[b'Content-Type'] = FRAMINGTYPE
525 action, meta = reactor.onapplicationerror( 550 action, meta = reactor.onapplicationerror(
526 _('unhandled response type from wire proto command')) 551 _('unhandled response type from wire proto command'))
527 552
528 if action == 'sendframes': 553 if action == 'sendframes':
529 res.setbodygen(meta['framegen']) 554 res.setbodygen(meta['framegen'])
555 return True
530 elif action == 'noop': 556 elif action == 'noop':
531 pass 557 pass
532 else: 558 else:
533 raise error.ProgrammingError('unhandled event from reactor: %s' % 559 raise error.ProgrammingError('unhandled event from reactor: %s' %
534 action) 560 action)