diff --git a/src/applications/herald/controller/HeraldWebhookViewController.php b/src/applications/herald/controller/HeraldWebhookViewController.php index 9b11f5d433..d8e5eb3c54 100644 --- a/src/applications/herald/controller/HeraldWebhookViewController.php +++ b/src/applications/herald/controller/HeraldWebhookViewController.php @@ -1,184 +1,197 @@ getViewer(); $hook = id(new HeraldWebhookQuery()) ->setViewer($viewer) ->withIDs(array($request->getURIData('id'))) ->executeOne(); if (!$hook) { return new Aphront404Response(); } $header = $this->buildHeaderView($hook); $warnings = null; if ($hook->isInErrorBackoff($viewer)) { $message = pht( 'Many requests to this webhook have failed recently (at least %s '. 'errors in the last %s seconds). New requests are temporarily paused.', $hook->getErrorBackoffThreshold(), $hook->getErrorBackoffWindow()); $warnings = id(new PHUIInfoView()) ->setSeverity(PHUIInfoView::SEVERITY_WARNING) ->setErrors( array( $message, )); } $curtain = $this->buildCurtain($hook); $properties_view = $this->buildPropertiesView($hook); $timeline = $this->buildTransactionTimeline( $hook, new HeraldWebhookTransactionQuery()); $timeline->setShouldTerminate(true); $requests = id(new HeraldWebhookRequestQuery()) ->setViewer($viewer) ->withWebhookPHIDs(array($hook->getPHID())) ->setLimit(20) ->execute(); + $warnings = array(); + if (PhabricatorEnv::getEnvConfig('phabricator.silent')) { + $message = pht( + 'Phabricator is currently configured in silent mode, so it will not '. + 'publish webhooks. To adjust this setting, see '. + '@{config:phabricator.silent} in Config.'); + + $warnings[] = id(new PHUIInfoView()) + ->setTitle(pht('Silent Mode')) + ->setSeverity(PHUIInfoView::SEVERITY_WARNING) + ->appendChild(new PHUIRemarkupView($viewer, $message)); + } + $requests_table = id(new HeraldWebhookRequestListView()) ->setViewer($viewer) ->setRequests($requests) ->setHighlightID($request->getURIData('requestID')); $requests_view = id(new PHUIObjectBoxView()) ->setHeaderText(pht('Recent Requests')) ->setBackground(PHUIObjectBoxView::BLUE_PROPERTY) ->setTable($requests_table); $hook_view = id(new PHUITwoColumnView()) ->setHeader($header) ->setMainColumn( array( $warnings, $properties_view, $requests_view, $timeline, )) ->setCurtain($curtain); $crumbs = $this->buildApplicationCrumbs() ->addTextCrumb(pht('Webhook %d', $hook->getID())) ->setBorder(true); return $this->newPage() ->setTitle( array( pht('Webhook %d', $hook->getID()), $hook->getName(), )) ->setCrumbs($crumbs) ->setPageObjectPHIDs( array( $hook->getPHID(), )) ->appendChild($hook_view); } private function buildHeaderView(HeraldWebhook $hook) { $viewer = $this->getViewer(); $title = $hook->getName(); $status_icon = $hook->getStatusIcon(); $status_color = $hook->getStatusColor(); $status_name = $hook->getStatusDisplayName(); $header = id(new PHUIHeaderView()) ->setHeader($title) ->setViewer($viewer) ->setPolicyObject($hook) ->setStatus($status_icon, $status_color, $status_name) ->setHeaderIcon('fa-cloud-upload'); return $header; } private function buildCurtain(HeraldWebhook $hook) { $viewer = $this->getViewer(); $curtain = $this->newCurtainView($hook); $can_edit = PhabricatorPolicyFilter::hasCapability( $viewer, $hook, PhabricatorPolicyCapability::CAN_EDIT); $id = $hook->getID(); $edit_uri = $this->getApplicationURI("webhook/edit/{$id}/"); $test_uri = $this->getApplicationURI("webhook/test/{$id}/"); $key_view_uri = $this->getApplicationURI("webhook/key/view/{$id}/"); $key_cycle_uri = $this->getApplicationURI("webhook/key/cycle/{$id}/"); $curtain->addAction( id(new PhabricatorActionView()) ->setName(pht('Edit Webhook')) ->setIcon('fa-pencil') ->setDisabled(!$can_edit) ->setWorkflow(!$can_edit) ->setHref($edit_uri)); $curtain->addAction( id(new PhabricatorActionView()) ->setName(pht('New Test Request')) ->setIcon('fa-cloud-upload') ->setDisabled(!$can_edit) ->setWorkflow(true) ->setHref($test_uri)); $curtain->addAction( id(new PhabricatorActionView()) ->setName(pht('View HMAC Key')) ->setIcon('fa-key') ->setDisabled(!$can_edit) ->setWorkflow(true) ->setHref($key_view_uri)); $curtain->addAction( id(new PhabricatorActionView()) ->setName(pht('Regenerate HMAC Key')) ->setIcon('fa-refresh') ->setDisabled(!$can_edit) ->setWorkflow(true) ->setHref($key_cycle_uri)); return $curtain; } private function buildPropertiesView(HeraldWebhook $hook) { $viewer = $this->getViewer(); $properties = id(new PHUIPropertyListView()) ->setViewer($viewer); $properties->addProperty( pht('URI'), $hook->getWebhookURI()); $properties->addProperty( pht('Status'), $hook->getStatusDisplayName()); return id(new PHUIObjectBoxView()) ->setHeaderText(pht('Details')) ->setBackground(PHUIObjectBoxView::BLUE_PROPERTY) ->appendChild($properties); } } diff --git a/src/applications/herald/storage/HeraldWebhookRequest.php b/src/applications/herald/storage/HeraldWebhookRequest.php index 5db5b2916e..3381f6a99c 100644 --- a/src/applications/herald/storage/HeraldWebhookRequest.php +++ b/src/applications/herald/storage/HeraldWebhookRequest.php @@ -1,223 +1,276 @@ true, self::CONFIG_SERIALIZATION => array( 'properties' => self::SERIALIZATION_JSON, ), self::CONFIG_COLUMN_SCHEMA => array( 'status' => 'text32', 'lastRequestResult' => 'text32', 'lastRequestEpoch' => 'epoch', ), self::CONFIG_KEY_SCHEMA => array( 'key_ratelimit' => array( 'columns' => array( 'webhookPHID', 'lastRequestResult', 'lastRequestEpoch', ), ), 'key_collect' => array( 'columns' => array('dateCreated'), ), ), ) + parent::getConfiguration(); } public function getPHIDType() { return HeraldWebhookRequestPHIDType::TYPECONST; } public static function initializeNewWebhookRequest(HeraldWebhook $hook) { return id(new self()) ->setWebhookPHID($hook->getPHID()) ->attachWebhook($hook) ->setStatus(self::STATUS_QUEUED) ->setRetryMode(self::RETRY_NEVER) ->setLastRequestResult(self::RESULT_NONE) ->setLastRequestEpoch(0); } public function getWebhook() { return $this->assertAttached($this->webhook); } public function attachWebhook(HeraldWebhook $hook) { $this->webhook = $hook; return $this; } protected function setProperty($key, $value) { $this->properties[$key] = $value; return $this; } protected function getProperty($key, $default = null) { return idx($this->properties, $key, $default); } public function setRetryMode($mode) { return $this->setProperty('retry', $mode); } public function getRetryMode() { return $this->getProperty('retry'); } public function setErrorType($error_type) { return $this->setProperty('errorType', $error_type); } public function getErrorType() { return $this->getProperty('errorType'); } public function setErrorCode($error_code) { return $this->setProperty('errorCode', $error_code); } public function getErrorCode() { return $this->getProperty('errorCode'); } + public function getErrorTypeForDisplay() { + $map = array( + self::ERRORTYPE_HOOK => pht('Hook Error'), + self::ERRORTYPE_HTTP => pht('HTTP Error'), + self::ERRORTYPE_TIMEOUT => pht('Request Timeout'), + ); + + $type = $this->getErrorType(); + return idx($map, $type, $type); + } + + public function getErrorCodeForDisplay() { + $code = $this->getErrorCode(); + + if ($this->getErrorType() !== self::ERRORTYPE_HOOK) { + return $code; + } + + $spec = $this->getHookErrorSpec($code); + return idx($spec, 'display', $code); + } + public function setTransactionPHIDs(array $phids) { return $this->setProperty('transactionPHIDs', $phids); } public function getTransactionPHIDs() { return $this->getProperty('transactionPHIDs', array()); } public function setTriggerPHIDs(array $phids) { return $this->setProperty('triggerPHIDs', $phids); } public function getTriggerPHIDs() { return $this->getProperty('triggerPHIDs', array()); } public function setIsSilentAction($bool) { return $this->setProperty('silent', $bool); } public function getIsSilentAction() { return $this->getProperty('silent', false); } public function setIsTestAction($bool) { return $this->setProperty('test', $bool); } public function getIsTestAction() { return $this->getProperty('test', false); } public function setIsSecureAction($bool) { return $this->setProperty('secure', $bool); } public function getIsSecureAction() { return $this->getProperty('secure', false); } public function queueCall() { PhabricatorWorker::scheduleTask( 'HeraldWebhookWorker', array( 'webhookRequestPHID' => $this->getPHID(), ), array( 'objectPHID' => $this->getPHID(), )); return $this; } public function newStatusIcon() { switch ($this->getStatus()) { case self::STATUS_QUEUED: $icon = 'fa-refresh'; $color = 'blue'; $tooltip = pht('Queued'); break; case self::STATUS_SENT: $icon = 'fa-check'; $color = 'green'; $tooltip = pht('Sent'); break; case self::STATUS_FAILED: default: $icon = 'fa-times'; $color = 'red'; $tooltip = pht('Failed'); break; } return id(new PHUIIconView()) ->setIcon($icon, $color) ->setTooltip($tooltip); } + private function getHookErrorSpec($code) { + $map = $this->getHookErrorMap(); + return idx($map, $code, array()); + } + + private function getHookErrorMap() { + return array( + self::ERROR_SILENT => array( + 'display' => pht('In Silent Mode'), + ), + self::ERROR_DISABLED => array( + 'display' => pht('Hook Disabled'), + ), + self::ERROR_URI => array( + 'display' => pht('Invalid URI'), + ), + self::ERROR_OBJECT => array( + 'display' => pht('Invalid Object'), + ), + ); + } + /* -( PhabricatorPolicyInterface )----------------------------------------- */ public function getCapabilities() { return array( PhabricatorPolicyCapability::CAN_VIEW, ); } public function getPolicy($capability) { switch ($capability) { case PhabricatorPolicyCapability::CAN_VIEW: return PhabricatorPolicies::getMostOpenPolicy(); } } public function hasAutomaticCapability($capability, PhabricatorUser $viewer) { return false; } /* -( PhabricatorExtendedPolicyInterface )--------------------------------- */ public function getExtendedPolicy($capability, PhabricatorUser $viewer) { return array( array($this->getWebhook(), PhabricatorPolicyCapability::CAN_VIEW), ); } } diff --git a/src/applications/herald/view/HeraldWebhookRequestListView.php b/src/applications/herald/view/HeraldWebhookRequestListView.php index 4e0f6510b9..082d320bba 100644 --- a/src/applications/herald/view/HeraldWebhookRequestListView.php +++ b/src/applications/herald/view/HeraldWebhookRequestListView.php @@ -1,88 +1,88 @@ requests = $requests; return $this; } public function setHighlightID($highlight_id) { $this->highlightID = $highlight_id; return $this; } public function getHighlightID() { return $this->highlightID; } public function render() { $viewer = $this->getViewer(); $requests = $this->requests; $handle_phids = array(); foreach ($requests as $request) { $handle_phids[] = $request->getObjectPHID(); } $handles = $viewer->loadHandles($handle_phids); $highlight_id = $this->getHighlightID(); $rows = array(); $rowc = array(); foreach ($requests as $request) { $icon = $request->newStatusIcon(); if ($highlight_id == $request->getID()) { $rowc[] = 'highlighted'; } else { $rowc[] = null; } $last_epoch = $request->getLastRequestEpoch(); if ($request->getLastRequestEpoch()) { $last_request = phabricator_datetime($last_epoch, $viewer); } else { $last_request = null; } $rows[] = array( $request->getID(), $icon, $handles[$request->getObjectPHID()]->renderLink(), - $request->getErrorType(), - $request->getErrorCode(), + $request->getErrorTypeForDisplay(), + $request->getErrorCodeForDisplay(), $last_request, ); } $table = id(new AphrontTableView($rows)) ->setRowClasses($rowc) ->setHeaders( array( pht('ID'), - '', + null, pht('Object'), pht('Type'), pht('Code'), pht('Requested At'), )) ->setColumnClasses( array( 'n', '', 'wide', '', '', '', )); return $table; } } diff --git a/src/applications/herald/worker/HeraldWebhookWorker.php b/src/applications/herald/worker/HeraldWebhookWorker.php index 150f98fd50..bc93f092d5 100644 --- a/src/applications/herald/worker/HeraldWebhookWorker.php +++ b/src/applications/herald/worker/HeraldWebhookWorker.php @@ -1,250 +1,263 @@ getTaskData(); $request_phid = idx($data, 'webhookRequestPHID'); $request = id(new HeraldWebhookRequestQuery()) ->setViewer($viewer) ->withPHIDs(array($request_phid)) ->executeOne(); if (!$request) { throw new PhabricatorWorkerPermanentFailureException( pht( 'Unable to load webhook request ("%s"). It may have been '. 'garbage collected.', $request_phid)); } $status = $request->getStatus(); if ($status !== HeraldWebhookRequest::STATUS_QUEUED) { throw new PhabricatorWorkerPermanentFailureException( pht( 'Webhook request ("%s") is not in "%s" status (actual '. 'status is "%s"). Declining call to hook.', $request_phid, HeraldWebhookRequest::STATUS_QUEUED, $status)); } // If we're in silent mode, permanently fail the webhook request and then // return to complete this task. if (PhabricatorEnv::getEnvConfig('phabricator.silent')) { - $this->failRequest($request, 'hook', 'silent'); + $this->failRequest( + $request, + HeraldWebhookRequest::ERRORTYPE_HOOK, + HeraldWebhookRequest::ERROR_SILENT); return; } $hook = $request->getWebhook(); if ($hook->isDisabled()) { - $this->failRequest($request, 'hook', 'disabled'); + $this->failRequest( + $request, + HeraldWebhookRequest::ERRORTYPE_HOOK, + HeraldWebhookRequest::ERROR_DISABLED); throw new PhabricatorWorkerPermanentFailureException( pht( 'Associated hook ("%s") for webhook request ("%s") is disabled.', $hook->getPHID(), $request_phid)); } $uri = $hook->getWebhookURI(); try { PhabricatorEnv::requireValidRemoteURIForFetch( $uri, array( 'http', 'https', )); } catch (Exception $ex) { - $this->failRequest($request, 'hook', 'uri'); + $this->failRequest( + $request, + HeraldWebhookRequest::ERRORTYPE_HOOK, + HeraldWebhookRequest::ERROR_URI); throw new PhabricatorWorkerPermanentFailureException( pht( 'Associated hook ("%s") for webhook request ("%s") has invalid '. 'fetch URI: %s', $hook->getPHID(), $request_phid, $ex->getMessage())); } $object_phid = $request->getObjectPHID(); $object = id(new PhabricatorObjectQuery()) ->setViewer($viewer) ->withPHIDs(array($object_phid)) ->executeOne(); if (!$object) { - $this->failRequest($request, 'hook', 'object'); + $this->failRequest( + $request, + HeraldWebhookRequest::ERRORTYPE_HOOK, + HeraldWebhookRequest::ERROR_OBJECT); + throw new PhabricatorWorkerPermanentFailureException( pht( 'Unable to load object ("%s") for webhook request ("%s").', $object_phid, $request_phid)); } $xaction_query = PhabricatorApplicationTransactionQuery::newQueryForObject( $object); $xaction_phids = $request->getTransactionPHIDs(); if ($xaction_phids) { $xactions = $xaction_query ->setViewer($viewer) ->withObjectPHIDs(array($object_phid)) ->withPHIDs($xaction_phids) ->execute(); $xactions = mpull($xactions, null, 'getPHID'); } else { $xactions = array(); } // To prevent thundering herd issues for high volume webhooks (where // a large number of workers might try to work through a request backlog // simultaneously, before the error backoff can catch up), we never // parallelize requests to a particular webhook. $lock_key = 'webhook('.$hook->getPHID().')'; $lock = PhabricatorGlobalLock::newLock($lock_key); try { $lock->lock(); } catch (Exception $ex) { phlog($ex); throw new PhabricatorWorkerYieldException(15); } $caught = null; try { $this->callWebhookWithLock($hook, $request, $object, $xactions); } catch (Exception $ex) { $caught = $ex; } $lock->unlock(); if ($caught) { throw $caught; } } private function callWebhookWithLock( HeraldWebhook $hook, HeraldWebhookRequest $request, $object, array $xactions) { $viewer = PhabricatorUser::getOmnipotentUser(); if ($hook->isInErrorBackoff($viewer)) { throw new PhabricatorWorkerYieldException($hook->getErrorBackoffWindow()); } $xaction_data = array(); foreach ($xactions as $xaction) { $xaction_data[] = array( 'phid' => $xaction->getPHID(), ); } $trigger_data = array(); foreach ($request->getTriggerPHIDs() as $trigger_phid) { $trigger_data[] = array( 'phid' => $trigger_phid, ); } $payload = array( 'object' => array( 'type' => phid_get_type($object->getPHID()), 'phid' => $object->getPHID(), ), 'triggers' => $trigger_data, 'action' => array( 'test' => $request->getIsTestAction(), 'silent' => $request->getIsSilentAction(), 'secure' => $request->getIsSecureAction(), 'epoch' => (int)$request->getDateCreated(), ), 'transactions' => $xaction_data, ); $payload = id(new PhutilJSON())->encodeFormatted($payload); $key = $hook->getHmacKey(); $signature = PhabricatorHash::digestHMACSHA256($payload, $key); $uri = $hook->getWebhookURI(); $future = id(new HTTPSFuture($uri)) ->setMethod('POST') ->addHeader('Content-Type', 'application/json') ->addHeader('X-Phabricator-Webhook-Signature', $signature) ->setTimeout(15) ->setData($payload); list($status) = $future->resolve(); if ($status->isTimeout()) { - $error_type = 'timeout'; + $error_type = HeraldWebhookRequest::ERRORTYPE_TIMEOUT; } else { - $error_type = 'http'; + $error_type = HeraldWebhookRequest::ERRORTYPE_HTTP; } $error_code = $status->getStatusCode(); $request ->setErrorType($error_type) ->setErrorCode($error_code) ->setLastRequestEpoch(PhabricatorTime::getNow()); $retry_forever = HeraldWebhookRequest::RETRY_FOREVER; if ($status->isTimeout() || $status->isError()) { $should_retry = ($request->getRetryMode() === $retry_forever); $request ->setLastRequestResult(HeraldWebhookRequest::RESULT_FAIL); if ($should_retry) { $request->save(); throw new Exception( pht( 'Webhook request ("%s", to "%s") failed (%s / %s). The request '. 'will be retried.', $request->getPHID(), $uri, $error_type, $error_code)); } else { $request ->setStatus(HeraldWebhookRequest::STATUS_FAILED) ->save(); throw new PhabricatorWorkerPermanentFailureException( pht( 'Webhook request ("%s", to "%s") failed (%s / %s). The request '. 'will not be retried.', $request->getPHID(), $uri, $error_type, $error_code)); } } else { $request ->setLastRequestResult(HeraldWebhookRequest::RESULT_OKAY) ->setStatus(HeraldWebhookRequest::STATUS_SENT) ->save(); } } private function failRequest( HeraldWebhookRequest $request, $error_type, $error_code) { $request ->setStatus(HeraldWebhookRequest::STATUS_FAILED) ->setErrorType($error_type) ->setErrorCode($error_code) ->setLastRequestResult(HeraldWebhookRequest::RESULT_NONE) ->setLastRequestEpoch(0) ->save(); } }