diff --git a/.gitignore b/.gitignore index a90e868e6f..df6c16cde6 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ /conf/keys/device.pub /conf/keys/device.key /conf/keys/device.id +/conf/aphlict/aphlict.custom.json # Impact Font /resources/font/impact.ttf diff --git a/conf/aphlict/README b/conf/aphlict/README new file mode 100644 index 0000000000..2786ea5658 --- /dev/null +++ b/conf/aphlict/README @@ -0,0 +1,16 @@ +To customize this configuration, you have two options: create a custom +configuration file in this directory, or specify a path to a configuration file +explicitly when starting Aphlict. + +To create a custom configuration file, copy `aphlict.default.json` in this +directory and rename it `aphlict.custom.json`. If this file exists, it will +be read by default. + +To specify a path when starting Aphlict, use the `--config` flag: + + phabricator/ $ ./bin/aphlict start --config path/to/config.json + +Specifying a configuration file explicitly overrides default configuration. + +For more information about configuring notifications, see the article +"Notifications User Guide: Setup and Configuration" in the documentation. diff --git a/conf/aphlict/aphlict.default.json b/conf/aphlict/aphlict.default.json new file mode 100644 index 0000000000..7afdf7e8ff --- /dev/null +++ b/conf/aphlict/aphlict.default.json @@ -0,0 +1,26 @@ +{ + "servers": [ + { + "type": "client", + "port": 22280, + "listen": "0.0.0.0", + "ssl.key": null, + "ssl.cert": null, + "ssl.chain": null + }, + { + "type": "admin", + "port": 22281, + "listen": "127.0.0.1", + "ssl.key": null, + "ssl.cert": null, + "ssl.chain": null + } + ], + "logs": [ + { + "path": "/var/log/aphlict.log" + } + ], + "pidfile": "/var/tmp/aphlict/pid/aphlict.pid" +} diff --git a/resources/celerity/map.php b/resources/celerity/map.php index edc6372e8d..fb5a93fcc9 100644 --- a/resources/celerity/map.php +++ b/resources/celerity/map.php @@ -7,8 +7,8 @@ */ return array( 'names' => array( - 'core.pkg.css' => '82cefddc', - 'core.pkg.js' => 'e5484f37', + 'core.pkg.css' => 'ce06b6f6', + 'core.pkg.js' => '37344f3c', 'darkconsole.pkg.js' => 'e7393ebb', 'differential.pkg.css' => '7ba78475', 'differential.pkg.js' => 'd0cd0df6', @@ -22,7 +22,7 @@ return array( 'rsrc/css/aphront/lightbox-attachment.css' => '7acac05d', 'rsrc/css/aphront/list-filter-view.css' => '5d6f0526', 'rsrc/css/aphront/multi-column.css' => 'fd18389d', - 'rsrc/css/aphront/notification.css' => '7f684b62', + 'rsrc/css/aphront/notification.css' => '3f6c89c9', 'rsrc/css/aphront/panel-view.css' => '8427b78d', 'rsrc/css/aphront/phabricator-nav-view.css' => 'ac79a758', 'rsrc/css/aphront/table-view.css' => '9258e19f', @@ -109,7 +109,7 @@ return array( 'rsrc/css/core/z-index.css' => '5b6fcf3f', 'rsrc/css/diviner/diviner-shared.css' => 'aa3656aa', 'rsrc/css/font/font-aleo.css' => '8bdb2835', - 'rsrc/css/font/font-awesome.css' => 'c43323c5', + 'rsrc/css/font/font-awesome.css' => '2b7ebbcc', 'rsrc/css/font/font-lato.css' => 'c7ccd872', 'rsrc/css/font/phui-font-icon-base.css' => '6449bce8', 'rsrc/css/layout/phabricator-filetree-view.css' => 'fccf9f82', @@ -121,7 +121,7 @@ return array( 'rsrc/css/phui/calendar/phui-calendar.css' => 'ccabe893', 'rsrc/css/phui/phui-action-list.css' => 'c5eba19d', 'rsrc/css/phui/phui-action-panel.css' => '91c7b835', - 'rsrc/css/phui/phui-badge.css' => 'f25c3476', + 'rsrc/css/phui/phui-badge.css' => '3baef8db', 'rsrc/css/phui/phui-big-info-view.css' => 'bd903741', 'rsrc/css/phui/phui-box.css' => 'd909ea3d', 'rsrc/css/phui/phui-button.css' => 'a64a8de6', @@ -148,7 +148,7 @@ return array( 'rsrc/css/phui/phui-object-item-list-view.css' => '8d99e42b', 'rsrc/css/phui/phui-pager.css' => 'bea33d23', 'rsrc/css/phui/phui-pinboard-view.css' => '2495140e', - 'rsrc/css/phui/phui-profile-menu.css' => '7e92a89a', + 'rsrc/css/phui/phui-profile-menu.css' => 'c8557f33', 'rsrc/css/phui/phui-property-list-view.css' => '1d42ee7c', 'rsrc/css/phui/phui-remarkup-preview.css' => '1a8f2591', 'rsrc/css/phui/phui-segment-bar-view.css' => '46342871', @@ -175,10 +175,10 @@ return array( 'rsrc/externals/font/aleo/aleo-regular.ttf' => '751e7479', 'rsrc/externals/font/aleo/aleo-regular.woff' => 'c3744be9', 'rsrc/externals/font/aleo/aleo-regular.woff2' => '851aa0ee', - 'rsrc/externals/font/fontawesome/fontawesome-webfont.eot' => '346fbcc5', - 'rsrc/externals/font/fontawesome/fontawesome-webfont.ttf' => '510fccb2', - 'rsrc/externals/font/fontawesome/fontawesome-webfont.woff' => '0334f580', - 'rsrc/externals/font/fontawesome/fontawesome-webfont.woff2' => '45dca585', + 'rsrc/externals/font/fontawesome/fontawesome-webfont.eot' => '59b3076c', + 'rsrc/externals/font/fontawesome/fontawesome-webfont.ttf' => '45ad7e57', + 'rsrc/externals/font/fontawesome/fontawesome-webfont.woff' => 'f861e2a8', + 'rsrc/externals/font/fontawesome/fontawesome-webfont.woff2' => '0ee0f078', 'rsrc/externals/font/lato/lato-bold.eot' => '99fbcf8c', 'rsrc/externals/font/lato/lato-bold.svg' => '2aa83045', 'rsrc/externals/font/lato/lato-bold.ttf' => '0a7141f7', @@ -232,7 +232,7 @@ return array( 'rsrc/externals/javelin/lib/DOM.js' => '805b806a', 'rsrc/externals/javelin/lib/History.js' => 'd4505101', 'rsrc/externals/javelin/lib/JSON.js' => '69adf288', - 'rsrc/externals/javelin/lib/Leader.js' => '331b1611', + 'rsrc/externals/javelin/lib/Leader.js' => 'b4ba945c', 'rsrc/externals/javelin/lib/Mask.js' => '8a41885b', 'rsrc/externals/javelin/lib/Quicksand.js' => '6b8ef10b', 'rsrc/externals/javelin/lib/Request.js' => '94b750d2', @@ -393,6 +393,7 @@ return array( 'rsrc/js/application/diffusion/behavior-audit-preview.js' => 'd835b03a', 'rsrc/js/application/diffusion/behavior-commit-branches.js' => 'bdaf4d04', 'rsrc/js/application/diffusion/behavior-commit-graph.js' => '5a0b1a64', + 'rsrc/js/application/diffusion/behavior-diffusion-browse-file.js' => '054a0f0b', 'rsrc/js/application/diffusion/behavior-jump-to.js' => '73d09eef', 'rsrc/js/application/diffusion/behavior-load-blame.js' => '42126667', 'rsrc/js/application/diffusion/behavior-locate-file.js' => '6d3e1947', @@ -471,6 +472,7 @@ return array( 'rsrc/js/core/behavior-active-nav.js' => 'e379b58e', 'rsrc/js/core/behavior-audio-source.js' => '59b251eb', 'rsrc/js/core/behavior-autofocus.js' => '7319e029', + 'rsrc/js/core/behavior-badge-view.js' => '8ff5e24c', 'rsrc/js/core/behavior-choose-control.js' => '327a00d1', 'rsrc/js/core/behavior-crop.js' => 'fa0f4fc2', 'rsrc/js/core/behavior-dark-console.js' => 'f411b6ae', @@ -494,6 +496,7 @@ return array( 'rsrc/js/core/behavior-oncopy.js' => '2926fff2', 'rsrc/js/core/behavior-phabricator-nav.js' => '56a1ca03', 'rsrc/js/core/behavior-phabricator-remarkup-assist.js' => '340c8eff', + 'rsrc/js/core/behavior-read-only-warning.js' => 'ba158207', 'rsrc/js/core/behavior-refresh-csrf.js' => 'ab2f381b', 'rsrc/js/core/behavior-remarkup-preview.js' => '4b700e9e', 'rsrc/js/core/behavior-reorder-applications.js' => '76b9fc3e', @@ -501,8 +504,8 @@ return array( 'rsrc/js/core/behavior-scrollbar.js' => '834a1173', 'rsrc/js/core/behavior-search-typeahead.js' => '06c32383', 'rsrc/js/core/behavior-select-on-click.js' => '4e3e79a6', - 'rsrc/js/core/behavior-time-typeahead.js' => 'f80d6bf0', - 'rsrc/js/core/behavior-toggle-class.js' => '5d7c9f33', + 'rsrc/js/core/behavior-time-typeahead.js' => '522431f7', + 'rsrc/js/core/behavior-toggle-class.js' => '92b9ec77', 'rsrc/js/core/behavior-tokenizer.js' => 'b3a4b884', 'rsrc/js/core/behavior-tooltip.js' => '42fcb747', 'rsrc/js/core/behavior-watch-anchor.js' => '9f36c42d', @@ -558,7 +561,7 @@ return array( 'diffusion-source-css' => '68b30fd3', 'diviner-shared-css' => 'aa3656aa', 'font-aleo' => '8bdb2835', - 'font-fontawesome' => 'c43323c5', + 'font-fontawesome' => '2b7ebbcc', 'font-lato' => 'c7ccd872', 'global-drag-and-drop-css' => '5c1b47c2', 'harbormaster-css' => 'f491c9f4', @@ -578,6 +581,7 @@ return array( 'javelin-behavior-aphront-more' => 'a80d0378', 'javelin-behavior-audio-source' => '59b251eb', 'javelin-behavior-audit-preview' => 'd835b03a', + 'javelin-behavior-badge-view' => '8ff5e24c', 'javelin-behavior-bulk-job-reload' => 'edf8a145', 'javelin-behavior-choose-control' => '327a00d1', 'javelin-behavior-comment-actions' => '06460e71', @@ -605,6 +609,7 @@ return array( 'javelin-behavior-differential-populate' => '8694b1df', 'javelin-behavior-differential-toggle-files' => 'ca3f91eb', 'javelin-behavior-differential-user-select' => 'a8d8459d', + 'javelin-behavior-diffusion-browse-file' => '054a0f0b', 'javelin-behavior-diffusion-commit-branches' => 'bdaf4d04', 'javelin-behavior-diffusion-commit-graph' => '5a0b1a64', 'javelin-behavior-diffusion-jump-to' => '73d09eef', @@ -666,6 +671,7 @@ return array( 'javelin-behavior-project-boards' => '14a1faae', 'javelin-behavior-project-create' => '065227cc', 'javelin-behavior-quicksand-blacklist' => '7927a7d3', + 'javelin-behavior-read-only-warning' => 'ba158207', 'javelin-behavior-recurring-edit' => '5f1c4d5f', 'javelin-behavior-refresh-csrf' => 'ab2f381b', 'javelin-behavior-releeph-preview-branch' => 'b2b4fbaf', @@ -682,8 +688,8 @@ return array( 'javelin-behavior-slowvote-embed' => '887ad43f', 'javelin-behavior-stripe-payment-form' => '3f5d6dbf', 'javelin-behavior-test-payment-form' => 'fc91ab6c', - 'javelin-behavior-time-typeahead' => 'f80d6bf0', - 'javelin-behavior-toggle-class' => '5d7c9f33', + 'javelin-behavior-time-typeahead' => '522431f7', + 'javelin-behavior-toggle-class' => '92b9ec77', 'javelin-behavior-typeahead-browse' => '635de1ec', 'javelin-behavior-typeahead-search' => '93d0c9e3', 'javelin-behavior-view-placeholder' => '47830651', @@ -698,7 +704,7 @@ return array( 'javelin-history' => 'd4505101', 'javelin-install' => '05270951', 'javelin-json' => '69adf288', - 'javelin-leader' => '331b1611', + 'javelin-leader' => 'b4ba945c', 'javelin-magical-init' => '3010e992', 'javelin-mask' => '8a41885b', 'javelin-quicksand' => '6b8ef10b', @@ -766,7 +772,7 @@ return array( 'phabricator-main-menu-view' => 'd00a795a', 'phabricator-nav-view-css' => 'ac79a758', 'phabricator-notification' => 'ccf1cbf8', - 'phabricator-notification-css' => '7f684b62', + 'phabricator-notification-css' => '3f6c89c9', 'phabricator-notification-menu-css' => 'f31c0bde', 'phabricator-object-selector-css' => '85ee8ce6', 'phabricator-phtize' => 'd254d646', @@ -803,7 +809,7 @@ return array( 'phrequent-css' => 'ffc185ad', 'phriction-document-css' => 'd1861e06', 'phui-action-panel-css' => '91c7b835', - 'phui-badge-view-css' => 'f25c3476', + 'phui-badge-view-css' => '3baef8db', 'phui-big-info-view-css' => 'bd903741', 'phui-box-css' => 'd909ea3d', 'phui-button-css' => 'a64a8de6', @@ -837,7 +843,7 @@ return array( 'phui-object-item-list-view-css' => '8d99e42b', 'phui-pager-css' => 'bea33d23', 'phui-pinboard-view-css' => '2495140e', - 'phui-profile-menu-css' => '7e92a89a', + 'phui-profile-menu-css' => 'c8557f33', 'phui-property-list-view-css' => '1d42ee7c', 'phui-remarkup-preview-css' => '1a8f2591', 'phui-segment-bar-view-css' => '46342871', @@ -916,6 +922,12 @@ return array( 'javelin-util', 'javelin-magical-init', ), + '054a0f0b' => array( + 'javelin-behavior', + 'javelin-dom', + 'javelin-util', + 'phabricator-tooltip', + ), '056da01b' => array( 'aphront-typeahead-control-css', 'phui-tag-view-css', @@ -1106,9 +1118,6 @@ return array( 'javelin-dom', 'javelin-workflow', ), - '331b1611' => array( - 'javelin-install', - ), '340c8eff' => array( 'javelin-behavior', 'javelin-stratcom', @@ -1235,6 +1244,14 @@ return array( 'javelin-dom', 'javelin-reactor-dom', ), + '522431f7' => array( + 'javelin-behavior', + 'javelin-util', + 'javelin-dom', + 'javelin-stratcom', + 'javelin-vector', + 'javelin-typeahead-static-source', + ), 52291776 => array( 'javelin-install', 'javelin-dom', @@ -1328,11 +1345,6 @@ return array( 'javelin-stratcom', 'javelin-dom', ), - '5d7c9f33' => array( - 'javelin-behavior', - 'javelin-stratcom', - 'javelin-dom', - ), '5e9f347c' => array( 'javelin-behavior', 'multirow-row-manager', @@ -1568,6 +1580,11 @@ return array( 'javelin-stratcom', 'javelin-install', ), + '8ff5e24c' => array( + 'javelin-behavior', + 'javelin-stratcom', + 'javelin-dom', + ), '901935ef' => array( 'javelin-behavior', 'javelin-dom', @@ -1582,6 +1599,11 @@ return array( 92197373 => array( 'phui-workcard-view-css', ), + '92b9ec77' => array( + 'javelin-behavior', + 'javelin-stratcom', + 'javelin-dom', + ), '93d0c9e3' => array( 'javelin-behavior', 'javelin-stratcom', @@ -1750,6 +1772,9 @@ return array( 'javelin-typeahead-preloaded-source', 'javelin-util', ), + 'b4ba945c' => array( + 'javelin-install', + ), 'b59e1e96' => array( 'javelin-behavior', 'javelin-stratcom', @@ -1781,6 +1806,11 @@ return array( 'javelin-json', 'phabricator-draggable-list', ), + 'ba158207' => array( + 'javelin-behavior', + 'javelin-uri', + 'phabricator-notification', + ), 'bae58312' => array( 'javelin-install', 'javelin-workboard-card', @@ -2088,14 +2118,6 @@ return array( 'javelin-typeahead-ondemand-source', 'javelin-util', ), - 'f80d6bf0' => array( - 'javelin-behavior', - 'javelin-util', - 'javelin-dom', - 'javelin-stratcom', - 'javelin-vector', - 'javelin-typeahead-static-source', - ), 'f829edb3' => array( 'javelin-view', 'javelin-install', @@ -2284,6 +2306,7 @@ return array( 'javelin-quicksand', 'javelin-behavior-quicksand-blacklist', 'javelin-behavior-high-security-warning', + 'javelin-behavior-read-only-warning', 'javelin-scrollbar', 'javelin-behavior-scrollbar', 'javelin-behavior-durable-column', diff --git a/resources/celerity/packages.php b/resources/celerity/packages.php index 2b0f8b9ff0..df6c2ed726 100644 --- a/resources/celerity/packages.php +++ b/resources/celerity/packages.php @@ -76,6 +76,7 @@ return array( 'javelin-quicksand', 'javelin-behavior-quicksand-blacklist', 'javelin-behavior-high-security-warning', + 'javelin-behavior-read-only-warning', 'javelin-scrollbar', 'javelin-behavior-scrollbar', 'javelin-behavior-durable-column', diff --git a/resources/sql/autopatches/20160411.repo.1.version.sql b/resources/sql/autopatches/20160411.repo.1.version.sql new file mode 100644 index 0000000000..bd0db5f5ce --- /dev/null +++ b/resources/sql/autopatches/20160411.repo.1.version.sql @@ -0,0 +1,8 @@ +CREATE TABLE {$NAMESPACE}_repository.repository_workingcopyversion ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + repositoryPHID VARBINARY(64) NOT NULL, + devicePHID VARBINARY(64) NOT NULL, + repositoryVersion INT UNSIGNED NOT NULL, + isWriting BOOL NOT NULL, + UNIQUE KEY `key_workingcopy` (repositoryPHID, devicePHID) +) ENGINE=InnoDB, COLLATE {$COLLATE_TEXT}; diff --git a/scripts/sql/manage_storage.php b/scripts/sql/manage_storage.php index d1e1d16b6f..7ad94277f7 100755 --- a/scripts/sql/manage_storage.php +++ b/scripts/sql/manage_storage.php @@ -19,13 +19,6 @@ EOHELP ); $args->parseStandardArguments(); -$conf = PhabricatorEnv::newObjectFromConfig( - 'mysql.configuration-provider', - array($dao = null, 'w')); - -$default_user = $conf->getUser(); -$default_host = $conf->getHost(); -$default_port = $conf->getPort(); $default_namespace = PhabricatorLiskDAO::getDefaultStorageNamespace(); try { @@ -37,14 +30,18 @@ try { 'help' => pht( 'Do not prompt before performing dangerous operations.'), ), + array( + 'name' => 'host', + 'param' => 'hostname', + 'help' => pht( + 'Connect to __host__ instead of the default host.'), + ), array( 'name' => 'user', 'short' => 'u', 'param' => 'username', - 'default' => $default_user, 'help' => pht( - "Connect with __username__ instead of the configured default ('%s').", - $default_user), + 'Connect with __username__ instead of the configured default.'), ), array( 'name' => 'password', @@ -84,11 +81,48 @@ try { // First, test that the Phabricator configuration is set up correctly. After // we know this works we'll test any administrative credentials specifically. +$host = $args->getArg('host'); +if (strlen($host)) { + $ref = null; + + $refs = PhabricatorDatabaseRef::getLiveRefs(); + + // Include the master in case the user is just specifying a redundant + // "--host" flag for no reason and does not actually have a database + // cluster configured. + $refs[] = PhabricatorDatabaseRef::getMasterDatabaseRef(); + + foreach ($refs as $possible_ref) { + if ($possible_ref->getHost() == $host) { + $ref = $possible_ref; + break; + } + } + + if (!$ref) { + throw new PhutilArgumentUsageException( + pht( + 'There is no configured database on host "%s". This command can '. + 'only interact with configured databases.', + $host)); + } +} else { + $ref = PhabricatorDatabaseRef::getMasterDatabaseRef(); + if (!$ref) { + throw new Exception( + pht('No database master is configured.')); + } +} + +$default_user = $ref->getUser(); +$default_host = $ref->getHost(); +$default_port = $ref->getPort(); + $test_api = id(new PhabricatorStorageManagementAPI()) ->setUser($default_user) ->setHost($default_host) ->setPort($default_port) - ->setPassword($conf->getPassword()) + ->setPassword($ref->getPass()) ->setNamespace($args->getArg('namespace')); try { @@ -120,15 +154,20 @@ try { if ($args->getArg('password') === null) { // This is already a PhutilOpaqueEnvelope. - $password = $conf->getPassword(); + $password = $ref->getPass(); } else { // Put this in a PhutilOpaqueEnvelope. $password = new PhutilOpaqueEnvelope($args->getArg('password')); PhabricatorEnv::overrideConfig('mysql.pass', $args->getArg('password')); } +$selected_user = $args->getArg('user'); +if ($selected_user === null) { + $selected_user = $default_user; +} + $api = id(new PhabricatorStorageManagementAPI()) - ->setUser($args->getArg('user')) + ->setUser($selected_user) ->setHost($default_host) ->setPort($default_port) ->setPassword($password) diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php index 596901a360..e76e768774 100644 --- a/src/__phutil_library_map__.php +++ b/src/__phutil_library_map__.php @@ -650,6 +650,7 @@ phutil_register_library_map(array( 'DiffusionHovercardEngineExtension' => 'applications/diffusion/engineextension/DiffusionHovercardEngineExtension.php', 'DiffusionInlineCommentController' => 'applications/diffusion/controller/DiffusionInlineCommentController.php', 'DiffusionInlineCommentPreviewController' => 'applications/diffusion/controller/DiffusionInlineCommentPreviewController.php', + 'DiffusionInternalGitRawDiffQueryConduitAPIMethod' => 'applications/diffusion/conduit/DiffusionInternalGitRawDiffQueryConduitAPIMethod.php', 'DiffusionLastModifiedController' => 'applications/diffusion/controller/DiffusionLastModifiedController.php', 'DiffusionLastModifiedQueryConduitAPIMethod' => 'applications/diffusion/conduit/DiffusionLastModifiedQueryConduitAPIMethod.php', 'DiffusionLintController' => 'applications/diffusion/controller/DiffusionLintController.php', @@ -739,6 +740,7 @@ phutil_register_library_map(array( 'DiffusionRefsQueryConduitAPIMethod' => 'applications/diffusion/conduit/DiffusionRefsQueryConduitAPIMethod.php', 'DiffusionRenameHistoryQuery' => 'applications/diffusion/query/DiffusionRenameHistoryQuery.php', 'DiffusionRepositoryByIDRemarkupRule' => 'applications/diffusion/remarkup/DiffusionRepositoryByIDRemarkupRule.php', + 'DiffusionRepositoryClusterManagementPanel' => 'applications/diffusion/management/DiffusionRepositoryClusterManagementPanel.php', 'DiffusionRepositoryController' => 'applications/diffusion/controller/DiffusionRepositoryController.php', 'DiffusionRepositoryCreateController' => 'applications/diffusion/controller/DiffusionRepositoryCreateController.php', 'DiffusionRepositoryDatasource' => 'applications/diffusion/typeahead/DiffusionRepositoryDatasource.php', @@ -759,6 +761,8 @@ phutil_register_library_map(array( 'DiffusionRepositoryEditSubversionController' => 'applications/diffusion/controller/DiffusionRepositoryEditSubversionController.php', 'DiffusionRepositoryEditUpdateController' => 'applications/diffusion/controller/DiffusionRepositoryEditUpdateController.php', 'DiffusionRepositoryListController' => 'applications/diffusion/controller/DiffusionRepositoryListController.php', + 'DiffusionRepositoryManageController' => 'applications/diffusion/controller/DiffusionRepositoryManageController.php', + 'DiffusionRepositoryManagementPanel' => 'applications/diffusion/management/DiffusionRepositoryManagementPanel.php', 'DiffusionRepositoryNewController' => 'applications/diffusion/controller/DiffusionRepositoryNewController.php', 'DiffusionRepositoryPath' => 'applications/diffusion/data/DiffusionRepositoryPath.php', 'DiffusionRepositoryRef' => 'applications/diffusion/data/DiffusionRepositoryRef.php', @@ -1880,6 +1884,7 @@ phutil_register_library_map(array( 'PhabricatorBadgesController' => 'applications/badges/controller/PhabricatorBadgesController.php', 'PhabricatorBadgesCreateCapability' => 'applications/badges/capability/PhabricatorBadgesCreateCapability.php', 'PhabricatorBadgesDAO' => 'applications/badges/storage/PhabricatorBadgesDAO.php', + 'PhabricatorBadgesDatasource' => 'applications/badges/typeahead/PhabricatorBadgesDatasource.php', 'PhabricatorBadgesDefaultEditCapability' => 'applications/badges/capability/PhabricatorBadgesDefaultEditCapability.php', 'PhabricatorBadgesEditConduitAPIMethod' => 'applications/badges/conduit/PhabricatorBadgesEditConduitAPIMethod.php', 'PhabricatorBadgesEditController' => 'applications/badges/controller/PhabricatorBadgesEditController.php', @@ -1986,6 +1991,12 @@ phutil_register_library_map(array( 'PhabricatorChatLogQuery' => 'applications/chatlog/query/PhabricatorChatLogQuery.php', 'PhabricatorChunkedFileStorageEngine' => 'applications/files/engine/PhabricatorChunkedFileStorageEngine.php', 'PhabricatorClusterConfigOptions' => 'applications/config/option/PhabricatorClusterConfigOptions.php', + 'PhabricatorClusterDatabasesConfigOptionType' => 'infrastructure/cluster/PhabricatorClusterDatabasesConfigOptionType.php', + 'PhabricatorClusterException' => 'infrastructure/cluster/PhabricatorClusterException.php', + 'PhabricatorClusterExceptionHandler' => 'infrastructure/cluster/PhabricatorClusterExceptionHandler.php', + 'PhabricatorClusterImpossibleWriteException' => 'infrastructure/cluster/PhabricatorClusterImpossibleWriteException.php', + 'PhabricatorClusterImproperWriteException' => 'infrastructure/cluster/PhabricatorClusterImproperWriteException.php', + 'PhabricatorClusterStrandedException' => 'infrastructure/cluster/PhabricatorClusterStrandedException.php', 'PhabricatorColumnProxyInterface' => 'applications/project/interface/PhabricatorColumnProxyInterface.php', 'PhabricatorColumnsEditField' => 'applications/transactions/editfield/PhabricatorColumnsEditField.php', 'PhabricatorCommentEditEngineExtension' => 'applications/transactions/engineextension/PhabricatorCommentEditEngineExtension.php', @@ -2028,6 +2039,8 @@ phutil_register_library_map(array( 'PhabricatorConfigAllController' => 'applications/config/controller/PhabricatorConfigAllController.php', 'PhabricatorConfigApplication' => 'applications/config/application/PhabricatorConfigApplication.php', 'PhabricatorConfigCacheController' => 'applications/config/controller/PhabricatorConfigCacheController.php', + 'PhabricatorConfigClusterDatabasesController' => 'applications/config/controller/PhabricatorConfigClusterDatabasesController.php', + 'PhabricatorConfigClusterNotificationsController' => 'applications/config/controller/PhabricatorConfigClusterNotificationsController.php', 'PhabricatorConfigCollectorsModule' => 'applications/config/module/PhabricatorConfigCollectorsModule.php', 'PhabricatorConfigColumnSchema' => 'applications/config/schema/PhabricatorConfigColumnSchema.php', 'PhabricatorConfigConfigPHIDType' => 'applications/config/phid/PhabricatorConfigConfigPHIDType.php', @@ -2147,6 +2160,7 @@ phutil_register_library_map(array( 'PhabricatorCustomFieldStringIndexStorage' => 'infrastructure/customfield/storage/PhabricatorCustomFieldStringIndexStorage.php', 'PhabricatorCustomHeaderConfigType' => 'applications/config/custom/PhabricatorCustomHeaderConfigType.php', 'PhabricatorDaemon' => 'infrastructure/daemon/PhabricatorDaemon.php', + 'PhabricatorDaemonBulkJobController' => 'applications/daemon/controller/PhabricatorDaemonBulkJobController.php', 'PhabricatorDaemonBulkJobListController' => 'applications/daemon/controller/PhabricatorDaemonBulkJobListController.php', 'PhabricatorDaemonBulkJobMonitorController' => 'applications/daemon/controller/PhabricatorDaemonBulkJobMonitorController.php', 'PhabricatorDaemonBulkJobViewController' => 'applications/daemon/controller/PhabricatorDaemonBulkJobViewController.php', @@ -2234,6 +2248,8 @@ phutil_register_library_map(array( 'PhabricatorDashboardViewController' => 'applications/dashboard/controller/PhabricatorDashboardViewController.php', 'PhabricatorDataCacheSpec' => 'applications/cache/spec/PhabricatorDataCacheSpec.php', 'PhabricatorDataNotAttachedException' => 'infrastructure/storage/lisk/PhabricatorDataNotAttachedException.php', + 'PhabricatorDatabaseHealthRecord' => 'infrastructure/cluster/PhabricatorDatabaseHealthRecord.php', + 'PhabricatorDatabaseRef' => 'infrastructure/cluster/PhabricatorDatabaseRef.php', 'PhabricatorDatabaseSetupCheck' => 'applications/config/check/PhabricatorDatabaseSetupCheck.php', 'PhabricatorDatasourceEditField' => 'applications/transactions/editfield/PhabricatorDatasourceEditField.php', 'PhabricatorDatasourceEditType' => 'applications/transactions/edittype/PhabricatorDatasourceEditType.php', @@ -2697,7 +2713,8 @@ phutil_register_library_map(array( 'PhabricatorNotificationPanelController' => 'applications/notification/controller/PhabricatorNotificationPanelController.php', 'PhabricatorNotificationQuery' => 'applications/notification/query/PhabricatorNotificationQuery.php', 'PhabricatorNotificationSearchEngine' => 'applications/notification/query/PhabricatorNotificationSearchEngine.php', - 'PhabricatorNotificationStatusController' => 'applications/notification/controller/PhabricatorNotificationStatusController.php', + 'PhabricatorNotificationServerRef' => 'applications/notification/client/PhabricatorNotificationServerRef.php', + 'PhabricatorNotificationServersConfigOptionType' => 'applications/notification/config/PhabricatorNotificationServersConfigOptionType.php', 'PhabricatorNotificationStatusView' => 'applications/notification/view/PhabricatorNotificationStatusView.php', 'PhabricatorNotificationTestController' => 'applications/notification/controller/PhabricatorNotificationTestController.php', 'PhabricatorNotificationTestFeedStory' => 'applications/notification/feed/PhabricatorNotificationTestFeedStory.php', @@ -3197,6 +3214,7 @@ phutil_register_library_map(array( 'PhabricatorRepositoryURITestCase' => 'applications/repository/storage/__tests__/PhabricatorRepositoryURITestCase.php', 'PhabricatorRepositoryVCSPassword' => 'applications/repository/storage/PhabricatorRepositoryVCSPassword.php', 'PhabricatorRepositoryVersion' => 'applications/repository/constants/PhabricatorRepositoryVersion.php', + 'PhabricatorRepositoryWorkingCopyVersion' => 'applications/repository/storage/PhabricatorRepositoryWorkingCopyVersion.php', 'PhabricatorRequestExceptionHandler' => 'aphront/handler/PhabricatorRequestExceptionHandler.php', 'PhabricatorResourceSite' => 'aphront/site/PhabricatorResourceSite.php', 'PhabricatorRobotsController' => 'applications/system/controller/PhabricatorRobotsController.php', @@ -3422,6 +3440,7 @@ phutil_register_library_map(array( 'PhabricatorSystemDAO' => 'applications/system/storage/PhabricatorSystemDAO.php', 'PhabricatorSystemDestructionGarbageCollector' => 'applications/system/garbagecollector/PhabricatorSystemDestructionGarbageCollector.php', 'PhabricatorSystemDestructionLog' => 'applications/system/storage/PhabricatorSystemDestructionLog.php', + 'PhabricatorSystemReadOnlyController' => 'applications/system/controller/PhabricatorSystemReadOnlyController.php', 'PhabricatorSystemRemoveDestroyWorkflow' => 'applications/system/management/PhabricatorSystemRemoveDestroyWorkflow.php', 'PhabricatorSystemRemoveLogWorkflow' => 'applications/system/management/PhabricatorSystemRemoveLogWorkflow.php', 'PhabricatorSystemRemoveWorkflow' => 'applications/system/management/PhabricatorSystemRemoveWorkflow.php', @@ -4821,6 +4840,7 @@ phutil_register_library_map(array( 'DiffusionHovercardEngineExtension' => 'PhabricatorHovercardEngineExtension', 'DiffusionInlineCommentController' => 'PhabricatorInlineCommentController', 'DiffusionInlineCommentPreviewController' => 'PhabricatorInlineCommentPreviewController', + 'DiffusionInternalGitRawDiffQueryConduitAPIMethod' => 'DiffusionQueryConduitAPIMethod', 'DiffusionLastModifiedController' => 'DiffusionController', 'DiffusionLastModifiedQueryConduitAPIMethod' => 'DiffusionQueryConduitAPIMethod', 'DiffusionLintController' => 'DiffusionController', @@ -4910,6 +4930,7 @@ phutil_register_library_map(array( 'DiffusionRefsQueryConduitAPIMethod' => 'DiffusionQueryConduitAPIMethod', 'DiffusionRenameHistoryQuery' => 'Phobject', 'DiffusionRepositoryByIDRemarkupRule' => 'PhabricatorObjectRemarkupRule', + 'DiffusionRepositoryClusterManagementPanel' => 'DiffusionRepositoryManagementPanel', 'DiffusionRepositoryController' => 'DiffusionController', 'DiffusionRepositoryCreateController' => 'DiffusionRepositoryEditController', 'DiffusionRepositoryDatasource' => 'PhabricatorTypeaheadDatasource', @@ -4930,6 +4951,8 @@ phutil_register_library_map(array( 'DiffusionRepositoryEditSubversionController' => 'DiffusionRepositoryEditController', 'DiffusionRepositoryEditUpdateController' => 'DiffusionRepositoryEditController', 'DiffusionRepositoryListController' => 'DiffusionController', + 'DiffusionRepositoryManageController' => 'DiffusionController', + 'DiffusionRepositoryManagementPanel' => 'Phobject', 'DiffusionRepositoryNewController' => 'DiffusionController', 'DiffusionRepositoryPath' => 'Phobject', 'DiffusionRepositoryRef' => 'Phobject', @@ -6264,6 +6287,7 @@ phutil_register_library_map(array( 'PhabricatorBadgesController' => 'PhabricatorController', 'PhabricatorBadgesCreateCapability' => 'PhabricatorPolicyCapability', 'PhabricatorBadgesDAO' => 'PhabricatorLiskDAO', + 'PhabricatorBadgesDatasource' => 'PhabricatorTypeaheadDatasource', 'PhabricatorBadgesDefaultEditCapability' => 'PhabricatorPolicyCapability', 'PhabricatorBadgesEditConduitAPIMethod' => 'PhabricatorEditEngineAPIMethod', 'PhabricatorBadgesEditController' => 'PhabricatorBadgesController', @@ -6392,6 +6416,12 @@ phutil_register_library_map(array( 'PhabricatorChatLogQuery' => 'PhabricatorCursorPagedPolicyAwareQuery', 'PhabricatorChunkedFileStorageEngine' => 'PhabricatorFileStorageEngine', 'PhabricatorClusterConfigOptions' => 'PhabricatorApplicationConfigOptions', + 'PhabricatorClusterDatabasesConfigOptionType' => 'PhabricatorConfigJSONOptionType', + 'PhabricatorClusterException' => 'Exception', + 'PhabricatorClusterExceptionHandler' => 'PhabricatorRequestExceptionHandler', + 'PhabricatorClusterImpossibleWriteException' => 'PhabricatorClusterException', + 'PhabricatorClusterImproperWriteException' => 'PhabricatorClusterException', + 'PhabricatorClusterStrandedException' => 'PhabricatorClusterException', 'PhabricatorColumnsEditField' => 'PhabricatorPHIDListEditField', 'PhabricatorCommentEditEngineExtension' => 'PhabricatorEditEngineExtension', 'PhabricatorCommentEditField' => 'PhabricatorEditField', @@ -6439,6 +6469,8 @@ phutil_register_library_map(array( 'PhabricatorConfigAllController' => 'PhabricatorConfigController', 'PhabricatorConfigApplication' => 'PhabricatorApplication', 'PhabricatorConfigCacheController' => 'PhabricatorConfigController', + 'PhabricatorConfigClusterDatabasesController' => 'PhabricatorConfigController', + 'PhabricatorConfigClusterNotificationsController' => 'PhabricatorConfigController', 'PhabricatorConfigCollectorsModule' => 'PhabricatorConfigModule', 'PhabricatorConfigColumnSchema' => 'PhabricatorConfigStorageSchema', 'PhabricatorConfigConfigPHIDType' => 'PhabricatorPHIDType', @@ -6573,9 +6605,10 @@ phutil_register_library_map(array( 'PhabricatorCustomFieldStringIndexStorage' => 'PhabricatorCustomFieldIndexStorage', 'PhabricatorCustomHeaderConfigType' => 'PhabricatorConfigOptionType', 'PhabricatorDaemon' => 'PhutilDaemon', - 'PhabricatorDaemonBulkJobListController' => 'PhabricatorDaemonController', - 'PhabricatorDaemonBulkJobMonitorController' => 'PhabricatorDaemonController', - 'PhabricatorDaemonBulkJobViewController' => 'PhabricatorDaemonController', + 'PhabricatorDaemonBulkJobController' => 'PhabricatorDaemonController', + 'PhabricatorDaemonBulkJobListController' => 'PhabricatorDaemonBulkJobController', + 'PhabricatorDaemonBulkJobMonitorController' => 'PhabricatorDaemonBulkJobController', + 'PhabricatorDaemonBulkJobViewController' => 'PhabricatorDaemonBulkJobController', 'PhabricatorDaemonConsoleController' => 'PhabricatorDaemonController', 'PhabricatorDaemonContentSource' => 'PhabricatorContentSource', 'PhabricatorDaemonController' => 'PhabricatorController', @@ -6681,6 +6714,8 @@ phutil_register_library_map(array( 'PhabricatorDashboardViewController' => 'PhabricatorDashboardController', 'PhabricatorDataCacheSpec' => 'PhabricatorCacheSpec', 'PhabricatorDataNotAttachedException' => 'Exception', + 'PhabricatorDatabaseHealthRecord' => 'Phobject', + 'PhabricatorDatabaseRef' => 'Phobject', 'PhabricatorDatabaseSetupCheck' => 'PhabricatorSetupCheck', 'PhabricatorDatasourceEditField' => 'PhabricatorTokenizerEditField', 'PhabricatorDatasourceEditType' => 'PhabricatorPHIDListEditType', @@ -7197,7 +7232,8 @@ phutil_register_library_map(array( 'PhabricatorNotificationPanelController' => 'PhabricatorNotificationController', 'PhabricatorNotificationQuery' => 'PhabricatorCursorPagedPolicyAwareQuery', 'PhabricatorNotificationSearchEngine' => 'PhabricatorApplicationSearchEngine', - 'PhabricatorNotificationStatusController' => 'PhabricatorNotificationController', + 'PhabricatorNotificationServerRef' => 'Phobject', + 'PhabricatorNotificationServersConfigOptionType' => 'PhabricatorConfigJSONOptionType', 'PhabricatorNotificationStatusView' => 'AphrontTagView', 'PhabricatorNotificationTestController' => 'PhabricatorNotificationController', 'PhabricatorNotificationTestFeedStory' => 'PhabricatorFeedStory', @@ -7827,6 +7863,7 @@ phutil_register_library_map(array( 'PhabricatorRepositoryURITestCase' => 'PhabricatorTestCase', 'PhabricatorRepositoryVCSPassword' => 'PhabricatorRepositoryDAO', 'PhabricatorRepositoryVersion' => 'Phobject', + 'PhabricatorRepositoryWorkingCopyVersion' => 'PhabricatorRepositoryDAO', 'PhabricatorRequestExceptionHandler' => 'AphrontRequestExceptionHandler', 'PhabricatorResourceSite' => 'PhabricatorSite', 'PhabricatorRobotsController' => 'PhabricatorController', @@ -8070,6 +8107,7 @@ phutil_register_library_map(array( 'PhabricatorSystemDAO' => 'PhabricatorLiskDAO', 'PhabricatorSystemDestructionGarbageCollector' => 'PhabricatorGarbageCollector', 'PhabricatorSystemDestructionLog' => 'PhabricatorSystemDAO', + 'PhabricatorSystemReadOnlyController' => 'PhabricatorController', 'PhabricatorSystemRemoveDestroyWorkflow' => 'PhabricatorSystemRemoveWorkflow', 'PhabricatorSystemRemoveLogWorkflow' => 'PhabricatorSystemRemoveWorkflow', 'PhabricatorSystemRemoveWorkflow' => 'PhabricatorManagementWorkflow', diff --git a/src/aphront/configuration/AphrontApplicationConfiguration.php b/src/aphront/configuration/AphrontApplicationConfiguration.php index b48ea3be13..dc32faedec 100644 --- a/src/aphront/configuration/AphrontApplicationConfiguration.php +++ b/src/aphront/configuration/AphrontApplicationConfiguration.php @@ -345,6 +345,18 @@ abstract class AphrontApplicationConfiguration extends Phobject { if ($site->shouldRequireHTTPS()) { if (!$request->isHTTPS()) { + + // Don't redirect intracluster requests: doing so drops headers and + // parameters, imposes a performance penalty, and indicates a + // misconfiguration. + if ($request->isProxiedClusterRequest()) { + throw new AphrontMalformedRequestException( + pht('HTTPS Required'), + pht( + 'This request reached a site which requires HTTPS, but the '. + 'request is not marked as HTTPS.')); + } + $https_uri = $request->getRequestURI(); $https_uri->setDomain($request->getHost()); $https_uri->setProtocol('https'); diff --git a/src/aphront/site/PhabricatorSite.php b/src/aphront/site/PhabricatorSite.php index 866ca178ca..95e0a538fa 100644 --- a/src/aphront/site/PhabricatorSite.php +++ b/src/aphront/site/PhabricatorSite.php @@ -3,6 +3,15 @@ abstract class PhabricatorSite extends AphrontSite { public function shouldRequireHTTPS() { + // If this is an intracluster request, it's okay for it to use HTTP even + // if the site otherwise requires HTTPS. It is common to terminate SSL at + // a load balancer and use plain HTTP from then on, and administrators are + // usually not concerned about attackers observing traffic within a + // datacenter. + if (PhabricatorEnv::isClusterRemoteAddress()) { + return false; + } + return PhabricatorEnv::getEnvConfig('security.require-https'); } diff --git a/src/applications/almanac/application/PhabricatorAlmanacApplication.php b/src/applications/almanac/application/PhabricatorAlmanacApplication.php index 76caa3a06b..27b41e9cd0 100644 --- a/src/applications/almanac/application/PhabricatorAlmanacApplication.php +++ b/src/applications/almanac/application/PhabricatorAlmanacApplication.php @@ -83,8 +83,7 @@ final class PhabricatorAlmanacApplication extends PhabricatorApplication { phutil_tag( 'a', array( - 'href' => PhabricatorEnv::getDoclink( - 'User Guide: Phabricator Clusters'), + 'href' => PhabricatorEnv::getDoclink('Clustering Introduction'), 'target' => '_blank', ), pht('Learn More'))); diff --git a/src/applications/almanac/controller/AlmanacController.php b/src/applications/almanac/controller/AlmanacController.php index c23e99591d..e4d9921660 100644 --- a/src/applications/almanac/controller/AlmanacController.php +++ b/src/applications/almanac/controller/AlmanacController.php @@ -178,7 +178,7 @@ abstract class AlmanacController 'a', array( 'href' => PhabricatorEnv::getDoclink( - 'User Guide: Phabricator Clusters'), + 'Clustering Introduction'), 'target' => '_blank', ), pht('Learn More')); diff --git a/src/applications/almanac/controller/AlmanacDeviceViewController.php b/src/applications/almanac/controller/AlmanacDeviceViewController.php index efc4334132..000c8f8971 100644 --- a/src/applications/almanac/controller/AlmanacDeviceViewController.php +++ b/src/applications/almanac/controller/AlmanacDeviceViewController.php @@ -117,7 +117,7 @@ final class AlmanacDeviceViewController ->setCanEdit($can_edit); $header = id(new PHUIHeaderView()) - ->setHeader(pht('DEVICE INTERFACES')) + ->setHeader(pht('Device Interfaces')) ->addActionLink( id(new PHUIButtonView()) ->setTag('a') @@ -167,7 +167,7 @@ final class AlmanacDeviceViewController $upload_uri = '/auth/sshkey/upload/?objectPHID='.$device_phid; $header = id(new PHUIHeaderView()) - ->setHeader(pht('SSH PUBLIC KEYS')) + ->setHeader(pht('SSH Public Keys')) ->addActionLink( id(new PHUIButtonView()) ->setTag('a') @@ -238,7 +238,7 @@ final class AlmanacDeviceViewController )); return id(new PHUIObjectBoxView()) - ->setHeaderText(pht('BOUND SERVICES')) + ->setHeaderText(pht('Bound Services')) ->setBackground(PHUIObjectBoxView::BLUE_PROPERTY) ->setTable($table); } diff --git a/src/applications/almanac/util/AlmanacKeys.php b/src/applications/almanac/util/AlmanacKeys.php index dec49a08a7..d15d3cb439 100644 --- a/src/applications/almanac/util/AlmanacKeys.php +++ b/src/applications/almanac/util/AlmanacKeys.php @@ -19,4 +19,33 @@ final class AlmanacKeys extends Phobject { return null; } + public static function getLiveDevice() { + $device_id = self::getDeviceID(); + if (!$device_id) { + return null; + } + + $cache = PhabricatorCaches::getRequestCache(); + $cache_key = 'almanac.device.self'; + + $device = $cache->getKey($cache_key); + if (!$device) { + $viewer = PhabricatorUser::getOmnipotentUser(); + $device = id(new AlmanacDeviceQuery()) + ->setViewer($viewer) + ->withNames(array($device_id)) + ->executeOne(); + if (!$device) { + throw new Exception( + pht( + 'This host has device ID "%s", but there is no corresponding '. + 'device record in Almanac.', + $device_id)); + } + $cache->setKey($cache_key, $device); + } + + return $device; + } + } diff --git a/src/applications/aphlict/management/PhabricatorAphlictManagementDebugWorkflow.php b/src/applications/aphlict/management/PhabricatorAphlictManagementDebugWorkflow.php index 2fda59e6fe..27459b79ad 100644 --- a/src/applications/aphlict/management/PhabricatorAphlictManagementDebugWorkflow.php +++ b/src/applications/aphlict/management/PhabricatorAphlictManagementDebugWorkflow.php @@ -4,17 +4,18 @@ final class PhabricatorAphlictManagementDebugWorkflow extends PhabricatorAphlictManagementWorkflow { protected function didConstruct() { - parent::didConstruct(); $this ->setName('debug') ->setSynopsis( pht( 'Start the notifications server in the foreground and print large '. - 'volumes of diagnostic information to the console.')); + 'volumes of diagnostic information to the console.')) + ->setArguments($this->getLaunchArguments()); } public function execute(PhutilArgumentParser $args) { - parent::execute($args); + $this->parseLaunchArguments($args); + $this->setDebug(true); $this->willLaunch(); diff --git a/src/applications/aphlict/management/PhabricatorAphlictManagementRestartWorkflow.php b/src/applications/aphlict/management/PhabricatorAphlictManagementRestartWorkflow.php index 55d97a9ac5..787003e382 100644 --- a/src/applications/aphlict/management/PhabricatorAphlictManagementRestartWorkflow.php +++ b/src/applications/aphlict/management/PhabricatorAphlictManagementRestartWorkflow.php @@ -4,19 +4,20 @@ final class PhabricatorAphlictManagementRestartWorkflow extends PhabricatorAphlictManagementWorkflow { protected function didConstruct() { - parent::didConstruct(); $this ->setName('restart') - ->setSynopsis(pht('Stop, then start the notifications server.')); + ->setSynopsis(pht('Stop, then start the notification server.')) + ->setArguments($this->getLaunchArguments()); } public function execute(PhutilArgumentParser $args) { - parent::execute($args); + $this->parseLaunchArguments($args); $err = $this->executeStopCommand(); if ($err) { return $err; } + return $this->executeStartCommand(); } diff --git a/src/applications/aphlict/management/PhabricatorAphlictManagementStartWorkflow.php b/src/applications/aphlict/management/PhabricatorAphlictManagementStartWorkflow.php index 4217ac5903..bd034f8c88 100644 --- a/src/applications/aphlict/management/PhabricatorAphlictManagementStartWorkflow.php +++ b/src/applications/aphlict/management/PhabricatorAphlictManagementStartWorkflow.php @@ -4,14 +4,14 @@ final class PhabricatorAphlictManagementStartWorkflow extends PhabricatorAphlictManagementWorkflow { protected function didConstruct() { - parent::didConstruct(); $this ->setName('start') - ->setSynopsis(pht('Start the notifications server.')); + ->setSynopsis(pht('Start the notifications server.')) + ->setArguments($this->getLaunchArguments()); } public function execute(PhutilArgumentParser $args) { - parent::execute($args); + $this->parseLaunchArguments($args); return $this->executeStartCommand(); } diff --git a/src/applications/aphlict/management/PhabricatorAphlictManagementStatusWorkflow.php b/src/applications/aphlict/management/PhabricatorAphlictManagementStatusWorkflow.php index 85f80bfad4..c9a0768bf2 100644 --- a/src/applications/aphlict/management/PhabricatorAphlictManagementStatusWorkflow.php +++ b/src/applications/aphlict/management/PhabricatorAphlictManagementStatusWorkflow.php @@ -6,7 +6,7 @@ final class PhabricatorAphlictManagementStatusWorkflow protected function didConstruct() { $this ->setName('status') - ->setSynopsis(pht('Show the status of the notifications server.')) + ->setSynopsis(pht('Show the status of the notification server.')) ->setArguments(array()); } diff --git a/src/applications/aphlict/management/PhabricatorAphlictManagementStopWorkflow.php b/src/applications/aphlict/management/PhabricatorAphlictManagementStopWorkflow.php index f685afbaab..f7c270352b 100644 --- a/src/applications/aphlict/management/PhabricatorAphlictManagementStopWorkflow.php +++ b/src/applications/aphlict/management/PhabricatorAphlictManagementStopWorkflow.php @@ -6,11 +6,12 @@ final class PhabricatorAphlictManagementStopWorkflow protected function didConstruct() { $this ->setName('stop') - ->setSynopsis(pht('Stop the notifications server.')) - ->setArguments(array()); + ->setSynopsis(pht('Stop the notification server.')) + ->setArguments($this->getLaunchArguments()); } public function execute(PhutilArgumentParser $args) { + $this->parseLaunchArguments($args); return $this->executeStopCommand(); } diff --git a/src/applications/aphlict/management/PhabricatorAphlictManagementWorkflow.php b/src/applications/aphlict/management/PhabricatorAphlictManagementWorkflow.php index 659fb80edb..d45c9dd50a 100644 --- a/src/applications/aphlict/management/PhabricatorAphlictManagementWorkflow.php +++ b/src/applications/aphlict/management/PhabricatorAphlictManagementWorkflow.php @@ -4,66 +4,293 @@ abstract class PhabricatorAphlictManagementWorkflow extends PhabricatorManagementWorkflow { private $debug = false; - private $clientHost; - private $clientPort; + private $configData; + private $configPath; - protected function didConstruct() { - $this - ->setArguments( - array( - array( - 'name' => 'client-host', - 'param' => 'hostname', - 'help' => pht('Hostname to bind to for the client server.'), - ), - array( - 'name' => 'client-port', - 'param' => 'port', - 'help' => pht('Port to bind to for the client server.'), - ), - )); + final protected function setDebug($debug) { + $this->debug = $debug; + return $this; } - public function execute(PhutilArgumentParser $args) { - $this->clientHost = $args->getArg('client-host'); - $this->clientPort = $args->getArg('client-port'); - return 0; + protected function getLaunchArguments() { + return array( + array( + 'name' => 'config', + 'param' => 'file', + 'help' => pht( + 'Use a specific configuration file instead of the default '. + 'configuration.'), + ), + ); + } + + protected function parseLaunchArguments(PhutilArgumentParser $args) { + $config_file = $args->getArg('config'); + if ($config_file) { + $full_path = Filesystem::resolvePath($config_file); + $show_path = $full_path; + } else { + $root = dirname(dirname(phutil_get_library_root('phabricator'))); + + $try = array( + 'phabricator/conf/aphlict/aphlict.custom.json', + 'phabricator/conf/aphlict/aphlict.default.json', + ); + + foreach ($try as $config) { + $full_path = $root.'/'.$config; + $show_path = $config; + if (Filesystem::pathExists($full_path)) { + break; + } + } + } + + echo tsprintf( + "%s\n", + pht( + 'Reading configuration from: %s', + $show_path)); + + try { + $data = Filesystem::readFile($full_path); + } catch (Exception $ex) { + throw new PhutilArgumentUsageException( + pht( + 'Failed to read configuration file. %s', + $ex->getMessage())); + } + + try { + $data = phutil_json_decode($data); + } catch (Exception $ex) { + throw new PhutilArgumentUsageException( + pht( + 'Configuration file is not properly formatted JSON. %s', + $ex->getMessage())); + } + + try { + PhutilTypeSpec::checkMap( + $data, + array( + 'servers' => 'list', + 'logs' => 'optional list', + 'cluster' => 'optional list', + 'pidfile' => 'string', + 'memory.hint' => 'optional int', + )); + } catch (Exception $ex) { + throw new PhutilArgumentUsageException( + pht( + 'Configuration file has improper configuration keys at top '. + 'level. %s', + $ex->getMessage())); + } + + $servers = $data['servers']; + $has_client = false; + $has_admin = false; + $port_map = array(); + foreach ($servers as $index => $server) { + PhutilTypeSpec::checkMap( + $server, + array( + 'type' => 'string', + 'port' => 'int', + 'listen' => 'optional string|null', + 'ssl.key' => 'optional string|null', + 'ssl.cert' => 'optional string|null', + 'ssl.chain' => 'optional string|null', + )); + + $port = $server['port']; + if (!isset($port_map[$port])) { + $port_map[$port] = $index; + } else { + throw new PhutilArgumentUsageException( + pht( + 'Two servers (at indexes "%s" and "%s") both bind to the same '. + 'port ("%s"). Each server must bind to a unique port.', + $port_map[$port], + $index, + $port)); + } + + $type = $server['type']; + switch ($type) { + case 'admin': + $has_admin = true; + break; + case 'client': + $has_client = true; + break; + default: + throw new PhutilArgumentUsageException( + pht( + 'A specified server (at index "%s", on port "%s") has an '. + 'invalid type ("%s"). Valid types are: admin, client.', + $index, + $port, + $type)); + } + + $ssl_key = idx($server, 'ssl.key'); + $ssl_cert = idx($server, 'ssl.cert'); + if (($ssl_key && !$ssl_cert) || ($ssl_cert && !$ssl_key)) { + throw new PhutilArgumentUsageException( + pht( + 'A specified server (at index "%s", on port "%s") specifies '. + 'only one of "%s" and "%s". Each server must specify neither '. + '(to disable SSL) or specify both (to enable it).', + $index, + $port, + 'ssl.key', + 'ssl.cert')); + } + + $ssl_chain = idx($server, 'ssl.chain'); + if ($ssl_chain && (!$ssl_key && !$ssl_cert)) { + throw new PhutilArgumentUsageException( + pht( + 'A specified server (at index "%s", on port "%s") specifies '. + 'a value for "%s", but no value for "%s" or "%s". Servers '. + 'should only provide an SSL chain if they also provide an SSL '. + 'key and SSL certificate.', + $index, + $port, + 'ssl.chain', + 'ssl.key', + 'ssl.cert')); + } + } + + if (!$servers) { + throw new PhutilArgumentUsageException( + pht( + 'Configuration file does not specify any servers. This service '. + 'will not be able to interact with the outside world if it does '. + 'not listen on any ports. You must specify at least one "%s" '. + 'server and at least one "%s" server.', + 'admin', + 'client')); + } + + if (!$has_client) { + throw new PhutilArgumentUsageException( + pht( + 'Configuration file does not specify any client servers. This '. + 'service will be unable to transmit any notifications without a '. + 'client server. You must specify at least one server with '. + 'type "%s".', + 'client')); + } + + if (!$has_admin) { + throw new PhutilArgumentUsageException( + pht( + 'Configuration file does not specify any administrative '. + 'servers. This service will be unable to receive messages. '. + 'You must specify at least one server with type "%s".', + 'admin')); + } + + $logs = idx($data, 'logs', array()); + foreach ($logs as $index => $log) { + PhutilTypeSpec::checkMap( + $log, + array( + 'path' => 'string', + )); + + $path = $log['path']; + + try { + $dir = dirname($path); + if (!Filesystem::pathExists($dir)) { + Filesystem::createDirectory($dir, 0755, true); + } + } catch (FilesystemException $ex) { + throw new PhutilArgumentUsageException( + pht( + 'Failed to create directory "%s" for specified log file (with '. + 'index "%s"). You should manually create this directory or '. + 'choose a different logfile location. %s', + $dir, + $ex->getMessage())); + } + } + + $peer_map = array(); + + $cluster = idx($data, 'cluster', array()); + foreach ($cluster as $index => $peer) { + PhutilTypeSpec::checkMap( + $peer, + array( + 'host' => 'string', + 'port' => 'int', + 'protocol' => 'string', + )); + + $host = $peer['host']; + $port = $peer['port']; + $protocol = $peer['protocol']; + + switch ($protocol) { + case 'http': + case 'https': + break; + default: + throw new PhutilArgumentUsageException( + pht( + 'Configuration file specifies cluster peer ("%s", at index '. + '"%s") with an invalid protocol, "%s". Valid protocols are '. + '"%s" or "%s".', + $host, + $index, + $protocol, + 'http', + 'https')); + } + + $peer_key = "{$host}:{$port}"; + if (!isset($peer_map[$peer_key])) { + $peer_map[$peer_key] = $index; + } else { + throw new PhutilArgumentUsageException( + pht( + 'Configuration file specifies cluster peer "%s" more than '. + 'once (at indexes "%s" and "%s"). Each peer must have a '. + 'unique host and port combination.', + $peer_key, + $peer_map[$peer_key], + $index)); + } + } + + $this->configData = $data; + $this->configPath = $full_path; + + $pid_path = $this->getPIDPath(); + try { + $dir = dirname($path); + if (!Filesystem::pathExists($dir)) { + Filesystem::createDirectory($dir, 0755, true); + } + } catch (FilesystemException $ex) { + throw new PhutilArgumentUsageException( + pht( + 'Failed to create directory "%s" for specified PID file. You '. + 'should manually create this directory or choose a different '. + 'PID file location. %s', + $dir, + $ex->getMessage())); + } } final public function getPIDPath() { - $path = PhabricatorEnv::getEnvConfig('notification.pidfile'); - - try { - $dir = dirname($path); - if (!Filesystem::pathExists($dir)) { - Filesystem::createDirectory($dir, 0755, true); - } - } catch (FilesystemException $ex) { - throw new Exception( - pht( - "Failed to create '%s'. You should manually create this directory.", - $dir)); - } - - return $path; - } - - final public function getLogPath() { - $path = PhabricatorEnv::getEnvConfig('notification.log'); - - try { - $dir = dirname($path); - if (!Filesystem::pathExists($dir)) { - Filesystem::createDirectory($dir, 0755, true); - } - } catch (FilesystemException $ex) { - throw new Exception( - pht( - "Failed to create '%s'. You should manually create this directory.", - $dir)); - } - - return $path; + return $this->configData['pidfile']; } final public function getPID() { @@ -86,11 +313,6 @@ abstract class PhabricatorAphlictManagementWorkflow exit(1); } - final protected function setDebug($debug) { - $this->debug = $debug; - return $this; - } - public static function requireExtensions() { self::mustHaveExtension('pcntl'); self::mustHaveExtension('posix'); @@ -146,54 +368,16 @@ abstract class PhabricatorAphlictManagementWorkflow $test_argv = $this->getServerArgv(); $test_argv[] = '--test=true'; - execx( - '%s %s %Ls', - $this->getNodeBinary(), - $this->getAphlictScriptPath(), - $test_argv); + + execx('%C', $this->getStartCommand($test_argv)); } private function getServerArgv() { - $ssl_key = PhabricatorEnv::getEnvConfig('notification.ssl-key'); - $ssl_cert = PhabricatorEnv::getEnvConfig('notification.ssl-cert'); - - $server_uri = PhabricatorEnv::getEnvConfig('notification.server-uri'); - $server_uri = new PhutilURI($server_uri); - - $client_uri = PhabricatorEnv::getEnvConfig('notification.client-uri'); - $client_uri = new PhutilURI($client_uri); - - $log = $this->getLogPath(); - $server_argv = array(); - $server_argv[] = '--client-port='.coalesce( - $this->clientPort, - $client_uri->getPort()); - $server_argv[] = '--admin-port='.$server_uri->getPort(); - $server_argv[] = '--admin-host='.$server_uri->getDomain(); - - if ($ssl_key) { - $server_argv[] = '--ssl-key='.$ssl_key; - } - - if ($ssl_cert) { - $server_argv[] = '--ssl-cert='.$ssl_cert; - } - - $server_argv[] = '--log='.$log; - - if ($this->clientHost) { - $server_argv[] = '--client-host='.$this->clientHost; - } - + $server_argv[] = '--config='.$this->configPath; return $server_argv; } - private function getAphlictScriptPath() { - $root = dirname(phutil_get_library_root('phabricator')); - return $root.'/support/aphlict/server/aphlict_server.js'; - } - final protected function launch() { $console = PhutilConsole::getConsole(); @@ -205,11 +389,7 @@ abstract class PhabricatorAphlictManagementWorkflow Filesystem::writeFile($this->getPIDPath(), getmypid()); } - $command = csprintf( - '%s %s %Ls', - $this->getNodeBinary(), - $this->getAphlictScriptPath(), - $this->getServerArgv()); + $command = $this->getStartCommand($this->getServerArgv()); if (!$this->debug) { declare(ticks = 1); @@ -267,7 +447,6 @@ abstract class PhabricatorAphlictManagementWorkflow fclose(STDOUT); fclose(STDERR); - $this->launch(); return 0; } @@ -325,4 +504,29 @@ abstract class PhabricatorAphlictManagementWorkflow '$PATH')); } + private function getAphlictScriptPath() { + $root = dirname(phutil_get_library_root('phabricator')); + return $root.'/support/aphlict/server/aphlict_server.js'; + } + + private function getNodeArgv() { + $argv = array(); + + $hint = idx($this->configData, 'memory.hint'); + $hint = nonempty($hint, 256); + + $argv[] = sprintf('--max-old-space-size=%d', $hint); + + return $argv; + } + + private function getStartCommand(array $server_argv) { + return csprintf( + '%R %Ls -- %s %Ls', + $this->getNodeBinary(), + $this->getNodeArgv(), + $this->getAphlictScriptPath(), + $server_argv); + } + } diff --git a/src/applications/badges/controller/PhabricatorBadgesAwardController.php b/src/applications/badges/controller/PhabricatorBadgesAwardController.php index 0475dd6277..0e5679caa4 100644 --- a/src/applications/badges/controller/PhabricatorBadgesAwardController.php +++ b/src/applications/badges/controller/PhabricatorBadgesAwardController.php @@ -18,60 +18,52 @@ final class PhabricatorBadgesAwardController $view_uri = '/p/'.$user->getUsername(); if ($request->isFormPost()) { - $xactions = array(); - $badge_phid = $request->getStr('badgePHID'); - $badge = id(new PhabricatorBadgesQuery()) + $badge_phids = $request->getArr('badgePHIDs'); + $badges = id(new PhabricatorBadgesQuery()) ->setViewer($viewer) - ->withPHIDs(array($badge_phid)) + ->withPHIDs($badge_phids) ->needRecipients(true) ->requireCapabilities( array( PhabricatorPolicyCapability::CAN_EDIT, PhabricatorPolicyCapability::CAN_VIEW, )) - ->executeOne(); - if (!$badge) { + ->execute(); + if (!$badges) { return new Aphront404Response(); } $award_phids = array($user->getPHID()); - $xactions[] = id(new PhabricatorBadgesTransaction()) - ->setTransactionType(PhabricatorBadgesTransaction::TYPE_AWARD) - ->setNewValue($award_phids); + foreach ($badges as $badge) { + $xactions = array(); + $xactions[] = id(new PhabricatorBadgesTransaction()) + ->setTransactionType(PhabricatorBadgesTransaction::TYPE_AWARD) + ->setNewValue($award_phids); - $editor = id(new PhabricatorBadgesEditor($badge)) - ->setActor($viewer) - ->setContentSourceFromRequest($request) - ->setContinueOnNoEffect(true) - ->setContinueOnMissingFields(true) - ->applyTransactions($badge, $xactions); + $editor = id(new PhabricatorBadgesEditor($badge)) + ->setActor($viewer) + ->setContentSourceFromRequest($request) + ->setContinueOnNoEffect(true) + ->setContinueOnMissingFields(true) + ->applyTransactions($badge, $xactions); + } return id(new AphrontRedirectResponse()) ->setURI($view_uri); } - $badges = id(new PhabricatorBadgesQuery()) - ->setViewer($viewer) - ->withStatuses(array( - PhabricatorBadgesBadge::STATUS_ACTIVE, - )) - ->requireCapabilities( - array( - PhabricatorPolicyCapability::CAN_VIEW, - PhabricatorPolicyCapability::CAN_EDIT, - )) - ->execute(); - - $options = mpull($badges, 'getName', 'getPHID'); - asort($options); - $form = id(new AphrontFormView()) ->setUser($viewer) - ->appendChild( - id(new AphrontFormSelectControl()) + ->appendControl( + id(new AphrontFormTokenizerControl()) ->setLabel(pht('Badge')) - ->setName('badgePHID') - ->setOptions($options)); + ->setName('badgePHIDs') + ->setDatasource( + id(new PhabricatorBadgesDatasource()) + ->setParameters( + array( + 'recipientPHID' => $user->getPHID(), + )))); $dialog = $this->newDialog() ->setTitle(pht('Grant Badge')) diff --git a/src/applications/badges/typeahead/PhabricatorBadgesDatasource.php b/src/applications/badges/typeahead/PhabricatorBadgesDatasource.php new file mode 100644 index 0000000000..458c9230d5 --- /dev/null +++ b/src/applications/badges/typeahead/PhabricatorBadgesDatasource.php @@ -0,0 +1,69 @@ +getViewer(); + $raw_query = $this->getRawQuery(); + + $params = $this->getParameters(); + $recipient_phid = $params['recipientPHID']; + + $badges = id(new PhabricatorBadgesQuery()) + ->setViewer($viewer) + ->requireCapabilities( + array( + PhabricatorPolicyCapability::CAN_VIEW, + PhabricatorPolicyCapability::CAN_EDIT, + )) + ->execute(); + + $awards = id(new PhabricatorBadgesAwardQuery()) + ->setViewer($viewer) + ->withAwarderPHIDs(array($viewer->getPHID())) + ->withRecipientPHIDs(array($recipient_phid)) + ->execute(); + $awards = mpull($awards, null, 'getBadgePHID'); + + $results = array(); + foreach ($badges as $badge) { + $closed = null; + + $badge_awards = idx($awards, $badge->getPHID(), null); + if ($badge_awards) { + $closed = pht('Already awarded'); + } + + $status = $badge->getStatus(); + if ($status === PhabricatorBadgesBadge::STATUS_ARCHIVED) { + $closed = pht('Archived'); + } + + $results[] = id(new PhabricatorTypeaheadResult()) + ->setName($badge->getName()) + ->setIcon($badge->getIcon()) + ->setColor( + PhabricatorBadgesQuality::getQualityColor($badge->getQuality())) + ->setClosed($closed) + ->setPHID($badge->getPHID()); + } + + $results = $this->filterResultsAgainstTokens($results); + + return $results; + } + +} diff --git a/src/applications/badges/view/PhabricatorBadgesRecipientsListView.php b/src/applications/badges/view/PhabricatorBadgesRecipientsListView.php index 7b2ceaa38b..c5d3a56cda 100644 --- a/src/applications/badges/view/PhabricatorBadgesRecipientsListView.php +++ b/src/applications/badges/view/PhabricatorBadgesRecipientsListView.php @@ -62,7 +62,7 @@ final class PhabricatorBadgesRecipientsListView extends AphrontView { } $box = id(new PHUIObjectBoxView()) - ->setHeaderText(pht('RECIPIENTS')) + ->setHeaderText(pht('Recipients')) ->setBackground(PHUIObjectBoxView::BLUE_PROPERTY) ->setObjectList($list); diff --git a/src/applications/cache/PhabricatorCaches.php b/src/applications/cache/PhabricatorCaches.php index 1127a7312b..e9baa299a2 100644 --- a/src/applications/cache/PhabricatorCaches.php +++ b/src/applications/cache/PhabricatorCaches.php @@ -174,6 +174,11 @@ final class PhabricatorCaches extends Phobject { * @task setup */ private static function buildSetupCaches() { + // If this is the CLI, just build a setup cache. + if (php_sapi_name() == 'cli') { + return array(); + } + // In most cases, we should have APC. This is an ideal cache for our // purposes -- it's fast and empties on server restart. $apc = new PhutilAPCKeyValueCache(); diff --git a/src/applications/cache/PhabricatorKeyValueDatabaseCache.php b/src/applications/cache/PhabricatorKeyValueDatabaseCache.php index 85bc57f8b7..99060478c0 100644 --- a/src/applications/cache/PhabricatorKeyValueDatabaseCache.php +++ b/src/applications/cache/PhabricatorKeyValueDatabaseCache.php @@ -7,6 +7,10 @@ final class PhabricatorKeyValueDatabaseCache const CACHE_FORMAT_DEFLATE = 'deflate'; public function setKeys(array $keys, $ttl = null) { + if (PhabricatorEnv::isReadOnly()) { + return; + } + if ($keys) { $map = $this->digestKeys(array_keys($keys)); $conn_w = $this->establishConnection('w'); @@ -30,19 +34,19 @@ final class PhabricatorKeyValueDatabaseCache $guard = AphrontWriteGuard::beginScopedUnguardedWrites(); foreach (PhabricatorLiskDAO::chunkSQL($sql) as $chunk) { - queryfx( - $conn_w, - 'INSERT INTO %T - (cacheKeyHash, cacheKey, cacheFormat, cacheData, - cacheCreated, cacheExpires) VALUES %Q - ON DUPLICATE KEY UPDATE - cacheKey = VALUES(cacheKey), - cacheFormat = VALUES(cacheFormat), - cacheData = VALUES(cacheData), - cacheCreated = VALUES(cacheCreated), - cacheExpires = VALUES(cacheExpires)', - $this->getTableName(), - $chunk); + queryfx( + $conn_w, + 'INSERT INTO %T + (cacheKeyHash, cacheKey, cacheFormat, cacheData, + cacheCreated, cacheExpires) VALUES %Q + ON DUPLICATE KEY UPDATE + cacheKey = VALUES(cacheKey), + cacheFormat = VALUES(cacheFormat), + cacheData = VALUES(cacheData), + cacheCreated = VALUES(cacheCreated), + cacheExpires = VALUES(cacheExpires)', + $this->getTableName(), + $chunk); } unset($guard); } diff --git a/src/applications/conduit/controller/PhabricatorConduitAPIController.php b/src/applications/conduit/controller/PhabricatorConduitAPIController.php index 005b23d505..c4f3e65c35 100644 --- a/src/applications/conduit/controller/PhabricatorConduitAPIController.php +++ b/src/applications/conduit/controller/PhabricatorConduitAPIController.php @@ -402,6 +402,23 @@ final class PhabricatorConduitAPIController $user); } + + // For intracluster requests, use a public user if no authentication + // information is provided. We could do this safely for any request, + // but making the API fully public means there's no way to disable badly + // behaved clients. + if (PhabricatorEnv::isClusterRemoteAddress()) { + if (PhabricatorEnv::getEnvConfig('policy.allow-public')) { + $api_request->setIsClusterRequest(true); + + $user = new PhabricatorUser(); + return $this->validateAuthenticatedUser( + $api_request, + $user); + } + } + + // Handle sessionless auth. // TODO: This is super messy. // TODO: Remove this in favor of token-based auth. diff --git a/src/applications/conduit/method/ConduitAPIMethod.php b/src/applications/conduit/method/ConduitAPIMethod.php index 5b6c16bb93..7992518919 100644 --- a/src/applications/conduit/method/ConduitAPIMethod.php +++ b/src/applications/conduit/method/ConduitAPIMethod.php @@ -52,6 +52,9 @@ abstract class ConduitAPIMethod abstract protected function execute(ConduitAPIRequest $request); + public function isInternalAPI() { + return false; + } public function getParamTypes() { $types = $this->defineParamTypes(); diff --git a/src/applications/conduit/query/PhabricatorConduitMethodQuery.php b/src/applications/conduit/query/PhabricatorConduitMethodQuery.php index cb30bf5e55..512dc70e6f 100644 --- a/src/applications/conduit/query/PhabricatorConduitMethodQuery.php +++ b/src/applications/conduit/query/PhabricatorConduitMethodQuery.php @@ -9,6 +9,7 @@ final class PhabricatorConduitMethodQuery private $applicationNames; private $nameContains; private $methods; + private $isInternal; public function withMethods(array $methods) { $this->methods = $methods; @@ -40,6 +41,11 @@ final class PhabricatorConduitMethodQuery return $this; } + public function withIsInternal($is_internal) { + $this->isInternal = $is_internal; + return $this; + } + protected function loadPage() { $methods = $this->getAllMethods(); $methods = $this->filterMethods($methods); @@ -112,6 +118,14 @@ final class PhabricatorConduitMethodQuery } } + if ($this->isInternal !== null) { + foreach ($methods as $key => $method) { + if ($method->isInternalAPI() !== $this->isInternal) { + unset($methods[$key]); + } + } + } + return $methods; } diff --git a/src/applications/conduit/query/PhabricatorConduitSearchEngine.php b/src/applications/conduit/query/PhabricatorConduitSearchEngine.php index 6c067a8ff4..22e4e19e53 100644 --- a/src/applications/conduit/query/PhabricatorConduitSearchEngine.php +++ b/src/applications/conduit/query/PhabricatorConduitSearchEngine.php @@ -37,6 +37,7 @@ final class PhabricatorConduitSearchEngine $query->withIsStable($saved->getParameter('isStable')); $query->withIsUnstable($saved->getParameter('isUnstable')); $query->withIsDeprecated($saved->getParameter('isDeprecated')); + $query->withIsInternal(false); $names = $saved->getParameter('applicationNames', array()); if ($names) { diff --git a/src/applications/config/application/PhabricatorConfigApplication.php b/src/applications/config/application/PhabricatorConfigApplication.php index 8570484e31..bdaf0b2cc0 100644 --- a/src/applications/config/application/PhabricatorConfigApplication.php +++ b/src/applications/config/application/PhabricatorConfigApplication.php @@ -62,6 +62,10 @@ final class PhabricatorConfigApplication extends PhabricatorApplication { 'module/' => array( '(?P[^/]+)/' => 'PhabricatorConfigModuleController', ), + 'cluster/' => array( + 'databases/' => 'PhabricatorConfigClusterDatabasesController', + 'notifications/' => 'PhabricatorConfigClusterNotificationsController', + ), ), ); } diff --git a/src/applications/config/check/PhabricatorDaemonsSetupCheck.php b/src/applications/config/check/PhabricatorDaemonsSetupCheck.php index 0b2bd8614e..1802e608e9 100644 --- a/src/applications/config/check/PhabricatorDaemonsSetupCheck.php +++ b/src/applications/config/check/PhabricatorDaemonsSetupCheck.php @@ -46,49 +46,40 @@ final class PhabricatorDaemonsSetupCheck extends PhabricatorSetupCheck { ->addCommand('phabricator/ $ ./bin/phd start'); } - $phd_user = PhabricatorEnv::getEnvConfig('phd.user'); - $all_daemons = id(new PhabricatorDaemonLogQuery()) - ->setViewer(PhabricatorUser::getOmnipotentUser()) - ->withStatus(PhabricatorDaemonLogQuery::STATUS_ALIVE) - ->execute(); - foreach ($all_daemons as $daemon) { - - if ($phd_user) { - if ($daemon->getRunningAsUser() != $phd_user) { - $doc_href = PhabricatorEnv::getDocLink('Managing Daemons with phd'); - - $summary = pht( - 'At least one daemon is currently running as a different '. - 'user than configured in the Phabricator %s setting', - 'phd.user'); - - $message = pht( - 'A daemon is running as user %s while the Phabricator config '. - 'specifies %s to be %s.'. - "\n\n". - 'Either adjust %s to match %s or start '. - 'the daemons as the correct user. '. - "\n\n". - '%s Daemons will try to use %s to start as the configured user. '. - 'Make sure that the user who starts %s has the correct '. - 'sudo permissions to start %s daemons as %s', - 'phd.user', - 'phd.user', - 'phd', - 'sudo', - 'phd', - 'phd', - phutil_tag('tt', array(), $daemon->getRunningAsUser()), - phutil_tag('tt', array(), $phd_user), - phutil_tag('tt', array(), $daemon->getRunningAsUser()), - phutil_tag('tt', array(), $phd_user)); - - $this->newIssue('daemons.run-as-different-user') - ->setName(pht('Daemons are running as the wrong user')) - ->setSummary($summary) - ->setMessage($message) - ->addCommand('phabricator/ $ ./bin/phd restart'); + $expect_user = PhabricatorEnv::getEnvConfig('phd.user'); + if (strlen($expect_user)) { + $all_daemons = id(new PhabricatorDaemonLogQuery()) + ->setViewer(PhabricatorUser::getOmnipotentUser()) + ->withStatus(PhabricatorDaemonLogQuery::STATUS_ALIVE) + ->execute(); + foreach ($all_daemons as $daemon) { + $actual_user = $daemon->getRunningAsUser(); + if ($actual_user == $expect_user) { + continue; } + + $summary = pht( + 'At least one daemon is currently running as the wrong user.'); + + $message = pht( + 'A daemon is running as user %s, but daemons should be '. + 'running as %s.'. + "\n\n". + 'Either adjust the configuration setting %s or restart the '. + 'daemons. Daemons should attempt to run as the proper user when '. + 'restarted.', + phutil_tag('tt', array(), $actual_user), + phutil_tag('tt', array(), $expect_user), + phutil_tag('tt', array(), 'phd.user')); + + $this->newIssue('daemons.run-as-different-user') + ->setName(pht('Daemon Running as Wrong User')) + ->setSummary($summary) + ->setMessage($message) + ->addPhabricatorConfig('phd.user') + ->addCommand('phabricator/ $ ./bin/phd restart'); + + break; } } } diff --git a/src/applications/config/check/PhabricatorDatabaseSetupCheck.php b/src/applications/config/check/PhabricatorDatabaseSetupCheck.php index 4ee0a75c6f..dab9ad3b54 100644 --- a/src/applications/config/check/PhabricatorDatabaseSetupCheck.php +++ b/src/applications/config/check/PhabricatorDatabaseSetupCheck.php @@ -12,25 +12,14 @@ final class PhabricatorDatabaseSetupCheck extends PhabricatorSetupCheck { } protected function executeChecks() { - $conf = PhabricatorEnv::newObjectFromConfig('mysql.configuration-provider'); - $conn_user = $conf->getUser(); - $conn_pass = $conf->getPassword(); - $conn_host = $conf->getHost(); - $conn_port = $conf->getPort(); + $master = PhabricatorDatabaseRef::getMasterDatabaseRef(); + if (!$master) { + // If we're implicitly in read-only mode during disaster recovery, + // don't bother with these setup checks. + return; + } - ini_set('mysql.connect_timeout', 2); - - $config = array( - 'user' => $conn_user, - 'pass' => $conn_pass, - 'host' => $conn_host, - 'port' => $conn_port, - 'database' => null, - ); - - $conn_raw = PhabricatorEnv::newObjectFromConfig( - 'mysql.implementation', - array($config)); + $conn_raw = $master->newManagementConnection(); try { queryfx($conn_raw, 'SELECT 1'); @@ -88,11 +77,8 @@ final class PhabricatorDatabaseSetupCheck extends PhabricatorSetupCheck { ->setIsFatal(true) ->addCommand(hsprintf('phabricator/ $ ./bin/storage upgrade')); } else { - - $config['database'] = $namespace.'_meta_data'; - $conn_meta = PhabricatorEnv::newObjectFromConfig( - 'mysql.implementation', - array($config)); + $conn_meta = $master->newApplicationConnection( + $namespace.'_meta_data'); $applied = queryfx_all($conn_meta, 'SELECT patch FROM patch_status'); $applied = ipull($applied, 'patch', 'patch'); @@ -113,7 +99,6 @@ final class PhabricatorDatabaseSetupCheck extends PhabricatorSetupCheck { } } - $host = PhabricatorEnv::getEnvConfig('mysql.host'); $matches = null; if (preg_match('/^([^:]+):(\d+)$/', $host, $matches)) { diff --git a/src/applications/config/check/PhabricatorExtraConfigSetupCheck.php b/src/applications/config/check/PhabricatorExtraConfigSetupCheck.php index a37e655853..5e5b493591 100644 --- a/src/applications/config/check/PhabricatorExtraConfigSetupCheck.php +++ b/src/applications/config/check/PhabricatorExtraConfigSetupCheck.php @@ -182,6 +182,10 @@ final class PhabricatorExtraConfigSetupCheck extends PhabricatorSetupCheck { 'Garbage collectors are now configured with "%s".', 'bin/garbage set-policy'); + $aphlict_reason = pht( + 'Configuration of the notification server has changed substantially. '. + 'For discussion, see T10794.'); + $ancient_config += array( 'phid.external-loaders' => pht( @@ -298,6 +302,14 @@ final class PhabricatorExtraConfigSetupCheck extends PhabricatorSetupCheck { 'phd.variant-config' => pht( 'This configuration is no longer relevant because daemons '. 'restart automatically on configuration changes.'), + + 'notification.ssl-cert' => $aphlict_reason, + 'notification.ssl-key' => $aphlict_reason, + 'notification.pidfile' => $aphlict_reason, + 'notification.log' => $aphlict_reason, + 'notification.enabled' => $aphlict_reason, + 'notification.client-uri' => $aphlict_reason, + 'notification.server-uri' => $aphlict_reason, ); return $ancient_config; diff --git a/src/applications/config/check/PhabricatorMySQLSetupCheck.php b/src/applications/config/check/PhabricatorMySQLSetupCheck.php index bcda6280f5..3303f29e89 100644 --- a/src/applications/config/check/PhabricatorMySQLSetupCheck.php +++ b/src/applications/config/check/PhabricatorMySQLSetupCheck.php @@ -20,6 +20,12 @@ final class PhabricatorMySQLSetupCheck extends PhabricatorSetupCheck { } protected function executeChecks() { + // TODO: These checks should be executed against every reachable replica? + // See T10759. + if (PhabricatorEnv::isReadOnly()) { + return; + } + $max_allowed_packet = self::loadRawConfigValue('max_allowed_packet'); // This primarily supports setting the filesize limit for MySQL to 8MB, diff --git a/src/applications/config/controller/PhabricatorConfigClusterDatabasesController.php b/src/applications/config/controller/PhabricatorConfigClusterDatabasesController.php new file mode 100644 index 0000000000..453ebfe742 --- /dev/null +++ b/src/applications/config/controller/PhabricatorConfigClusterDatabasesController.php @@ -0,0 +1,213 @@ +buildSideNavView(); + $nav->selectFilter('cluster/databases/'); + + $title = pht('Database Servers'); + + $crumbs = $this + ->buildApplicationCrumbs($nav) + ->addTextCrumb(pht('Database Servers')); + + $database_status = $this->buildClusterDatabaseStatus(); + + $view = id(new PHUITwoColumnView()) + ->setNavigation($nav) + ->setMainColumn($database_status); + + return $this->newPage() + ->setTitle($title) + ->setCrumbs($crumbs) + ->appendChild($view); + } + + private function buildClusterDatabaseStatus() { + $viewer = $this->getViewer(); + + $databases = PhabricatorDatabaseRef::queryAll(); + $connection_map = PhabricatorDatabaseRef::getConnectionStatusMap(); + $replica_map = PhabricatorDatabaseRef::getReplicaStatusMap(); + Javelin::initBehavior('phabricator-tooltips'); + + $rows = array(); + foreach ($databases as $database) { + $messages = array(); + + if ($database->getIsMaster()) { + $role_icon = id(new PHUIIconView()) + ->setIcon('fa-database sky') + ->addSigil('has-tooltip') + ->setMetadata( + array( + 'tip' => pht('Master'), + )); + } else { + $role_icon = id(new PHUIIconView()) + ->setIcon('fa-download') + ->addSigil('has-tooltip') + ->setMetadata( + array( + 'tip' => pht('Replica'), + )); + } + + if ($database->getDisabled()) { + $conn_icon = 'fa-times'; + $conn_color = 'grey'; + $conn_label = pht('Disabled'); + } else { + $status = $database->getConnectionStatus(); + + $info = idx($connection_map, $status, array()); + $conn_icon = idx($info, 'icon'); + $conn_color = idx($info, 'color'); + $conn_label = idx($info, 'label'); + + if ($status === PhabricatorDatabaseRef::STATUS_OKAY) { + $latency = $database->getConnectionLatency(); + $latency = (int)(1000000 * $latency); + $conn_label = pht('%s us', new PhutilNumber($latency)); + } + } + + $connection = array( + id(new PHUIIconView())->setIcon("{$conn_icon} {$conn_color}"), + ' ', + $conn_label, + ); + + if ($database->getDisabled()) { + $replica_icon = 'fa-times'; + $replica_color = 'grey'; + $replica_label = pht('Disabled'); + } else { + $status = $database->getReplicaStatus(); + + $info = idx($replica_map, $status, array()); + $replica_icon = idx($info, 'icon'); + $replica_color = idx($info, 'color'); + $replica_label = idx($info, 'label'); + + if ($database->getIsMaster()) { + if ($status === PhabricatorDatabaseRef::REPLICATION_OKAY) { + $replica_icon = 'fa-database'; + } + } else { + switch ($status) { + case PhabricatorDatabaseRef::REPLICATION_OKAY: + case PhabricatorDatabaseRef::REPLICATION_SLOW: + $delay = $database->getReplicaDelay(); + if ($delay) { + $replica_label = pht('%ss Behind', new PhutilNumber($delay)); + } else { + $replica_label = pht('Up to Date'); + } + break; + } + } + } + + $replication = array( + id(new PHUIIconView())->setIcon("{$replica_icon} {$replica_color}"), + ' ', + $replica_label, + ); + + $health = $database->getHealthRecord(); + $health_up = $health->getUpEventCount(); + $health_down = $health->getDownEventCount(); + + if ($health->getIsHealthy()) { + $health_icon = id(new PHUIIconView()) + ->setIcon('fa-plus green'); + } else { + $health_icon = id(new PHUIIconView()) + ->setIcon('fa-times red'); + $messages[] = pht( + 'UNHEALTHY: This database has failed recent health checks. Traffic '. + 'will not be sent to it until it recovers.'); + } + + $health_count = pht( + '%s / %s', + new PhutilNumber($health_up), + new PhutilNumber($health_up + $health_down)); + + $health_status = array( + $health_icon, + ' ', + $health_count, + ); + + $conn_message = $database->getConnectionMessage(); + if ($conn_message) { + $messages[] = $conn_message; + } + + $replica_message = $database->getReplicaMessage(); + if ($replica_message) { + $messages[] = $replica_message; + } + + $messages = phutil_implode_html(phutil_tag('br'), $messages); + + $rows[] = array( + $role_icon, + $database->getHost(), + $database->getPort(), + $database->getUser(), + $connection, + $replication, + $health_status, + $messages, + ); + } + + + $table = id(new AphrontTableView($rows)) + ->setNoDataString( + pht('Phabricator is not configured in cluster mode.')) + ->setHeaders( + array( + null, + pht('Host'), + pht('Port'), + pht('User'), + pht('Connection'), + pht('Replication'), + pht('Health'), + pht('Messages'), + )) + ->setColumnClasses( + array( + null, + null, + null, + null, + null, + null, + null, + 'wide', + )); + + $doc_href = PhabricatorEnv::getDoclink('Cluster: Databases'); + + $header = id(new PHUIHeaderView()) + ->setHeader(pht('Cluster Database Status')) + ->addActionLink( + id(new PHUIButtonView()) + ->setIcon('fa-book') + ->setHref($doc_href) + ->setTag('a') + ->setText(pht('Documentation'))); + + return id(new PHUIObjectBoxView()) + ->setHeader($header) + ->setTable($table); + } + +} diff --git a/src/applications/config/controller/PhabricatorConfigClusterNotificationsController.php b/src/applications/config/controller/PhabricatorConfigClusterNotificationsController.php new file mode 100644 index 0000000000..7f42c323e6 --- /dev/null +++ b/src/applications/config/controller/PhabricatorConfigClusterNotificationsController.php @@ -0,0 +1,163 @@ +buildSideNavView(); + $nav->selectFilter('cluster/notifications/'); + + $title = pht('Cluster Notifications'); + + $crumbs = $this + ->buildApplicationCrumbs($nav) + ->addTextCrumb(pht('Cluster Notifications')); + + $notification_status = $this->buildClusterNotificationStatus(); + + $view = id(new PHUITwoColumnView()) + ->setNavigation($nav) + ->setMainColumn($notification_status); + + return $this->newPage() + ->setTitle($title) + ->setCrumbs($crumbs) + ->appendChild($view); + } + + private function buildClusterNotificationStatus() { + $viewer = $this->getViewer(); + + $servers = PhabricatorNotificationServerRef::newRefs(); + Javelin::initBehavior('phabricator-tooltips'); + + $rows = array(); + foreach ($servers as $server) { + if ($server->isAdminServer()) { + $type_icon = 'fa-database sky'; + $type_tip = pht('Admin Server'); + } else { + $type_icon = 'fa-bell sky'; + $type_tip = pht('Client Server'); + } + + $type_icon = id(new PHUIIconView()) + ->setIcon($type_icon) + ->addSigil('has-tooltip') + ->setMetadata( + array( + 'tip' => $type_tip, + )); + + $messages = array(); + + $details = array(); + if ($server->isAdminServer()) { + try { + $details = $server->loadServerStatus(); + $status_icon = 'fa-exchange green'; + $status_label = pht('Version %s', idx($details, 'version')); + } catch (Exception $ex) { + $status_icon = 'fa-times red'; + $status_label = pht('Connection Error'); + $messages[] = $ex->getMessage(); + } + } else { + try { + $server->testClient(); + $status_icon = 'fa-exchange green'; + $status_label = pht('Connected'); + } catch (Exception $ex) { + $status_icon = 'fa-times red'; + $status_label = pht('Connection Error'); + $messages[] = $ex->getMessage(); + } + } + + if ($details) { + $uptime = idx($details, 'uptime'); + $uptime = $uptime / 1000; + $uptime = phutil_format_relative_time_detailed($uptime); + + $clients = pht( + '%s Active / %s Total', + new PhutilNumber(idx($details, 'clients.active')), + new PhutilNumber(idx($details, 'clients.total'))); + + $stats = pht( + '%s In / %s Out', + new PhutilNumber(idx($details, 'messages.in')), + new PhutilNumber(idx($details, 'messages.out'))); + + } else { + $uptime = null; + $clients = null; + $stats = null; + } + + $status_view = array( + id(new PHUIIconView())->setIcon($status_icon), + ' ', + $status_label, + ); + + $messages = phutil_implode_html(phutil_tag('br'), $messages); + + $rows[] = array( + $type_icon, + $server->getProtocol(), + $server->getHost(), + $server->getPort(), + $status_view, + $uptime, + $clients, + $stats, + $messages, + ); + } + + $table = id(new AphrontTableView($rows)) + ->setNoDataString( + pht('No notification servers are configured.')) + ->setHeaders( + array( + null, + pht('Proto'), + pht('Host'), + pht('Port'), + pht('Status'), + pht('Uptime'), + pht('Clients'), + pht('Messages'), + null, + )) + ->setColumnClasses( + array( + null, + null, + null, + null, + null, + null, + null, + null, + 'wide', + )); + + $doc_href = PhabricatorEnv::getDoclink('Cluster: Notifications'); + + $header = id(new PHUIHeaderView()) + ->setHeader(pht('Cluster Notification Status')) + ->addActionLink( + id(new PHUIButtonView()) + ->setIcon('fa-book') + ->setHref($doc_href) + ->setTag('a') + ->setText(pht('Documentation'))); + + return id(new PHUIObjectBoxView()) + ->setHeader($header) + ->setTable($table); + } + +} diff --git a/src/applications/config/controller/PhabricatorConfigController.php b/src/applications/config/controller/PhabricatorConfigController.php index a752c2754c..c390688930 100644 --- a/src/applications/config/controller/PhabricatorConfigController.php +++ b/src/applications/config/controller/PhabricatorConfigController.php @@ -22,6 +22,9 @@ abstract class PhabricatorConfigController extends PhabricatorController { $nav->addFilter('dbissue/', pht('Database Issues')); $nav->addLabel(pht('Cache')); $nav->addFilter('cache/', pht('Cache Status')); + $nav->addLabel(pht('Cluster')); + $nav->addFilter('cluster/databases/', pht('Database Servers')); + $nav->addFilter('cluster/notifications/', pht('Notification Servers')); $nav->addLabel(pht('Welcome')); $nav->addFilter('welcome/', pht('Welcome Screen')); $nav->addLabel(pht('Modules')); diff --git a/src/applications/config/option/PhabricatorClusterConfigOptions.php b/src/applications/config/option/PhabricatorClusterConfigOptions.php index 0b7974904c..bcf498c32b 100644 --- a/src/applications/config/option/PhabricatorClusterConfigOptions.php +++ b/src/applications/config/option/PhabricatorClusterConfigOptions.php @@ -20,25 +20,46 @@ final class PhabricatorClusterConfigOptions } public function getOptions() { + $databases_type = 'custom:PhabricatorClusterDatabasesConfigOptionType'; + $databases_help = $this->deformat(pht(<<newOption('cluster.addresses', 'list', array()) ->setLocked(true) ->setSummary(pht('Address ranges of cluster hosts.')) ->setDescription( pht( - 'To allow Phabricator nodes to communicate with other nodes '. - 'in the cluster, provide an address whitelist of hosts that '. - 'are part of the cluster.'. + 'Define a Phabricator cluster by providing a whitelist of host '. + 'addresses that are part of the cluster.'. "\n\n". - 'Hosts on this whitelist are permitted to use special cluster '. - 'mechanisms to authenticate requests. By default, these '. - 'mechanisms are disabled.'. + 'Hosts on this whitelist have special powers. These hosts are '. + 'permitted to bend security rules, and misconfiguring this list '. + 'can make your install less secure. For more information, '. + 'see **[[ %s | %s ]]**.'. "\n\n". 'Define a list of CIDR blocks which whitelist all hosts in the '. - 'cluster. See the examples below for details.', + 'cluster and no additional hosts. See the examples below for '. + 'details.'. "\n\n". 'When cluster addresses are defined, Phabricator hosts will also '. - 'reject requests to interfaces which are not whitelisted.')) + 'reject requests to interfaces which are not whitelisted.', + $intro_href, + $intro_name)) ->addExample( array( '23.24.25.80/32', @@ -73,6 +94,26 @@ final class PhabricatorClusterConfigOptions 'subprocesses and commit hooks in the `%s` environmental variable.', 'PhabricatorConfigSiteSource', 'PHABRICATOR_INSTANCE')), + $this->newOption('cluster.read-only', 'bool', false) + ->setLocked(true) + ->setSummary( + pht( + 'Activate read-only mode for maintenance or disaster recovery.')) + ->setDescription( + pht( + 'WARNING: This is a prototype option and the description below '. + 'is currently pure fantasy.'. + "\n\n". + 'Switch Phabricator to read-only mode. In this mode, users will '. + 'be unable to write new data. Normally, the cluster degrades '. + 'into this mode automatically when it detects that the database '. + 'master is unreachable, but you can activate it manually in '. + 'order to perform maintenance or test configuration.')), + $this->newOption('cluster.databases', $databases_type, array()) + ->setHidden(true) + ->setSummary( + pht('Configure database read replicas.')) + ->setDescription($databases_help), ); } diff --git a/src/applications/config/option/PhabricatorNotificationConfigOptions.php b/src/applications/config/option/PhabricatorNotificationConfigOptions.php index 7b6acf4ec7..34fae7c184 100644 --- a/src/applications/config/option/PhabricatorNotificationConfigOptions.php +++ b/src/applications/config/option/PhabricatorNotificationConfigOptions.php @@ -20,45 +20,43 @@ final class PhabricatorNotificationConfigOptions } public function getOptions() { + $servers_type = 'custom:PhabricatorNotificationServersConfigOptionType'; + $servers_help = $this->deformat(pht(<< 'client', + 'host' => 'phabricator.mycompany.com', + 'port' => 22280, + 'protocol' => 'https', + ), + array( + 'type' => 'admin', + 'host' => '127.0.0.1', + 'port' => 22281, + 'protocol' => 'http', + ), + ); + + $servers_example1 = id(new PhutilJSON())->encodeAsList( + $servers_example1); + return array( - $this->newOption('notification.enabled', 'bool', false) - ->setBoolOptions( - array( - pht('Enable Real-Time Notifications'), - pht('Disable Real-Time Notifications'), - )) - ->setSummary(pht('Enable real-time notifications.')) - ->setDescription( - pht( - "Enable real-time notifications. You must also run a Node.js ". - "based notification server for this to work. Consult the ". - "documentation in 'Notifications User Guide: Setup and ". - "Configuration' for instructions.")), - $this->newOption( - 'notification.client-uri', - 'string', - 'http://localhost:22280/') - ->setDescription(pht('Location of the client server.')), - $this->newOption( - 'notification.server-uri', - 'string', - 'http://localhost:22281/') - ->setDescription(pht('Location of the notification receiver server.')), - $this->newOption('notification.log', 'string', '/var/log/aphlict.log') - ->setDescription(pht('Location of the server log file.')), - $this->newOption('notification.ssl-key', 'string', null) - ->setLocked(true) - ->setDescription( - pht('Path to SSL key to use for secure WebSockets.')), - $this->newOption('notification.ssl-cert', 'string', null) - ->setLocked(true) - ->setDescription( - pht('Path to SSL certificate to use for secure WebSockets.')), - $this->newOption( - 'notification.pidfile', - 'string', - '/var/tmp/aphlict/pid/aphlict.pid') - ->setDescription(pht('Location of the server PID file.')), + $this->newOption('notification.servers', $servers_type, array()) + ->setSummary(pht('Configure real-time notifications.')) + ->setDescription($servers_help) + ->addExample( + $servers_example1, + pht('Simple Example')), ); } diff --git a/src/applications/config/option/PhabricatorSecurityConfigOptions.php b/src/applications/config/option/PhabricatorSecurityConfigOptions.php index 8c9907d736..311c2d30b6 100644 --- a/src/applications/config/option/PhabricatorSecurityConfigOptions.php +++ b/src/applications/config/option/PhabricatorSecurityConfigOptions.php @@ -80,22 +80,25 @@ final class PhabricatorSecurityConfigOptions pht( "If the web server responds to both HTTP and HTTPS requests but ". "you want users to connect with only HTTPS, you can set this ". - "to true to make Phabricator redirect HTTP requests to HTTPS.\n\n". - + "to `true` to make Phabricator redirect HTTP requests to HTTPS.". + "\n\n". "Normally, you should just configure your server not to accept ". "HTTP traffic, but this setting may be useful if you originally ". "used HTTP and have now switched to HTTPS but don't want to ". "break old links, or if your webserver sits behind a load ". "balancer which terminates HTTPS connections and you can not ". - "reasonably configure more granular behavior there.\n\n". - + "reasonably configure more granular behavior there.". + "\n\n". "IMPORTANT: Phabricator determines if a request is HTTPS or not ". "by examining the PHP `%s` variable. If you run ". "Apache/mod_php this will probably be set correctly for you ". "automatically, but if you run Phabricator as CGI/FCGI (e.g., ". "through nginx or lighttpd), you need to configure your web ". "server so that it passes the value correctly based on the ". - "connection type.", + "connection type.". + "\n\n". + "If you configure Phabricator in cluster mode, note that this ". + "setting is ignored by intracluster requests.", "\$_SERVER['HTTPS']")) ->setBoolOptions( array( diff --git a/src/applications/conpherence/controller/ConpherenceUpdateController.php b/src/applications/conpherence/controller/ConpherenceUpdateController.php index 1ab0923fc4..661ad37339 100644 --- a/src/applications/conpherence/controller/ConpherenceUpdateController.php +++ b/src/applications/conpherence/controller/ConpherenceUpdateController.php @@ -111,7 +111,7 @@ final class ConpherenceUpdateController break; } $person_phid = $request->getStr('remove_person'); - if ($person_phid && $person_phid == $user->getPHID()) { + if ($person_phid) { $xactions[] = id(new ConpherenceTransaction()) ->setTransactionType( ConpherenceTransaction::TYPE_PARTICIPANTS) @@ -321,38 +321,83 @@ final class ConpherenceUpdateController ConpherenceThread $conpherence) { $request = $this->getRequest(); - $user = $request->getUser(); + $viewer = $request->getUser(); $remove_person = $request->getStr('remove_person'); $participants = $conpherence->getParticipants(); - $message = pht('Are you sure you want to leave this room?'); + $removed_user = id(new PhabricatorPeopleQuery()) + ->setViewer($viewer) + ->withPHIDs(array($remove_person)) + ->executeOne(); + if (!$removed_user) { + return new Aphront404Response(); + } + + $is_self = ($viewer->getPHID() == $removed_user->getPHID()); + $is_last = (count($participants) == 1); + $test_conpherence = clone $conpherence; $test_conpherence->attachParticipants(array()); - if (!PhabricatorPolicyFilter::hasCapability( - $user, + $still_visible = PhabricatorPolicyFilter::hasCapability( + $removed_user, $test_conpherence, - PhabricatorPolicyCapability::CAN_VIEW)) { - if (count($participants) == 1) { - $message .= ' '.pht('The room will be inaccessible forever and ever.'); + PhabricatorPolicyCapability::CAN_VIEW); + + $body = array(); + + if ($is_self) { + $title = pht('Leave Room'); + $body[] = pht( + 'Are you sure you want to leave this room?'); + } else { + $title = pht('Banish User'); + $body[] = pht( + 'Banish %s from the realm?', + phutil_tag('strong', array(), $removed_user->getUsername())); + } + + if ($still_visible) { + if ($is_self) { + $body[] = pht( + 'You will be able to rejoin the room later.'); } else { - $message .= ' '.pht('Someone else in the room can add you back later.'); + $body[] = pht( + 'This user will be able to rejoin the room later.'); + } + } else { + if ($is_self) { + if ($is_last) { + $body[] = pht( + 'You are the last member, so you will never be able to rejoin '. + 'the room.'); + } else { + $body[] = pht( + 'You will not be able to rejoin the room on your own, but '. + 'someone else can invite you later.'); + } + } else { + $body[] = pht( + 'This user will not be able to rejoin the room unless invited '. + 'again.'); } } - $body = phutil_tag( - 'p', - array(), - $message); require_celerity_resource('conpherence-update-css'); - return id(new AphrontDialogView()) - ->setTitle(pht('Leave Room')) + + $dialog = id(new AphrontDialogView()) + ->setTitle($title) ->addHiddenInput('action', 'remove_person') ->addHiddenInput('remove_person', $remove_person) ->addHiddenInput( 'latest_transaction_id', $request->getInt('latest_transaction_id')) - ->addHiddenInput('__continue__', true) - ->appendChild($body); + ->addHiddenInput('__continue__', true); + + foreach ($body as $paragraph) { + $dialog->appendParagraph($paragraph); + } + + return $dialog; } private function renderMetadataDialogue( diff --git a/src/applications/conpherence/view/ConpherencePeopleWidgetView.php b/src/applications/conpherence/view/ConpherencePeopleWidgetView.php index 8bcff0dad6..0771e4f4a1 100644 --- a/src/applications/conpherence/view/ConpherencePeopleWidgetView.php +++ b/src/applications/conpherence/view/ConpherencePeopleWidgetView.php @@ -5,11 +5,11 @@ final class ConpherencePeopleWidgetView extends ConpherenceWidgetView { public function render() { $conpherence = $this->getConpherence(); $widget_data = $conpherence->getWidgetData(); - $user = $this->getUser(); - $conpherence = $this->getConpherence(); + $viewer = $this->getUser(); + $participants = $conpherence->getParticipants(); $handles = $conpherence->getHandles(); - $head_handles = array_select_keys($handles, array($user->getPHID())); + $head_handles = array_select_keys($handles, array($viewer->getPHID())); $handle_list = mpull($handles, 'getName'); natcasesort($handle_list); $handles = mpull($handles, null, 'getName'); @@ -17,11 +17,16 @@ final class ConpherencePeopleWidgetView extends ConpherenceWidgetView { $head_handles = mpull($head_handles, null, 'getName'); $handles = $head_handles + $handles; + $can_edit = PhabricatorPolicyFilter::hasCapability( + $viewer, + $conpherence, + PhabricatorPolicyCapability::CAN_EDIT); + $body = array(); foreach ($handles as $handle) { $user_phid = $handle->getPHID(); - $remove_html = ''; - if ($user_phid == $user->getPHID()) { + + if (($user_phid == $viewer->getPHID()) || $can_edit) { $icon = id(new PHUIIconView()) ->setIcon('fa-times lightbluetext'); $remove_html = javelin_tag( @@ -35,7 +40,10 @@ final class ConpherencePeopleWidgetView extends ConpherenceWidgetView { ), ), $icon); + } else { + $remove_html = null; } + $body[] = phutil_tag( 'div', array( diff --git a/src/applications/daemon/controller/PhabricatorDaemonBulkJobController.php b/src/applications/daemon/controller/PhabricatorDaemonBulkJobController.php new file mode 100644 index 0000000000..506df1dd1a --- /dev/null +++ b/src/applications/daemon/controller/PhabricatorDaemonBulkJobController.php @@ -0,0 +1,25 @@ +newApplicationMenu() + ->setSearchEngine(new PhabricatorWorkerBulkJobSearchEngine()); + } + + protected function buildApplicationCrumbs() { + $crumbs = parent::buildApplicationCrumbs(); + $crumbs->addTextCrumb(pht('Bulk Jobs'), '/daemon/bulk/'); + return $crumbs; + } + +} diff --git a/src/applications/daemon/controller/PhabricatorDaemonBulkJobListController.php b/src/applications/daemon/controller/PhabricatorDaemonBulkJobListController.php index ee8d4f5bf4..b1754d7291 100644 --- a/src/applications/daemon/controller/PhabricatorDaemonBulkJobListController.php +++ b/src/applications/daemon/controller/PhabricatorDaemonBulkJobListController.php @@ -1,31 +1,12 @@ setQueryKey($request->getURIData('queryKey')) - ->setSearchEngine(new PhabricatorWorkerBulkJobSearchEngine()) - ->setNavigation($this->buildSideNavView()); - return $this->delegateToController($controller); + return id(new PhabricatorWorkerBulkJobSearchEngine()) + ->setController($this) + ->buildResponse(); } - protected function buildSideNavView($for_app = false) { - $user = $this->getRequest()->getUser(); - - $nav = new AphrontSideNavFilterView(); - $nav->setBaseURI(new PhutilURI($this->getApplicationURI())); - - id(new PhabricatorWorkerBulkJobSearchEngine()) - ->setViewer($user) - ->addNavigationItems($nav->getMenu()); - $nav->selectFilter(null); - - return $nav; - } } diff --git a/src/applications/daemon/controller/PhabricatorDaemonBulkJobMonitorController.php b/src/applications/daemon/controller/PhabricatorDaemonBulkJobMonitorController.php index 63ba3cacb1..79d509f1ea 100644 --- a/src/applications/daemon/controller/PhabricatorDaemonBulkJobMonitorController.php +++ b/src/applications/daemon/controller/PhabricatorDaemonBulkJobMonitorController.php @@ -1,11 +1,7 @@ getViewer(); diff --git a/src/applications/daemon/controller/PhabricatorDaemonBulkJobViewController.php b/src/applications/daemon/controller/PhabricatorDaemonBulkJobViewController.php index f794024591..f7aa396e94 100644 --- a/src/applications/daemon/controller/PhabricatorDaemonBulkJobViewController.php +++ b/src/applications/daemon/controller/PhabricatorDaemonBulkJobViewController.php @@ -1,11 +1,7 @@ getViewer(); @@ -21,7 +17,6 @@ final class PhabricatorDaemonBulkJobViewController $title = pht('Bulk Job %d', $job->getID()); $crumbs = $this->buildApplicationCrumbs(); - $crumbs->addTextCrumb(pht('Bulk Jobs'), '/daemon/bulk/'); $crumbs->addTextCrumb($title); $crumbs->setBorder(true); diff --git a/src/applications/daemon/controller/PhabricatorDaemonConsoleController.php b/src/applications/daemon/controller/PhabricatorDaemonConsoleController.php index a488ae3a63..7f7b323956 100644 --- a/src/applications/daemon/controller/PhabricatorDaemonConsoleController.php +++ b/src/applications/daemon/controller/PhabricatorDaemonConsoleController.php @@ -121,14 +121,13 @@ final class PhabricatorDaemonConsoleController ->setHeaderText(pht('Recently Completed Tasks (Last 15m)')) ->setTable($completed_table); - $daemon_table = new PhabricatorDaemonLogListView(); - $daemon_table->setUser($viewer); - $daemon_table->setDaemonLogs($logs); - - $daemon_panel = id(new PHUIObjectBoxView()); - $daemon_panel->setHeaderText(pht('Active Daemons')); - $daemon_panel->setObjectList($daemon_table); + $daemon_table = id(new PhabricatorDaemonLogListView()) + ->setUser($viewer) + ->setDaemonLogs($logs); + $daemon_panel = id(new PHUIObjectBoxView()) + ->setHeaderText(pht('Active Daemons')) + ->setTable($daemon_table); $tasks = id(new PhabricatorWorkerLeaseQuery()) ->setSkipLease(true) diff --git a/src/applications/daemon/controller/PhabricatorDaemonController.php b/src/applications/daemon/controller/PhabricatorDaemonController.php index 3b1d17a70b..a2734907fd 100644 --- a/src/applications/daemon/controller/PhabricatorDaemonController.php +++ b/src/applications/daemon/controller/PhabricatorDaemonController.php @@ -1,6 +1,11 @@ getViewer(); + $viewer = $this->getViewer(); $pager = new AphrontCursorPagerView(); $pager->readFromRequest($request); @@ -14,13 +14,13 @@ final class PhabricatorDaemonLogListController ->setAllowStatusWrites(true) ->executeWithCursorPager($pager); - $daemon_table = new PhabricatorDaemonLogListView(); - $daemon_table->setUser($request->getUser()); - $daemon_table->setDaemonLogs($logs); + $daemon_table = id(new PhabricatorDaemonLogListView()) + ->setViewer($viewer) + ->setDaemonLogs($logs); $box = id(new PHUIObjectBoxView()) ->setHeaderText(pht('All Daemons')) - ->appendChild($daemon_table); + ->setTable($daemon_table); $crumbs = $this->buildApplicationCrumbs(); $crumbs->addTextCrumb(pht('All Daemons')); diff --git a/src/applications/daemon/controller/PhabricatorDaemonLogViewController.php b/src/applications/daemon/controller/PhabricatorDaemonLogViewController.php index f2f5121898..004ce3a84e 100644 --- a/src/applications/daemon/controller/PhabricatorDaemonLogViewController.php +++ b/src/applications/daemon/controller/PhabricatorDaemonLogViewController.php @@ -16,10 +16,6 @@ final class PhabricatorDaemonLogViewController return new Aphront404Response(); } - $events = id(new PhabricatorDaemonLogEvent())->loadAllWhere( - 'logID = %d ORDER BY id DESC LIMIT 1000', - $log->getID()); - $crumbs = $this->buildApplicationCrumbs(); $crumbs->addTextCrumb(pht('Daemon %s', $log->getID())); $crumbs->setBorder(true); @@ -69,23 +65,15 @@ final class PhabricatorDaemonLogViewController $properties = $this->buildPropertyListView($log); - $event_view = id(new PhabricatorDaemonLogEventsView()) - ->setUser($viewer) - ->setEvents($events); - - $event_panel = id(new PHUIObjectBoxView()) - ->setHeaderText(pht('Events')) - ->setBackground(PHUIObjectBoxView::BLUE_PROPERTY) - ->appendChild($event_view); - $object_box = id(new PHUIObjectBoxView()) + ->setHeaderText(pht('Daemon Details')) + ->setBackground(PHUIObjectBoxView::BLUE_PROPERTY) ->addPropertyList($properties); $view = id(new PHUITwoColumnView()) ->setHeader($header) ->setFooter(array( $object_box, - $event_panel, )); return $this->newPage() diff --git a/src/applications/daemon/management/PhabricatorDaemonManagementWorkflow.php b/src/applications/daemon/management/PhabricatorDaemonManagementWorkflow.php index 447a614600..e0218a741d 100644 --- a/src/applications/daemon/management/PhabricatorDaemonManagementWorkflow.php +++ b/src/applications/daemon/management/PhabricatorDaemonManagementWorkflow.php @@ -224,17 +224,18 @@ abstract class PhabricatorDaemonManagementWorkflow $daemon_script_dir, $config, $this->runDaemonsAsUser); - } catch (Exception $e) { - // Retry without sudo - $console->writeOut( - "%s\n", + } catch (Exception $ex) { + throw new PhutilArgumentUsageException( pht( - '%s command failed. Starting daemon as current user.', - 'sudo')); - $this->executeDaemonLaunchCommand( - $command, - $daemon_script_dir, - $config); + 'Daemons are configured to run as user "%s" in configuration '. + 'option `%s`, but the current user is "%s" and `phd` was unable '. + 'to switch to the correct user with `sudo`. Command output:'. + "\n\n". + '%s', + $phd_user, + 'phd.user', + $current_user, + $ex->getMessage())); } } } diff --git a/src/applications/daemon/view/PhabricatorDaemonLogListView.php b/src/applications/daemon/view/PhabricatorDaemonLogListView.php index 046d1a29f5..ba25ac5c5e 100644 --- a/src/applications/daemon/view/PhabricatorDaemonLogListView.php +++ b/src/applications/daemon/view/PhabricatorDaemonLogListView.php @@ -14,65 +14,107 @@ final class PhabricatorDaemonLogListView extends AphrontView { $viewer = $this->getViewer(); $rows = array(); + $daemons = $this->daemonLogs; - $list = new PHUIObjectItemListView(); - $list->setFlush(true); - foreach ($this->daemonLogs as $log) { - $id = $log->getID(); - $epoch = $log->getDateCreated(); + foreach ($daemons as $daemon) { + $id = $daemon->getID(); + $host = $daemon->getHost(); + $pid = $daemon->getPID(); + $name = phutil_tag( + 'a', + array( + 'href' => "/daemon/log/{$id}/", + ), + $daemon->getDaemon()); - $item = id(new PHUIObjectItemView()) - ->setObjectName(pht('Daemon %s', $id)) - ->setHeader($log->getDaemon()) - ->setHref("/daemon/log/{$id}/") - ->addIcon('none', phabricator_datetime($epoch, $viewer)); - - $status = $log->getStatus(); + $status = $daemon->getStatus(); switch ($status) { case PhabricatorDaemonLog::STATUS_RUNNING: - $item->setStatusIcon('fa-rocket green'); - $item->addAttribute(pht('This daemon is running.')); + $status_icon = 'fa-rocket green'; + $status_label = pht('Running'); + $status_tip = pht('This daemon is running.'); break; case PhabricatorDaemonLog::STATUS_DEAD: - $item->setStatusIcon('fa-warning red'); - $item->addAttribute( - pht( - 'This daemon is lost or exited uncleanly, and is presumed '. - 'dead.')); - $item->addIcon('fa-times grey', pht('Dead')); + $status_icon = 'fa-warning red'; + $status_label = pht('Dead'); + $status_tip = pht( + 'This daemon has been lost or exited uncleanly, and is '. + 'presumed dead.'); break; case PhabricatorDaemonLog::STATUS_EXITING: - $item->addAttribute(pht('This daemon is exiting.')); - $item->addIcon('fa-check', pht('Exiting')); + $status_icon = 'fa-check'; + $status_label = pht('Shutting Down'); + $status_tip = pht('This daemon is shutting down.'); break; case PhabricatorDaemonLog::STATUS_EXITED: - $item->setDisabled(true); - $item->addAttribute(pht('This daemon exited cleanly.')); - $item->addIcon('fa-check grey', pht('Exited')); + $status_icon = 'fa-check grey'; + $status_label = pht('Exited'); + $status_tip = pht('This daemon exited cleanly.'); break; case PhabricatorDaemonLog::STATUS_WAIT: - $item->setStatusIcon('fa-clock-o blue'); - $item->addAttribute( - pht( - 'This daemon encountered an error recently and is waiting a '. - 'moment to restart.')); - $item->addIcon('fa-clock-o grey', pht('Waiting')); + $status_icon = 'fa-clock-o blue'; + $status_label = pht('Waiting'); + $status_tip = pht( + 'This daemon encountered an error recently and is waiting a '. + 'moment to restart.'); break; case PhabricatorDaemonLog::STATUS_UNKNOWN: default: - $item->setStatusIcon('fa-warning orange'); - $item->addAttribute( - pht( - 'This daemon has not reported its status recently. It may '. - 'have exited uncleanly.')); - $item->addIcon('fa-warning', pht('Unknown')); + $status_icon = 'fa-warning orange'; + $status_label = pht('Unknown'); + $status_tip = pht( + 'This daemon has not reported its status recently. It may '. + 'have exited uncleanly.'); break; } - $list->addItem($item); + $status = phutil_tag( + 'span', + array( + 'sigil' => 'has-tooltip', + 'meta' => array( + 'tip' => $status_tip, + ), + ), + array( + id(new PHUIIconView())->setIcon($status_icon), + ' ', + $status_label, + )); + + $launched = phabricator_datetime($daemon->getDateCreated(), $viewer); + + $rows[] = array( + $id, + $host, + $pid, + $name, + $status, + $launched, + ); } - return $list; + $table = id(new AphrontTableView($rows)) + ->setHeaders( + array( + pht('ID'), + pht('Host'), + pht('PPID'), + pht('Daemon'), + pht('Status'), + pht('Launched'), + )) + ->setColumnClasses( + array( + null, + null, + null, + 'pri', + 'wide', + 'right date', + )); + + return $table; } } diff --git a/src/applications/differential/storage/DifferentialDiff.php b/src/applications/differential/storage/DifferentialDiff.php index 0e32855c26..7ddfc43c5a 100644 --- a/src/applications/differential/storage/DifferentialDiff.php +++ b/src/applications/differential/storage/DifferentialDiff.php @@ -249,7 +249,6 @@ final class DifferentialDiff 'unitStatus' => $this->getUnitStatus(), 'lintStatus' => $this->getLintStatus(), 'changes' => array(), - 'properties' => array(), ); $dict['changes'] = $this->buildChangesList(); @@ -258,7 +257,7 @@ final class DifferentialDiff } public function getDiffAuthorshipDict() { - $dict = array(); + $dict = array('properties' => array()); $properties = id(new DifferentialDiffProperty())->loadAllWhere( 'diffID = %d', diff --git a/src/applications/diffusion/application/PhabricatorDiffusionApplication.php b/src/applications/diffusion/application/PhabricatorDiffusionApplication.php index b8766920fc..b65ec43937 100644 --- a/src/applications/diffusion/application/PhabricatorDiffusionApplication.php +++ b/src/applications/diffusion/application/PhabricatorDiffusionApplication.php @@ -87,6 +87,8 @@ final class PhabricatorDiffusionApplication extends PhabricatorApplication { => 'DiffusionCommitTagsController', 'commit/(?P[a-z0-9]+)/edit/' => 'DiffusionCommitEditController', + 'manage/(?:(?P[^/]+)/)?' + => 'DiffusionRepositoryManageController', 'edit/' => array( '' => 'DiffusionRepositoryEditMainController', 'basic/' => 'DiffusionRepositoryEditBasicController', diff --git a/src/applications/diffusion/conduit/DiffusionInternalGitRawDiffQueryConduitAPIMethod.php b/src/applications/diffusion/conduit/DiffusionInternalGitRawDiffQueryConduitAPIMethod.php new file mode 100644 index 0000000000..1e4c906322 --- /dev/null +++ b/src/applications/diffusion/conduit/DiffusionInternalGitRawDiffQueryConduitAPIMethod.php @@ -0,0 +1,74 @@ + 'required string', + ); + } + + protected function getResult(ConduitAPIRequest $request) { + $drequest = $this->getDiffusionRequest(); + $repository = $drequest->getRepository(); + + if (!$repository->isGit()) { + throw new Exception( + pht( + 'This API method can only be called on Git repositories.')); + } + + // Check if the commit has parents. We're testing to see whether it is the + // first commit in history (in which case we must use "git log") or some + // other commit (in which case we can use "git diff"). We'd rather use + // "git diff" because it has the right behavior for merge commits, but + // it requires the commit to have a parent that we can diff against. The + // first commit doesn't, so "commit^" is not a valid ref. + list($parents) = $repository->execxLocalCommand( + 'log -n1 --format=%s %s', + '%P', + $request->getValue('commit')); + + $use_log = !strlen(trim($parents)); + if ($use_log) { + // This is the first commit so we need to use "log". We know it's not a + // merge commit because it couldn't be merging anything, so this is safe. + + // NOTE: "--pretty=format: " is to disable diff output, we only want the + // part we get from "--raw". + list($raw) = $repository->execxLocalCommand( + 'log -n1 -M -C -B --find-copies-harder --raw -t '. + '--pretty=format: --abbrev=40 %s', + $request->getValue('commit')); + } else { + // Otherwise, we can use "diff", which will give us output for merges. + // We diff against the first parent, as this is generally the expectation + // and results in sensible behavior. + list($raw) = $repository->execxLocalCommand( + 'diff -n1 -M -C -B --find-copies-harder --raw -t '. + '--abbrev=40 %s^1 %s', + $request->getValue('commit'), + $request->getValue('commit')); + } + + return $raw; + } + +} diff --git a/src/applications/diffusion/controller/DiffusionBrowseController.php b/src/applications/diffusion/controller/DiffusionBrowseController.php index 7616922aa8..1357bed620 100644 --- a/src/applications/diffusion/controller/DiffusionBrowseController.php +++ b/src/applications/diffusion/controller/DiffusionBrowseController.php @@ -1187,6 +1187,19 @@ final class DiffusionBrowseController extends DiffusionController { $commit_links = $this->renderCommitLinks($blame_commits, $handles); $revision_links = $this->renderRevisionLinks($revisions, $handles); + if ($this->coverage) { + require_celerity_resource('differential-changeset-view-css'); + Javelin::initBehavior( + 'diffusion-browse-file', + array( + 'labels' => array( + 'cov-C' => pht('Covered'), + 'cov-N' => pht('Not Covered'), + 'cov-U' => pht('Not Executable'), + ), + )); + } + $skip_text = pht('Skip Past This Commit'); foreach ($display as $line_index => $line) { $row = array(); @@ -1304,7 +1317,6 @@ final class DiffusionBrowseController extends DiffusionController { )); if ($this->coverage) { - require_celerity_resource('differential-changeset-view-css'); $cov_index = $line_index; if (isset($this->coverage[$cov_index])) { diff --git a/src/applications/diffusion/controller/DiffusionRepositoryManageController.php b/src/applications/diffusion/controller/DiffusionRepositoryManageController.php new file mode 100644 index 0000000000..d4ba97440d --- /dev/null +++ b/src/applications/diffusion/controller/DiffusionRepositoryManageController.php @@ -0,0 +1,99 @@ +navigation) { + return $this->navigation->getMenu(); + } + return parent::buildApplicationMenu(); + } + + + public function handleRequest(AphrontRequest $request) { + $response = $this->loadDiffusionContext(); + if ($response) { + return $response; + } + + $viewer = $this->getViewer(); + $drequest = $this->getDiffusionRequest(); + $repository = $drequest->getRepository(); + + $panels = DiffusionRepositoryManagementPanel::getAllPanels(); + + foreach ($panels as $panel) { + $panel + ->setViewer($viewer) + ->setRepository($repository); + } + + $selected = $request->getURIData('panel'); + if (!strlen($selected)) { + $selected = head_key($panels); + } + + if (empty($panels[$selected])) { + return new Aphront404Response(); + } + + $nav = $this->renderSideNav($repository, $panels, $selected); + $this->navigation = $nav; + + $panel = $panels[$selected]; + + $content = $panel->buildManagementPanelContent(); + + $title = array( + $panel->getManagementPanelLabel(), + $repository->getDisplayName(), + ); + + $crumbs = $this->buildApplicationCrumbs(); + $crumbs->addTextCrumb( + $repository->getDisplayName(), + $repository->getURI()); + $crumbs->addTextCrumb( + pht('Manage'), + $repository->getPathURI('manage/')); + $crumbs->addTextCrumb($panel->getManagementPanelLabel()); + + $view = id(new PHUITwoColumnView()) + ->setNavigation($nav) + ->setMainColumn($content); + + return $this->newPage() + ->setTitle($title) + ->setCrumbs($crumbs) + ->appendChild($view); + } + + private function renderSideNav( + PhabricatorRepository $repository, + array $panels, + $selected) { + + $base_uri = $repository->getPathURI('manage/'); + $base_uri = new PhutilURI($base_uri); + + $nav = id(new AphrontSideNavFilterView()) + ->setBaseURI($base_uri); + + foreach ($panels as $panel) { + $nav->addFilter( + $panel->getManagementPanelKey(), + $panel->getManagementPanelLabel()); + } + + $nav->selectFilter($selected); + + return $nav; + } + + +} diff --git a/src/applications/diffusion/controller/DiffusionServeController.php b/src/applications/diffusion/controller/DiffusionServeController.php index a5871ca074..cac05b46b9 100644 --- a/src/applications/diffusion/controller/DiffusionServeController.php +++ b/src/applications/diffusion/controller/DiffusionServeController.php @@ -164,7 +164,14 @@ final class DiffusionServeController extends DiffusionController { // If authentication credentials have been provided, try to find a user // that actually matches those credentials. - if (isset($_SERVER['PHP_AUTH_USER']) && isset($_SERVER['PHP_AUTH_PW'])) { + + // We require both the username and password to be nonempty, because Git + // won't prompt users who provide a username but no password otherwise. + // See T10797 for discussion. + + $have_user = strlen(idx($_SERVER, 'PHP_AUTH_USER')); + $have_pass = strlen(idx($_SERVER, 'PHP_AUTH_PW')); + if ($have_user && $have_pass) { $username = $_SERVER['PHP_AUTH_USER']; $password = new PhutilOpaqueEnvelope($_SERVER['PHP_AUTH_PW']); diff --git a/src/applications/diffusion/management/DiffusionRepositoryClusterManagementPanel.php b/src/applications/diffusion/management/DiffusionRepositoryClusterManagementPanel.php new file mode 100644 index 0000000000..27991f62c7 --- /dev/null +++ b/src/applications/diffusion/management/DiffusionRepositoryClusterManagementPanel.php @@ -0,0 +1,166 @@ +getRepository(); + $viewer = $this->getViewer(); + + $service_phid = $repository->getAlmanacServicePHID(); + if ($service_phid) { + $service = id(new AlmanacServiceQuery()) + ->setViewer($viewer) + ->withServiceTypes( + array( + AlmanacClusterRepositoryServiceType::SERVICETYPE, + )) + ->withPHIDs(array($service_phid)) + ->needBindings(true) + ->executeOne(); + if (!$service) { + // TODO: Viewer may not have permission to see the service, or it may + // be invalid? Raise some more useful error here? + throw new Exception(pht('Unable to load cluster service.')); + } + } else { + $service = null; + } + + Javelin::initBehavior('phabricator-tooltips'); + + $rows = array(); + if ($service) { + $bindings = $service->getBindings(); + $bindings = mgroup($bindings, 'getDevicePHID'); + + // This is an unusual read which always comes from the master. + if (PhabricatorEnv::isReadOnly()) { + $versions = array(); + } else { + $versions = PhabricatorRepositoryWorkingCopyVersion::loadVersions( + $repository->getPHID()); + } + + $versions = mpull($versions, null, 'getDevicePHID'); + + foreach ($bindings as $binding_group) { + $all_disabled = true; + foreach ($binding_group as $binding) { + if (!$binding->getIsDisabled()) { + $all_disabled = false; + break; + } + } + + $any_binding = head($binding_group); + + if ($all_disabled) { + $binding_icon = 'fa-times grey'; + $binding_tip = pht('Disabled'); + } else { + $binding_icon = 'fa-folder-open green'; + $binding_tip = pht('Active'); + } + + $binding_icon = id(new PHUIIconView()) + ->setIcon($binding_icon) + ->addSigil('has-tooltip') + ->setMetadata( + array( + 'tip' => $binding_tip, + )); + + $device = $any_binding->getDevice(); + + $version = idx($versions, $device->getPHID()); + if ($version) { + $version_number = $version->getRepositoryVersion(); + $version_number = phutil_tag( + 'a', + array( + 'href' => "/diffusion/pushlog/view/{$version_number}/", + ), + $version_number); + } else { + $version_number = '-'; + } + + if ($version && $version->getIsWriting()) { + $is_writing = id(new PHUIIconView()) + ->setIcon('fa-pencil green'); + } else { + $is_writing = id(new PHUIIconView()) + ->setIcon('fa-pencil grey'); + } + + $rows[] = array( + $binding_icon, + phutil_tag( + 'a', + array( + 'href' => $device->getURI(), + ), + $device->getName()), + $version_number, + $is_writing, + ); + } + } + + $table = id(new AphrontTableView($rows)) + ->setNoDataString(pht('This is not a cluster repository.')) + ->setHeaders( + array( + null, + pht('Device'), + pht('Version'), + pht('Writing'), + )) + ->setColumnClasses( + array( + null, + null, + null, + 'right wide', + )); + + $doc_href = PhabricatorEnv::getDoclink('Cluster: Repositories'); + + $header = id(new PHUIHeaderView()) + ->setHeader(pht('Cluster Status')) + ->addActionLink( + id(new PHUIButtonView()) + ->setIcon('fa-book') + ->setHref($doc_href) + ->setTag('a') + ->setText(pht('Documentation'))); + + if ($service) { + $header->setSubheader( + pht( + 'This repository is hosted on %s.', + phutil_tag( + 'a', + array( + 'href' => $service->getURI(), + ), + $service->getName()))); + } + + return id(new PHUIObjectBoxView()) + ->setHeader($header) + ->setTable($table); + } + +} diff --git a/src/applications/diffusion/management/DiffusionRepositoryManagementPanel.php b/src/applications/diffusion/management/DiffusionRepositoryManagementPanel.php new file mode 100644 index 0000000000..b72411cd58 --- /dev/null +++ b/src/applications/diffusion/management/DiffusionRepositoryManagementPanel.php @@ -0,0 +1,43 @@ +viewer = $viewer; + return $this; + } + + final public function getViewer() { + return $this->viewer; + } + + final public function setRepository(PhabricatorRepository $repository) { + $this->repository = $repository; + return $this; + } + + final public function getRepository() { + return $this->repository; + } + + final public function getManagementPanelKey() { + return $this->getPhobjectClassConstant('PANELKEY'); + } + + abstract public function getManagementPanelLabel(); + abstract public function getManagementPanelOrder(); + abstract public function buildManagementPanelContent(); + + public static function getAllPanels() { + return id(new PhutilClassMapQuery()) + ->setAncestorClass(__CLASS__) + ->setUniqueMethod('getManagementPanelKey') + ->setSortMethod('getManagementPanelOrder') + ->execute(); + } + +} diff --git a/src/applications/diffusion/ssh/DiffusionGitReceivePackSSHWorkflow.php b/src/applications/diffusion/ssh/DiffusionGitReceivePackSSHWorkflow.php index e4eabc72ef..b138a2ef7d 100644 --- a/src/applications/diffusion/ssh/DiffusionGitReceivePackSSHWorkflow.php +++ b/src/applications/diffusion/ssh/DiffusionGitReceivePackSSHWorkflow.php @@ -21,8 +21,12 @@ final class DiffusionGitReceivePackSSHWorkflow extends DiffusionGitSSHWorkflow { if ($this->shouldProxy()) { $command = $this->getProxyCommand(); + $is_proxy = true; } else { $command = csprintf('git-receive-pack %s', $repository->getLocalPath()); + $is_proxy = false; + + $repository->synchronizeWorkingCopyBeforeWrite(); } $command = PhabricatorDaemon::sudoCommandAsDaemonUser($command); @@ -41,6 +45,10 @@ final class DiffusionGitReceivePackSSHWorkflow extends DiffusionGitSSHWorkflow { $this->waitForGitClient(); } + if (!$is_proxy) { + $repository->synchronizeWorkingCopyAfterWrite(); + } + return $err; } diff --git a/src/applications/diffusion/ssh/DiffusionGitUploadPackSSHWorkflow.php b/src/applications/diffusion/ssh/DiffusionGitUploadPackSSHWorkflow.php index 4812b960a0..926a477627 100644 --- a/src/applications/diffusion/ssh/DiffusionGitUploadPackSSHWorkflow.php +++ b/src/applications/diffusion/ssh/DiffusionGitUploadPackSSHWorkflow.php @@ -20,6 +20,7 @@ final class DiffusionGitUploadPackSSHWorkflow extends DiffusionGitSSHWorkflow { $command = $this->getProxyCommand(); } else { $command = csprintf('git-upload-pack -- %s', $repository->getLocalPath()); + $repository->synchronizeWorkingCopyBeforeRead(); } $command = PhabricatorDaemon::sudoCommandAsDaemonUser($command); diff --git a/src/applications/diffusion/ssh/DiffusionSubversionServeSSHWorkflow.php b/src/applications/diffusion/ssh/DiffusionSubversionServeSSHWorkflow.php index 4e591d318b..820a380856 100644 --- a/src/applications/diffusion/ssh/DiffusionSubversionServeSSHWorkflow.php +++ b/src/applications/diffusion/ssh/DiffusionSubversionServeSSHWorkflow.php @@ -21,6 +21,7 @@ final class DiffusionSubversionServeSSHWorkflow private $externalBaseURI; private $peekBuffer; private $command; + private $isProxying; private function getCommand() { return $this->command; @@ -146,6 +147,7 @@ final class DiffusionSubversionServeSSHWorkflow if ($this->shouldProxy()) { $command = $this->getProxyCommand(); + $this->isProxying = true; } else { $command = csprintf( 'svnserve -t --tunnel-user=%s', @@ -372,6 +374,10 @@ final class DiffusionSubversionServeSSHWorkflow } private function makeInternalURI($uri_string) { + if ($this->isProxying) { + return $uri_string; + } + $uri = new PhutilURI($uri_string); $repository = $this->getRepository(); @@ -409,6 +415,10 @@ final class DiffusionSubversionServeSSHWorkflow } private function makeExternalURI($uri) { + if ($this->isProxying) { + return $uri; + } + $internal = $this->internalBaseURI; $external = $this->externalBaseURI; diff --git a/src/applications/multimeter/data/MultimeterControl.php b/src/applications/multimeter/data/MultimeterControl.php index 6658319469..34e03e0587 100644 --- a/src/applications/multimeter/data/MultimeterControl.php +++ b/src/applications/multimeter/data/MultimeterControl.php @@ -124,6 +124,10 @@ final class MultimeterControl extends Phobject { } private function writeEvents() { + if (PhabricatorEnv::isReadOnly()) { + return; + } + $events = $this->events; $random = Filesystem::readRandomBytes(32); diff --git a/src/applications/notification/application/PhabricatorNotificationsApplication.php b/src/applications/notification/application/PhabricatorNotificationsApplication.php index b4cc2b45b6..e068db50a8 100644 --- a/src/applications/notification/application/PhabricatorNotificationsApplication.php +++ b/src/applications/notification/application/PhabricatorNotificationsApplication.php @@ -25,7 +25,6 @@ final class PhabricatorNotificationsApplication extends PhabricatorApplication { => 'PhabricatorNotificationListController', 'panel/' => 'PhabricatorNotificationPanelController', 'individual/' => 'PhabricatorNotificationIndividualController', - 'status/' => 'PhabricatorNotificationStatusController', 'clear/' => 'PhabricatorNotificationClearController', 'test/' => 'PhabricatorNotificationTestController', ), diff --git a/src/applications/notification/client/PhabricatorNotificationClient.php b/src/applications/notification/client/PhabricatorNotificationClient.php index f527a64bc4..ae8ac7eb34 100644 --- a/src/applications/notification/client/PhabricatorNotificationClient.php +++ b/src/applications/notification/client/PhabricatorNotificationClient.php @@ -2,62 +2,34 @@ final class PhabricatorNotificationClient extends Phobject { - const EXPECT_VERSION = 7; + public static function tryAnyConnection() { + $servers = PhabricatorNotificationServerRef::getEnabledAdminServers(); - public static function getServerStatus() { - $uri = PhabricatorEnv::getEnvConfig('notification.server-uri'); - $uri = id(new PhutilURI($uri)) - ->setPath('/status/') - ->setQueryParam('instance', self::getInstance()); - - // We always use HTTP to connect to the server itself: it's simpler and - // there's no meaningful security benefit to securing this link today. - // Force the protocol to HTTP in case users have set it to something else. - $uri->setProtocol('http'); - - list($body) = id(new HTTPSFuture($uri)) - ->setTimeout(3) - ->resolvex(); - - $status = phutil_json_decode($body); - if (!is_array($status)) { - throw new Exception( - pht( - 'Expected JSON response from notification server, received: %s', - $body)); - } - - return $status; - } - - public static function tryToPostMessage(array $data) { - if (!PhabricatorEnv::getEnvConfig('notification.enabled')) { + if (!$servers) { return; } - try { - self::postMessage($data); - } catch (Exception $ex) { - // Just ignore any issues here. - phlog($ex); + foreach ($servers as $server) { + $server->loadServerStatus(); + return; + } + + return; + } + + public static function tryToPostMessage(array $data) { + $servers = PhabricatorNotificationServerRef::getEnabledAdminServers(); + + shuffle($servers); + + foreach ($servers as $server) { + try { + $server->postMessage($data); + return; + } catch (Exception $ex) { + // Just ignore any issues here. + } } } - private static function postMessage(array $data) { - $server_uri = PhabricatorEnv::getEnvConfig('notification.server-uri'); - $server_uri = id(new PhutilURI($server_uri)) - ->setPath('/') - ->setQueryParam('instance', self::getInstance()); - - id(new HTTPSFuture($server_uri, json_encode($data))) - ->setMethod('POST') - ->setTimeout(1) - ->resolvex(); - } - - private static function getInstance() { - $client_uri = PhabricatorEnv::getEnvConfig('notification.client-uri'); - return id(new PhutilURI($client_uri))->getPath(); - } - } diff --git a/src/applications/notification/client/PhabricatorNotificationServerRef.php b/src/applications/notification/client/PhabricatorNotificationServerRef.php new file mode 100644 index 0000000000..b183221eee --- /dev/null +++ b/src/applications/notification/client/PhabricatorNotificationServerRef.php @@ -0,0 +1,234 @@ +type = $type; + return $this; + } + + public function getType() { + return $this->type; + } + + public function setHost($host) { + $this->host = $host; + return $this; + } + + public function getHost() { + return $this->host; + } + + public function setPort($port) { + $this->port = $port; + return $this; + } + + public function getPort() { + return $this->port; + } + + public function setProtocol($protocol) { + $this->protocol = $protocol; + return $this; + } + + public function getProtocol() { + return $this->protocol; + } + + public function setPath($path) { + $this->path = $path; + return $this; + } + + public function getPath() { + return $this->path; + } + + public function setIsDisabled($is_disabled) { + $this->isDisabled = $is_disabled; + return $this; + } + + public function getIsDisabled() { + return $this->isDisabled; + } + + public static function getLiveServers() { + $cache = PhabricatorCaches::getRequestCache(); + + $refs = $cache->getKey(self::KEY_REFS); + if (!$refs) { + $refs = self::newRefs(); + $cache->setKey(self::KEY_REFS, $refs); + } + + return $refs; + } + + public static function newRefs() { + $configs = PhabricatorEnv::getEnvConfig('notification.servers'); + + $refs = array(); + foreach ($configs as $config) { + $ref = id(new self()) + ->setType($config['type']) + ->setHost($config['host']) + ->setPort($config['port']) + ->setProtocol($config['protocol']) + ->setPath(idx($config, 'path')) + ->setIsDisabled(idx($config, 'disabled', false)); + $refs[] = $ref; + } + + return $refs; + } + + public static function getEnabledServers() { + $servers = self::getLiveServers(); + + foreach ($servers as $key => $server) { + if ($server->getIsDisabled()) { + unset($servers[$key]); + } + } + + return array_values($servers); + } + + public static function getEnabledAdminServers() { + $servers = self::getEnabledServers(); + + foreach ($servers as $key => $server) { + if (!$server->isAdminServer()) { + unset($servers[$key]); + } + } + + return array_values($servers); + } + + public static function getEnabledClientServers($with_protocol) { + $servers = self::getEnabledServers(); + + foreach ($servers as $key => $server) { + if ($server->isAdminServer()) { + unset($servers[$key]); + continue; + } + + $protocol = $server->getProtocol(); + if ($protocol != $with_protocol) { + unset($servers[$key]); + continue; + } + } + + return array_values($servers); + } + + public function isAdminServer() { + return ($this->type == 'admin'); + } + + public function getURI($to_path = null) { + $full_path = rtrim($this->getPath(), '/').'/'.ltrim($to_path, '/'); + + $uri = id(new PhutilURI('http://'.$this->getHost())) + ->setProtocol($this->getProtocol()) + ->setPort($this->getPort()) + ->setPath($full_path); + + $instance = PhabricatorEnv::getEnvConfig('cluster.instance'); + if (strlen($instance)) { + $uri->setQueryParam('instance', $instance); + } + + return $uri; + } + + public function getWebsocketURI($to_path = null) { + $instance = PhabricatorEnv::getEnvConfig('cluster.instance'); + if (strlen($instance)) { + $to_path = $to_path.'~'.$instance.'/'; + } + + $uri = $this->getURI($to_path); + + if ($this->getProtocol() == 'https') { + $uri->setProtocol('wss'); + } else { + $uri->setProtocol('ws'); + } + + return $uri; + } + + public function testClient() { + if ($this->isAdminServer()) { + throw new Exception( + pht('Unable to test client on an admin server!')); + } + + $server_uri = $this->getURI(); + + try { + id(new HTTPSFuture($server_uri)) + ->setTimeout(2) + ->resolvex(); + } catch (HTTPFutureHTTPResponseStatus $ex) { + // This is what we expect when things are working correctly. + if ($ex->getStatusCode() == 501) { + return true; + } + throw $ex; + } + + throw new Exception( + pht('Got HTTP 200, but expected HTTP 501 (WebSocket Upgrade)!')); + } + + public function loadServerStatus() { + if (!$this->isAdminServer()) { + throw new Exception( + pht( + 'Unable to load server status: this is not an admin server!')); + } + + $server_uri = $this->getURI('/status/'); + + list($body) = id(new HTTPSFuture($server_uri)) + ->setTimeout(2) + ->resolvex(); + + return phutil_json_decode($body); + } + + public function postMessage(array $data) { + if (!$this->isAdminServer()) { + throw new Exception( + pht('Unable to post message: this is not an admin server!')); + } + + $server_uri = $this->getURI('/'); + $payload = phutil_json_encode($data); + + id(new HTTPSFuture($server_uri, $payload)) + ->setMethod('POST') + ->setTimeout(2) + ->resolvex(); + } + +} diff --git a/src/applications/notification/config/PhabricatorNotificationServersConfigOptionType.php b/src/applications/notification/config/PhabricatorNotificationServersConfigOptionType.php new file mode 100644 index 0000000000..07cdf7eece --- /dev/null +++ b/src/applications/notification/config/PhabricatorNotificationServersConfigOptionType.php @@ -0,0 +1,140 @@ + $spec) { + if (!is_array($spec)) { + throw new Exception( + pht( + 'Notification server configuration is not valid: each entry in '. + 'the list must be a dictionary describing a service, but '. + 'the value with index "%s" is not a dictionary.', + $index)); + } + } + + $has_admin = false; + $has_client = false; + $map = array(); + foreach ($value as $index => $spec) { + try { + PhutilTypeSpec::checkMap( + $spec, + array( + 'type' => 'string', + 'host' => 'string', + 'port' => 'int', + 'protocol' => 'string', + 'path' => 'optional string', + 'disabled' => 'optional bool', + )); + } catch (Exception $ex) { + throw new Exception( + pht( + 'Notification server configuration has an invalid service '. + 'specification (at index "%s"): %s.', + $index, + $ex->getMessage())); + } + + $type = $spec['type']; + $host = $spec['host']; + $port = $spec['port']; + $protocol = $spec['protocol']; + $disabled = idx($spec, 'disabled'); + + switch ($type) { + case 'admin': + if (!$disabled) { + $has_admin = true; + } + break; + case 'client': + if (!$disabled) { + $has_client = true; + } + break; + default: + throw new Exception( + pht( + 'Notification server configuration describes an invalid '. + 'host ("%s", at index "%s") with an unrecognized type ("%s"). '. + 'Valid types are "%s" or "%s".', + $host, + $index, + $type, + 'admin', + 'client')); + } + + switch ($protocol) { + case 'http': + case 'https': + break; + default: + throw new Exception( + pht( + 'Notification server configuration describes an invalid '. + 'host ("%s", at index "%s") with an invalid protocol ("%s"). '. + 'Valid protocols are "%s" or "%s".', + $host, + $index, + $protocol, + 'http', + 'https')); + } + + $path = idx($spec, 'path'); + if ($type == 'admin' && strlen($path)) { + throw new Exception( + pht( + 'Notification server configuration describes an invalid host '. + '("%s", at index "%s"). This is an "admin" service but it has a '. + '"path" property. This property is only valid for "client" '. + 'services.')); + } + + // We can't guarantee that you didn't just give the same host two + // different names in DNS, but this check can catch silly copy/paste + // mistakes. + $key = "{$host}:{$port}"; + if (isset($map[$key])) { + throw new Exception( + pht( + 'Notification server configuration is invalid: it describes the '. + 'same host and port ("%s") multiple times. Each host and port '. + 'combination should appear only once in the list.', + $key)); + } + $map[$key] = true; + } + + if ($value) { + if (!$has_admin) { + throw new Exception( + pht( + 'Notification server configuration is invalid: it does not '. + 'specify any enabled servers with type "admin". Notifications '. + 'require at least one active "admin" server.')); + } + + if (!$has_client) { + throw new Exception( + pht( + 'Notification server configuration is invalid: it does not '. + 'specify any enabled servers with type "client". Notifications '. + 'require at least one active "client" server.')); + } + } + } + +} diff --git a/src/applications/notification/controller/PhabricatorNotificationPanelController.php b/src/applications/notification/controller/PhabricatorNotificationPanelController.php index c16069a0e6..be68cc2de7 100644 --- a/src/applications/notification/controller/PhabricatorNotificationPanelController.php +++ b/src/applications/notification/controller/PhabricatorNotificationPanelController.php @@ -44,17 +44,8 @@ final class PhabricatorNotificationPanelController ), pht('Notifications')); - if (PhabricatorEnv::getEnvConfig('notification.enabled')) { - $connection_status = new PhabricatorNotificationStatusView(); - } else { - $connection_status = phutil_tag( - 'a', - array( - 'href' => PhabricatorEnv::getDoclink( - 'Notifications User Guide: Setup and Configuration'), - ), - pht('Notification Server not enabled.')); - } + $connection_status = new PhabricatorNotificationStatusView(); + $connection_ui = phutil_tag( 'div', array( diff --git a/src/applications/notification/controller/PhabricatorNotificationStatusController.php b/src/applications/notification/controller/PhabricatorNotificationStatusController.php deleted file mode 100644 index 49d3fb9d9b..0000000000 --- a/src/applications/notification/controller/PhabricatorNotificationStatusController.php +++ /dev/null @@ -1,82 +0,0 @@ -renderServerStatus($status); - } catch (Exception $ex) { - $status = new PHUIInfoView(); - $status->setTitle(pht('Notification Server Issue')); - $status->appendChild(hsprintf( - '%s

'. - '%s %s', - pht( - 'Unable to determine server status. This probably means the server '. - 'is not in great shape. The specific issue encountered was:'), - get_class($ex), - phutil_escape_html_newlines($ex->getMessage()))); - } - - $crumbs = $this->buildApplicationCrumbs(); - $crumbs->addTextCrumb(pht('Status')); - - $title = pht('Notification Server Status'); - - return $this->newPage() - ->setTitle($title) - ->setCrumbs($crumbs) - ->appendChild($status); - } - - private function renderServerStatus(array $status) { - - $rows = array(); - foreach ($status as $key => $value) { - switch ($key) { - case 'uptime': - $value /= 1000; - $value = phutil_format_relative_time_detailed($value); - break; - case 'log': - case 'instance': - break; - default: - $value = number_format($value); - break; - } - - $rows[] = array($key, $value); - } - - $table = new AphrontTableView($rows); - $table->setColumnClasses( - array( - 'header', - 'wide', - )); - - $test_icon = id(new PHUIIconView()) - ->setIcon('fa-exclamation-triangle'); - - $test_button = id(new PHUIButtonView()) - ->setTag('a') - ->setWorkflow(true) - ->setText(pht('Send Test Notification')) - ->setHref($this->getApplicationURI('test/')) - ->setIcon($test_icon); - - $header = id(new PHUIHeaderView()) - ->setHeader(pht('Notification Server Status')) - ->addActionLink($test_button); - - $box = id(new PHUIObjectBoxView()) - ->setHeader($header) - ->appendChild($table); - - return $box; - } -} diff --git a/src/applications/notification/setup/PhabricatorAphlictSetupCheck.php b/src/applications/notification/setup/PhabricatorAphlictSetupCheck.php index 99dba45ec4..b42ff85c2e 100644 --- a/src/applications/notification/setup/PhabricatorAphlictSetupCheck.php +++ b/src/applications/notification/setup/PhabricatorAphlictSetupCheck.php @@ -3,14 +3,8 @@ final class PhabricatorAphlictSetupCheck extends PhabricatorSetupCheck { protected function executeChecks() { - $enabled = PhabricatorEnv::getEnvConfig('notification.enabled'); - if (!$enabled) { - // Notifications aren't set up, so just ignore all of these checks. - return; - } - try { - $status = PhabricatorNotificationClient::getServerStatus(); + PhabricatorNotificationClient::tryAnyConnection(); } catch (Exception $ex) { $message = pht( "Phabricator is configured to use a notification server, but is ". @@ -31,8 +25,7 @@ final class PhabricatorAphlictSetupCheck extends PhabricatorSetupCheck { ->setShortName(pht('Notification Server Down')) ->setName(pht('Unable to Connect to Notification Server')) ->setMessage($message) - ->addRelatedPhabricatorConfig('notification.enabled') - ->addRelatedPhabricatorConfig('notification.server-uri') + ->addRelatedPhabricatorConfig('notification.servers') ->addCommand( pht( "(To start the server, run this command.)\n%s", @@ -40,23 +33,5 @@ final class PhabricatorAphlictSetupCheck extends PhabricatorSetupCheck { return; } - - $expect_version = PhabricatorNotificationClient::EXPECT_VERSION; - $have_version = idx($status, 'version', 1); - if ($have_version != $expect_version) { - $message = pht( - 'The notification server is out of date. You are running server '. - 'version %d, but Phabricator expects version %d. Restart the server '. - 'to update it, using the command below:', - $have_version, - $expect_version); - - $this->newIssue('aphlict.version') - ->setShortName(pht('Notification Server Version')) - ->setName(pht('Notification Server Out of Date')) - ->setMessage($message) - ->addCommand('phabricator/ $ ./bin/aphlict restart'); - } - } } diff --git a/src/applications/notification/storage/PhabricatorFeedStoryNotification.php b/src/applications/notification/storage/PhabricatorFeedStoryNotification.php index 30aec1f281..d0b4acab53 100644 --- a/src/applications/notification/storage/PhabricatorFeedStoryNotification.php +++ b/src/applications/notification/storage/PhabricatorFeedStoryNotification.php @@ -39,6 +39,10 @@ final class PhabricatorFeedStoryNotification extends PhabricatorFeedDAO { PhabricatorUser $user, $object_phid) { + if (PhabricatorEnv::isReadOnly()) { + return; + } + $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); $notification_table = new PhabricatorFeedStoryNotification(); diff --git a/src/applications/passphrase/controller/PassphraseCredentialRevealController.php b/src/applications/passphrase/controller/PassphraseCredentialRevealController.php index fb784fb5b8..4fb299b85e 100644 --- a/src/applications/passphrase/controller/PassphraseCredentialRevealController.php +++ b/src/applications/passphrase/controller/PassphraseCredentialRevealController.php @@ -51,6 +51,7 @@ final class PassphraseCredentialRevealController id(new AphrontFormTextAreaControl()) ->setLabel(pht('Plaintext')) ->setReadOnly(true) + ->setCustomClass('PhabricatorMonospaced') ->setHeight(AphrontFormTextAreaControl::HEIGHT_VERY_TALL) ->setValue($secret->openEnvelope())); } diff --git a/src/applications/people/profilepanel/PhabricatorPeopleManageProfilePanel.php b/src/applications/people/profilepanel/PhabricatorPeopleManageProfilePanel.php index f010016d35..29000b5a94 100644 --- a/src/applications/people/profilepanel/PhabricatorPeopleManageProfilePanel.php +++ b/src/applications/people/profilepanel/PhabricatorPeopleManageProfilePanel.php @@ -6,7 +6,7 @@ final class PhabricatorPeopleManageProfilePanel const PANELKEY = 'people.manage'; public function getPanelTypeName() { - return pht('Mangage User'); + return pht('Manage User'); } private function getDefaultName() { diff --git a/src/applications/people/storage/PhabricatorUser.php b/src/applications/people/storage/PhabricatorUser.php index ed6fceba1e..2c9202ef67 100644 --- a/src/applications/people/storage/PhabricatorUser.php +++ b/src/applications/people/storage/PhabricatorUser.php @@ -133,6 +133,19 @@ final class PhabricatorUser } public function canEstablishAPISessions() { + if ($this->getIsDisabled()) { + return false; + } + + // Intracluster requests are permitted even if the user is logged out: + // in particular, public users are allowed to issue intracluster requests + // when browsing Diffusion. + if (PhabricatorEnv::isClusterRemoteAddress()) { + if (!$this->isLoggedIn()) { + return true; + } + } + if (!$this->isUserActivated()) { return false; } diff --git a/src/applications/phurl/controller/PhabricatorPhurlURLViewController.php b/src/applications/phurl/controller/PhabricatorPhurlURLViewController.php index 4703adade5..1c9a66f237 100644 --- a/src/applications/phurl/controller/PhabricatorPhurlURLViewController.php +++ b/src/applications/phurl/controller/PhabricatorPhurlURLViewController.php @@ -114,7 +114,7 @@ final class PhabricatorPhurlURLViewController $curtain ->addAction( id(new PhabricatorActionView()) - ->setName(pht('Edit')) + ->setName(pht('Edit Phurl')) ->setIcon('fa-pencil') ->setHref($this->getApplicationURI("url/edit/{$id}/")) ->setDisabled(!$can_edit) diff --git a/src/applications/phurl/query/PhabricatorPhurlURLSearchEngine.php b/src/applications/phurl/query/PhabricatorPhurlURLSearchEngine.php index c652cfffdf..db40c263ad 100644 --- a/src/applications/phurl/query/PhabricatorPhurlURLSearchEngine.php +++ b/src/applications/phurl/query/PhabricatorPhurlURLSearchEngine.php @@ -44,8 +44,8 @@ final class PhabricatorPhurlURLSearchEngine protected function getBuiltinQueryNames() { $names = array( - 'authored' => pht('Authored'), 'all' => pht('All URLs'), + 'authored' => pht('Authored'), ); return $names; @@ -77,10 +77,16 @@ final class PhabricatorPhurlURLSearchEngine $handles = $viewer->loadHandles(mpull($urls, 'getAuthorPHID')); foreach ($urls as $url) { + $name = $url->getName(); + $item = id(new PHUIObjectItemView()) ->setUser($viewer) ->setObject($url) - ->setHeader($viewer->renderHandle($url->getPHID())); + ->setObjectName('U'.$url->getID()) + ->setHeader($name) + ->setHref('/U'.$url->getID()) + ->addAttribute($url->getAlias()) + ->addAttribute($url->getLongURL()); $list->addItem($item); } diff --git a/src/applications/project/controller/PhabricatorProjectMembersViewController.php b/src/applications/project/controller/PhabricatorProjectMembersViewController.php index 88bc56be24..e98412ff31 100644 --- a/src/applications/project/controller/PhabricatorProjectMembersViewController.php +++ b/src/applications/project/controller/PhabricatorProjectMembersViewController.php @@ -22,21 +22,23 @@ final class PhabricatorProjectMembersViewController $title = pht('Members and Watchers'); $properties = $this->buildProperties($project); - $actions = $this->buildActions($project); - $properties->setActionList($actions); + $curtain = $this->buildCurtainView($project); $object_box = id(new PHUIObjectBoxView()) - ->setHeaderText($title) + ->setHeaderText(pht('Details')) + ->setBackground(PHUIObjectBoxView::BLUE_PROPERTY) ->addPropertyList($properties); $member_list = id(new PhabricatorProjectMemberListView()) ->setUser($viewer) ->setProject($project) + ->setBackground(PHUIObjectBoxView::BLUE_PROPERTY) ->setUserPHIDs($project->getMemberPHIDs()); $watcher_list = id(new PhabricatorProjectWatcherListView()) ->setUser($viewer) ->setProject($project) + ->setBackground(PHUIObjectBoxView::BLUE_PROPERTY) ->setUserPHIDs($project->getWatcherPHIDs()); $nav = $this->getProfileMenu(); @@ -44,17 +46,27 @@ final class PhabricatorProjectMembersViewController $crumbs = $this->buildApplicationCrumbs(); $crumbs->addTextCrumb(pht('Members')); + $crumbs->setBorder(true); + + $header = id(new PHUIHeaderView()) + ->setHeader($title) + ->setHeaderIcon('fa-group'); + + $view = id(new PHUITwoColumnView()) + ->setHeader($header) + ->setCurtain($curtain) + ->setMainColumn(array( + $object_box, + $member_list, + $watcher_list, + )); + return $this->newPage() ->setNavigation($nav) ->setCrumbs($crumbs) - ->setTitle(array($project->getDisplayName(), $title)) - ->appendChild( - array( - $object_box, - $member_list, - $watcher_list, - )); + ->setTitle(array($project->getName(), $title)) + ->appendChild($view); } private function buildProperties(PhabricatorProject $project) { @@ -156,12 +168,11 @@ final class PhabricatorProjectMembersViewController return $view; } - private function buildActions(PhabricatorProject $project) { + private function buildCurtainView(PhabricatorProject $project) { $viewer = $this->getViewer(); $id = $project->getID(); - $view = id(new PhabricatorActionListView()) - ->setUser($viewer); + $curtain = $this->newCurtainView($project); $is_locked = $project->getIsMembershipLocked(); @@ -182,7 +193,7 @@ final class PhabricatorProjectMembersViewController $viewer_phid = $viewer->getPHID(); if (!$project->isUserMember($viewer_phid)) { - $view->addAction( + $curtain->addAction( id(new PhabricatorActionView()) ->setHref('/project/update/'.$project->getID().'/join/') ->setIcon('fa-plus') @@ -190,7 +201,7 @@ final class PhabricatorProjectMembersViewController ->setWorkflow(true) ->setName(pht('Join Project'))); } else { - $view->addAction( + $curtain->addAction( id(new PhabricatorActionView()) ->setHref('/project/update/'.$project->getID().'/leave/') ->setIcon('fa-times') @@ -200,14 +211,14 @@ final class PhabricatorProjectMembersViewController } if (!$project->isUserWatcher($viewer->getPHID())) { - $view->addAction( + $curtain->addAction( id(new PhabricatorActionView()) ->setWorkflow(true) ->setHref('/project/watch/'.$project->getID().'/') ->setIcon('fa-eye') ->setName(pht('Watch Project'))); } else { - $view->addAction( + $curtain->addAction( id(new PhabricatorActionView()) ->setWorkflow(true) ->setHref('/project/unwatch/'.$project->getID().'/') @@ -224,7 +235,7 @@ final class PhabricatorProjectMembersViewController $silence_text = pht('Disable Mail'); } - $view->addAction( + $curtain->addAction( id(new PhabricatorActionView()) ->setName($silence_text) ->setIcon('fa-envelope-o') @@ -234,7 +245,7 @@ final class PhabricatorProjectMembersViewController $can_add = $can_edit && $supports_edit; - $view->addAction( + $curtain->addAction( id(new PhabricatorActionView()) ->setName(pht('Add Members')) ->setIcon('fa-user-plus') @@ -253,7 +264,7 @@ final class PhabricatorProjectMembersViewController $lock_icon = 'fa-lock'; } - $view->addAction( + $curtain->addAction( id(new PhabricatorActionView()) ->setName($lock_name) ->setIcon($lock_icon) @@ -261,7 +272,7 @@ final class PhabricatorProjectMembersViewController ->setDisabled(!$can_lock) ->setWorkflow(true)); - return $view; + return $curtain; } private function isProjectSilenced(PhabricatorProject $project) { diff --git a/src/applications/project/controller/PhabricatorProjectSubprojectsController.php b/src/applications/project/controller/PhabricatorProjectSubprojectsController.php index eb32d00b92..1232d4010e 100644 --- a/src/applications/project/controller/PhabricatorProjectSubprojectsController.php +++ b/src/applications/project/controller/PhabricatorProjectSubprojectsController.php @@ -52,6 +52,7 @@ final class PhabricatorProjectSubprojectsController if ($milestones) { $milestone_list = id(new PHUIObjectBoxView()) ->setHeaderText(pht('Milestones')) + ->setBackground(PHUIObjectBoxView::BLUE_PROPERTY) ->setObjectList( id(new PhabricatorProjectListView()) ->setUser($viewer) @@ -64,6 +65,7 @@ final class PhabricatorProjectSubprojectsController if ($subprojects) { $subproject_list = id(new PHUIObjectBoxView()) ->setHeaderText(pht('Subprojects')) + ->setBackground(PHUIObjectBoxView::BLUE_PROPERTY) ->setObjectList( id(new PhabricatorProjectListView()) ->setUser($viewer) @@ -78,15 +80,15 @@ final class PhabricatorProjectSubprojectsController $milestones, $subprojects); - $action_list = $this->buildActionList( + $curtain = $this->buildCurtainView( $project, $milestones, $subprojects); - $property_list->setActionList($action_list); - $header_box = id(new PHUIObjectBoxView()) - ->setHeaderText(pht('Subprojects and Milestones')) + $details = id(new PHUIObjectBoxView()) + ->setHeaderText(pht('Details')) + ->setBackground(PHUIObjectBoxView::BLUE_PROPERTY) ->addPropertyList($property_list); $nav = $this->getProfileMenu(); @@ -94,17 +96,26 @@ final class PhabricatorProjectSubprojectsController $crumbs = $this->buildApplicationCrumbs(); $crumbs->addTextCrumb(pht('Subprojects')); + $crumbs->setBorder(true); + + $header = id(new PHUIHeaderView()) + ->setHeader(pht('Subprojects and Milestones')) + ->setHeaderIcon('fa-sitemap'); + + $view = id(new PHUITwoColumnView()) + ->setHeader($header) + ->setCurtain($curtain) + ->setMainColumn(array( + $details, + $milestone_list, + $subproject_list, + )); return $this->newPage() ->setNavigation($nav) ->setCrumbs($crumbs) ->setTitle(array($project->getName(), pht('Subprojects'))) - ->appendChild( - array( - $header_box, - $milestone_list, - $subproject_list, - )); + ->appendChild($view); } private function buildPropertyList( @@ -174,7 +185,7 @@ final class PhabricatorProjectSubprojectsController return $view; } - private function buildActionList( + private function buildCurtainView( PhabricatorProject $project, array $milestones, array $subprojects) { @@ -192,8 +203,7 @@ final class PhabricatorProjectSubprojectsController $allows_milestones = $project->supportsMilestones(); $allows_subprojects = $project->supportsSubprojects(); - $view = id(new PhabricatorActionListView()) - ->setUser($viewer); + $curtain = $this->newCurtainView($project); if ($allows_milestones && $milestones) { $milestone_text = pht('Create Next Milestone'); @@ -204,7 +214,7 @@ final class PhabricatorProjectSubprojectsController $can_milestone = ($can_create && $can_edit && $allows_milestones); $milestone_href = "/project/edit/?milestone={$id}"; - $view->addAction( + $curtain->addAction( id(new PhabricatorActionView()) ->setName($milestone_text) ->setIcon('fa-plus') @@ -226,7 +236,7 @@ final class PhabricatorProjectSubprojectsController $subproject_workflow = !$can_subproject; } - $view->addAction( + $curtain->addAction( id(new PhabricatorActionView()) ->setName(pht('Create Subproject')) ->setIcon('fa-plus') @@ -234,7 +244,7 @@ final class PhabricatorProjectSubprojectsController ->setDisabled($subproject_disabled) ->setWorkflow($subproject_workflow)); - return $view; + return $curtain; } private function renderStatus($icon, $target, $note) { diff --git a/src/applications/repository/daemon/PhabricatorRepositoryPullLocalDaemon.php b/src/applications/repository/daemon/PhabricatorRepositoryPullLocalDaemon.php index 21b0e13413..da8d9336d2 100644 --- a/src/applications/repository/daemon/PhabricatorRepositoryPullLocalDaemon.php +++ b/src/applications/repository/daemon/PhabricatorRepositoryPullLocalDaemon.php @@ -74,7 +74,9 @@ final class PhabricatorRepositoryPullLocalDaemon while (!$this->shouldExit()) { PhabricatorCaches::destroyRequestCache(); - $pullable = $this->loadPullableRepositories($include, $exclude); + $device = AlmanacKeys::getLiveDevice(); + + $pullable = $this->loadPullableRepositories($include, $exclude, $device); // If any repositories have the NEEDS_UPDATE flag set, pull them // as soon as possible. @@ -297,7 +299,11 @@ final class PhabricatorRepositoryPullLocalDaemon /** * @task pull */ - private function loadPullableRepositories(array $include, array $exclude) { + private function loadPullableRepositories( + array $include, + array $exclude, + AlmanacDevice $device = null) { + $query = id(new PhabricatorRepositoryQuery()) ->setViewer($this->getViewer()); @@ -348,6 +354,119 @@ final class PhabricatorRepositoryPullLocalDaemon } } + $service_phids = array(); + foreach ($repositories as $key => $repository) { + $service_phid = $repository->getAlmanacServicePHID(); + + // If the repository is bound to a service but this host is not a + // recognized device, or vice versa, don't pull the repository. + $is_cluster_repo = (bool)$service_phid; + $is_cluster_device = (bool)$device; + if ($is_cluster_repo != $is_cluster_device) { + if ($is_cluster_device) { + $this->log( + pht( + 'Repository "%s" is not a cluster repository, but the current '. + 'host is a cluster device ("%s"), so the repository will not '. + 'be updated on this host.', + $repository->getDisplayName(), + $device->getName())); + } else { + $this->log( + pht( + 'Repository "%s" is a cluster repository, but the current '. + 'host is not a cluster device (it has no device ID), so the '. + 'repository will not be updated on this host.', + $repository->getDisplayName())); + } + unset($repositories[$key]); + continue; + } + + if ($service_phid) { + $service_phids[] = $service_phid; + } + } + + if ($device) { + $device_phid = $device->getPHID(); + + if ($service_phids) { + // We could include `withDevicePHIDs()` here to pull a smaller result + // set, but we can provide more helpful diagnostic messages below if + // we fetch a little more data. + $services = id(new AlmanacServiceQuery()) + ->setViewer($this->getViewer()) + ->withPHIDs($service_phids) + ->withServiceTypes( + array( + AlmanacClusterRepositoryServiceType::SERVICETYPE, + )) + ->needBindings(true) + ->execute(); + $services = mpull($services, null, 'getPHID'); + } else { + $services = array(); + } + + foreach ($repositories as $key => $repository) { + $service_phid = $repository->getAlmanacServicePHID(); + + $service = idx($services, $service_phid); + if (!$service) { + $this->log( + pht( + 'Repository "%s" is on cluster service "%s", but that service '. + 'could not be loaded, so the repository will not be updated '. + 'on this host.', + $repository->getDisplayName(), + $service_phid)); + unset($repositories[$key]); + continue; + } + + $bindings = $service->getBindings(); + $bindings = mgroup($bindings, 'getDevicePHID'); + $bindings = idx($bindings, $device_phid); + if (!$bindings) { + $this->log( + pht( + 'Repository "%s" is on cluster service "%s", but that service '. + 'is not bound to this device ("%s"), so the repository will '. + 'not be updated on this host.', + $repository->getDisplayName(), + $service->getName(), + $device->getName())); + unset($repositories[$key]); + continue; + } + + $all_disabled = true; + foreach ($bindings as $binding) { + if (!$binding->getIsDisabled()) { + $all_disabled = false; + break; + } + } + + if ($all_disabled) { + $this->log( + pht( + 'Repository "%s" is on cluster service "%s", but the binding '. + 'between that service and this device ("%s") is disabled, so '. + 'the not be updated on this host.', + $repository->getDisplayName(), + $service->getName(), + $device->getName())); + unset($repositories[$key]); + continue; + } + + // We have a valid service that is actively bound to the current host + // device, so we're good to go. + } + } + // Shuffle the repositories, then re-key the array since shuffle() // discards keys. This is mostly for startup, we'll use soft priorities // later. diff --git a/src/applications/repository/storage/PhabricatorRepository.php b/src/applications/repository/storage/PhabricatorRepository.php index 7068657acc..22a28daaa3 100644 --- a/src/applications/repository/storage/PhabricatorRepository.php +++ b/src/applications/repository/storage/PhabricatorRepository.php @@ -3,6 +3,7 @@ /** * @task uri Repository URI Management * @task autoclose Autoclose + * @task sync Cluster Synchronization */ final class PhabricatorRepository extends PhabricatorRepositoryDAO implements @@ -62,6 +63,9 @@ final class PhabricatorRepository extends PhabricatorRepositoryDAO private $mostRecentCommit = self::ATTACHABLE; private $projectPHIDs = self::ATTACHABLE; + private $clusterWriteLock; + private $clusterWriteVersion; + public static function initializeNewRepository(PhabricatorUser $actor) { $app = id(new PhabricatorApplicationQuery()) ->setViewer($actor) @@ -2262,6 +2266,174 @@ final class PhabricatorRepository extends PhabricatorRepositoryDAO } +/* -( Cluster Synchronization )-------------------------------------------- */ + + + private function shouldEnableSynchronization() { + // TODO: This mostly works, but isn't stable enough for production yet. + return false; + + $device = AlmanacKeys::getLiveDevice(); + if (!$device) { + return false; + } + + return true; + } + + + /** + * @task sync + */ + public function synchronizeWorkingCopyBeforeRead() { + if (!$this->shouldEnableSynchronization()) { + return; + } + + $repository_phid = $this->getPHID(); + + $device = AlmanacKeys::getLiveDevice(); + $device_phid = $device->getPHID(); + + $read_lock = PhabricatorRepositoryWorkingCopyVersion::getReadLock( + $repository_phid, + $device_phid); + + // TODO: Raise a more useful exception if we fail to grab this lock. + $read_lock->lock(phutil_units('2 minutes in seconds')); + + $versions = PhabricatorRepositoryWorkingCopyVersion::loadVersions( + $repository_phid); + $versions = mpull($versions, null, 'getDevicePHID'); + + $this_version = idx($versions, $device_phid); + if ($this_version) { + $this_version = (int)$this_version->getRepositoryVersion(); + } else { + $this_version = 0; + } + + if ($versions) { + $max_version = (int)max(mpull($versions, 'getRepositoryVersion')); + } else { + $max_version = 0; + } + + if ($max_version > $this_version) { + $fetchable = array(); + foreach ($versions as $version) { + if ($version->getRepositoryVersion() == $max_version) { + $fetchable[] = $version->getDevicePHID(); + } + } + + // TODO: Actualy fetch the newer version from one of the nodes which has + // it. + + PhabricatorRepositoryWorkingCopyVersion::updateVersion( + $repository_phid, + $device_phid, + $max_version); + } + + $read_lock->unlock(); + + return $max_version; + } + + + /** + * @task sync + */ + public function synchronizeWorkingCopyBeforeWrite() { + if (!$this->shouldEnableSynchronization()) { + return; + } + + $repository_phid = $this->getPHID(); + + $device = AlmanacKeys::getLiveDevice(); + $device_phid = $device->getPHID(); + + $write_lock = PhabricatorRepositoryWorkingCopyVersion::getWriteLock( + $repository_phid); + + // TODO: Raise a more useful exception if we fail to grab this lock. + $write_lock->lock(phutil_units('2 minutes in seconds')); + + $versions = PhabricatorRepositoryWorkingCopyVersion::loadVersions( + $repository_phid); + foreach ($versions as $version) { + if (!$version->getIsWriting()) { + continue; + } + + // TODO: This should provide more help so users can resolve the issue. + throw new Exception( + pht( + 'An incomplete write was previously performed to this repository; '. + 'refusing new writes.')); + } + + $max_version = $this->synchronizeWorkingCopyBeforeRead(); + + PhabricatorRepositoryWorkingCopyVersion::willWrite( + $repository_phid, + $device_phid); + + $this->clusterWriteVersion = $max_version; + $this->clusterWriteLock = $write_lock; + } + + + /** + * @task sync + */ + public function synchronizeWorkingCopyAfterWrite() { + if (!$this->shouldEnableSynchronization()) { + return; + } + + if (!$this->clusterWriteLock) { + throw new Exception( + pht( + 'Trying to synchronize after write, but not holding a write '. + 'lock!')); + } + + $repository_phid = $this->getPHID(); + + $device = AlmanacKeys::getLiveDevice(); + $device_phid = $device->getPHID(); + + // NOTE: This means we're still bumping the version when pushes fail. We + // could select only un-rejected events instead to bump a little less + // often. + + $new_log = id(new PhabricatorRepositoryPushEventQuery()) + ->setViewer(PhabricatorUser::getOmnipotentUser()) + ->withRepositoryPHIDs(array($repository_phid)) + ->setLimit(1) + ->executeOne(); + + $old_version = $this->clusterWriteVersion; + if ($new_log) { + $new_version = $new_log->getID(); + } else { + $new_version = $old_version; + } + + PhabricatorRepositoryWorkingCopyVersion::didWrite( + $repository_phid, + $device_phid, + $this->clusterWriteVersion, + $new_log->getID()); + + $this->clusterWriteLock->unlock(); + $this->clusterWriteLock = null; + } + + /* -( Symbols )-------------------------------------------------------------*/ public function getSymbolSources() { diff --git a/src/applications/repository/storage/PhabricatorRepositoryWorkingCopyVersion.php b/src/applications/repository/storage/PhabricatorRepositoryWorkingCopyVersion.php new file mode 100644 index 0000000000..00e74a3d61 --- /dev/null +++ b/src/applications/repository/storage/PhabricatorRepositoryWorkingCopyVersion.php @@ -0,0 +1,145 @@ + false, + self::CONFIG_COLUMN_SCHEMA => array( + 'repositoryVersion' => 'uint32', + 'isWriting' => 'bool', + ), + self::CONFIG_KEY_SCHEMA => array( + 'key_workingcopy' => array( + 'columns' => array('repositoryPHID', 'devicePHID'), + 'unique' => true, + ), + ), + ) + parent::getConfiguration(); + } + + public static function loadVersions($repository_phid) { + $version = new self(); + $conn_w = $version->establishConnection('w'); + $table = $version->getTableName(); + + // This is a normal read, but force it to come from the master. + $rows = queryfx_all( + $conn_w, + 'SELECT * FROM %T WHERE repositoryPHID = %s', + $table, + $repository_phid); + + return $version->loadAllFromArray($rows); + } + + public static function getReadLock($repository_phid, $device_phid) { + $repository_hash = PhabricatorHash::digestForIndex($repository_phid); + $device_hash = PhabricatorHash::digestForIndex($device_phid); + $lock_key = "repo.read({$repository_hash}, {$device_hash})"; + + return PhabricatorGlobalLock::newLock($lock_key); + } + + public static function getWriteLock($repository_phid) { + $repository_hash = PhabricatorHash::digestForIndex($repository_phid); + $lock_key = "repo.write({$repository_hash})"; + + return PhabricatorGlobalLock::newLock($lock_key); + } + + + /** + * Before a write, set the "isWriting" flag. + * + * This allows us to detect when we lose a node partway through a write and + * may have committed and acknowledged a write on a node that lost the lock + * partway through the write and is no longer reachable. + * + * In particular, if a node loses its connection to the datbase the global + * lock is released by default. This is a durable lock which stays locked + * by default. + */ + public static function willWrite($repository_phid, $device_phid) { + $version = new self(); + $conn_w = $version->establishConnection('w'); + $table = $version->getTableName(); + + queryfx( + $conn_w, + 'INSERT INTO %T + (repositoryPHID, devicePHID, repositoryVersion, isWriting) + VALUES + (%s, %s, %d, %d) + ON DUPLICATE KEY UPDATE + isWriting = VALUES(isWriting)', + $table, + $repository_phid, + $device_phid, + 1, + 1); + } + + + /** + * After a write, update the version and release the "isWriting" lock. + */ + public static function didWrite( + $repository_phid, + $device_phid, + $old_version, + $new_version) { + $version = new self(); + $conn_w = $version->establishConnection('w'); + $table = $version->getTableName(); + + queryfx( + $conn_w, + 'UPDATE %T SET repositoryVersion = %d, isWriting = 0 + WHERE + repositoryPHID = %s AND + devicePHID = %s AND + repositoryVersion = %d AND + isWriting = 1', + $table, + $new_version, + $repository_phid, + $device_phid, + $old_version); + } + + + /** + * After a fetch, set the local version to the fetched version. + */ + public static function updateVersion( + $repository_phid, + $device_phid, + $new_version) { + $version = new self(); + $conn_w = $version->establishConnection('w'); + $table = $version->getTableName(); + + queryfx( + $conn_w, + 'INSERT INTO %T + (repositoryPHID, devicePHID, repositoryVersion, isWriting) + VALUES + (%s, %s, %d, %d) + ON DUPLICATE KEY UPDATE + repositoryVersion = VALUES(repositoryVersion)', + $table, + $repository_phid, + $device_phid, + $new_version, + 0); + } + + +} diff --git a/src/applications/repository/worker/commitchangeparser/PhabricatorRepositoryGitCommitChangeParserWorker.php b/src/applications/repository/worker/commitchangeparser/PhabricatorRepositoryGitCommitChangeParserWorker.php index 4ab6f03f32..e421da228a 100644 --- a/src/applications/repository/worker/commitchangeparser/PhabricatorRepositoryGitCommitChangeParserWorker.php +++ b/src/applications/repository/worker/commitchangeparser/PhabricatorRepositoryGitCommitChangeParserWorker.php @@ -7,38 +7,18 @@ final class PhabricatorRepositoryGitCommitChangeParserWorker PhabricatorRepository $repository, PhabricatorRepositoryCommit $commit) { - // Check if the commit has parents. We're testing to see whether it is the - // first commit in history (in which case we must use "git log") or some - // other commit (in which case we can use "git diff"). We'd rather use - // "git diff" because it has the right behavior for merge commits, but - // it requires the commit to have a parent that we can diff against. The - // first commit doesn't, so "commit^" is not a valid ref. - list($parents) = $repository->execxLocalCommand( - 'log -n1 --format=%s %s', - '%P', - $commit->getCommitIdentifier()); - - $use_log = !strlen(trim($parents)); - if ($use_log) { - // This is the first commit so we need to use "log". We know it's not a - // merge commit because it couldn't be merging anything, so this is safe. - - // NOTE: "--pretty=format: " is to disable diff output, we only want the - // part we get from "--raw". - list($raw) = $repository->execxLocalCommand( - 'log -n1 -M -C -B --find-copies-harder --raw -t '. - '--pretty=format: --abbrev=40 %s', - $commit->getCommitIdentifier()); - } else { - // Otherwise, we can use "diff", which will give us output for merges. - // We diff against the first parent, as this is generally the expectation - // and results in sensible behavior. - list($raw) = $repository->execxLocalCommand( - 'diff -n1 -M -C -B --find-copies-harder --raw -t '. - '--abbrev=40 %s^1 %s', - $commit->getCommitIdentifier(), - $commit->getCommitIdentifier()); - } + $viewer = PhabricatorUser::getOmnipotentUser(); + $raw = DiffusionQuery::callConduitWithDiffusionRequest( + $viewer, + DiffusionRequest::newFromDictionary( + array( + 'repository' => $repository, + 'user' => $viewer, + )), + 'diffusion.internal.gitrawdiffquery', + array( + 'commit' => $commit->getCommitIdentifier(), + )); $changes = array(); $move_away = array(); diff --git a/src/applications/settings/panel/PhabricatorDesktopNotificationsSettingsPanel.php b/src/applications/settings/panel/PhabricatorDesktopNotificationsSettingsPanel.php index bc7538f6bd..8da45a518c 100644 --- a/src/applications/settings/panel/PhabricatorDesktopNotificationsSettingsPanel.php +++ b/src/applications/settings/panel/PhabricatorDesktopNotificationsSettingsPanel.php @@ -4,9 +4,13 @@ final class PhabricatorDesktopNotificationsSettingsPanel extends PhabricatorSettingsPanel { public function isEnabled() { - return PhabricatorEnv::getEnvConfig('notification.enabled') && - PhabricatorApplication::isClassInstalled( - 'PhabricatorNotificationsApplication'); + $servers = PhabricatorNotificationServerRef::getEnabledAdminServers(); + if (!$servers) { + return false; + } + + return PhabricatorApplication::isClassInstalled( + 'PhabricatorNotificationsApplication'); } public function getPanelKey() { diff --git a/src/applications/system/application/PhabricatorSystemApplication.php b/src/applications/system/application/PhabricatorSystemApplication.php index 4e1a0b23e5..0ec8f6a7a4 100644 --- a/src/applications/system/application/PhabricatorSystemApplication.php +++ b/src/applications/system/application/PhabricatorSystemApplication.php @@ -23,6 +23,9 @@ final class PhabricatorSystemApplication extends PhabricatorApplication { 'encoding/' => 'PhabricatorSystemSelectEncodingController', 'highlight/' => 'PhabricatorSystemSelectHighlightController', ), + '/readonly/' => array( + '(?P[^/]+)/' => 'PhabricatorSystemReadOnlyController', + ), ); } diff --git a/src/applications/system/controller/PhabricatorSystemReadOnlyController.php b/src/applications/system/controller/PhabricatorSystemReadOnlyController.php new file mode 100644 index 0000000000..7541e9adf4 --- /dev/null +++ b/src/applications/system/controller/PhabricatorSystemReadOnlyController.php @@ -0,0 +1,134 @@ +getViewer(); + $reason = $request->getURIData('reason'); + + $body = array(); + switch ($reason) { + case PhabricatorEnv::READONLY_CONFIG: + $title = pht('Administrative Read-Only Mode'); + $body[] = pht( + 'An administrator has placed Phabricator into read-only mode.'); + $body[] = pht( + 'This mode may be used to perform temporary maintenance, test '. + 'configuration, or archive an installation permanently.'); + $body[] = pht( + 'Read-only mode was enabled by the explicit action of a human '. + 'administrator, so you can get more information about why it '. + 'has been turned on by rolling your chair away from your desk and '. + 'yelling "Hey! Why is Phabricator in read-only mode??!" using '. + 'your very loudest outside voice.'); + $body[] = pht( + 'This mode is active because it is enabled in the configuration '. + 'option "%s".', + phutil_tag('tt', array(), 'cluster.read-only')); + $button = pht('Wait Patiently'); + break; + case PhabricatorEnv::READONLY_MASTERLESS: + $title = pht('No Writable Database'); + $body[] = pht( + 'Phabricator is currently configured with no writable ("master") '. + 'database, so it can not write new information anywhere. '. + 'Phabricator will run in read-only mode until an administrator '. + 'reconfigures it with a writable database.'); + $body[] = pht( + 'This usually occurs when an administrator is actively working on '. + 'fixing a temporary configuration or deployment problem.'); + $body[] = pht( + 'This mode is active because no database has a "%s" role in '. + 'the configuration option "%s".', + phutil_tag('tt', array(), 'master'), + phutil_tag('tt', array(), 'cluster.databases')); + $button = pht('Wait Patiently'); + break; + case PhabricatorEnv::READONLY_UNREACHABLE: + $title = pht('Unable to Reach Master'); + $body[] = pht( + 'Phabricator was unable to connect to the writable ("master") '. + 'database while handling this request, and automatically degraded '. + 'into read-only mode.'); + $body[] = pht( + 'This may happen if there is a temporary network anomaly on the '. + 'server side, like cosmic radiation or spooky ghosts. If this '. + 'failure was caused by a transient service interruption, '. + 'Phabricator will recover momentarily.'); + $body[] = pht( + 'This may also indicate that a more serious failure has occurred. '. + 'If this interruption does not resolve on its own, Phabricator '. + 'will soon detect the persistent disruption and degrade into '. + 'read-only mode until the issue is resolved.'); + $button = pht('Quite Unsettling'); + break; + case PhabricatorEnv::READONLY_SEVERED: + $title = pht('Severed From Master'); + $body[] = pht( + 'Phabricator has consistently been unable to reach the writable '. + '("master") database while processing recent requests.'); + $body[] = pht( + 'This likely indicates a severe misconfiguration or major service '. + 'interruption.'); + $body[] = pht( + 'Phabricator will periodically retry the connection and recover '. + 'once service is restored. Most causes of persistent service '. + 'interruption will require administrative intervention in order '. + 'to restore service.'); + $body[] = pht( + 'Although this may be the result of a misconfiguration or '. + 'operational error, this is also the state you reach if a '. + 'meteor recently obliterated a datacenter.'); + $button = pht('Panic!'); + break; + default: + return new Aphront404Response(); + } + + switch ($reason) { + case PhabricatorEnv::READONLY_UNREACHABLE: + case PhabricatorEnv::READONLY_SEVERED: + $body[] = pht( + 'This request was served from a replica database. Replica '. + 'databases may lag behind the master, so very recent activity '. + 'may not be reflected in the UI. This data will be restored if '. + 'the master database is restored, but may have been lost if the '. + 'master database has been reduced to a pile of ash.'); + break; + } + + $body[] = pht( + 'In read-only mode you can read existing information, but you will not '. + 'be able to edit objects or create new objects until this mode is '. + 'disabled.'); + + if ($viewer->getIsAdmin()) { + $body[] = pht( + 'As an administrator, you can review status information from the '. + '%s control panel. This may provide more information about the '. + 'current state of affairs.', + phutil_tag( + 'a', + array( + 'href' => '/config/cluster/databases/', + ), + pht('Cluster Database Status'))); + } + + $dialog = $this->newDialog() + ->setTitle($title) + ->setWidth(AphrontDialogView::WIDTH_FORM) + ->addCancelButton('/', $button); + + foreach ($body as $paragraph) { + $dialog->appendParagraph($paragraph); + } + + return $dialog; + } +} diff --git a/src/applications/uiexample/examples/PHUIBadgeExample.php b/src/applications/uiexample/examples/PHUIBadgeExample.php index 90c338f7e8..5207db753c 100644 --- a/src/applications/uiexample/examples/PHUIBadgeExample.php +++ b/src/applications/uiexample/examples/PHUIBadgeExample.php @@ -54,7 +54,7 @@ final class PHUIBadgeExample extends PhabricatorUIExample { ->setIcon('fa-user') ->setHeader(pht('Phabricator User')) ->setSubhead(pht('Confirmed your account.')) - ->setQuality(PHUIBadgeView::POOR) + ->setQuality(PhabricatorBadgesQuality::POOR) ->setSource(pht('People (automatic)')) ->addByline(pht('Dec 31, 1969')) ->addByline('212 Issued (100%)'); @@ -63,7 +63,7 @@ final class PHUIBadgeExample extends PhabricatorUIExample { ->setIcon('fa-code') ->setHeader(pht('Code Contributor')) ->setSubhead(pht('Wrote code that was acceptable')) - ->setQuality(PHUIBadgeView::COMMON) + ->setQuality(PhabricatorBadgesQuality::COMMON) ->setSource('Diffusion (automatic)') ->addByline(pht('Dec 31, 1969')) ->addByline('200 Awarded (98%)'); @@ -72,7 +72,7 @@ final class PHUIBadgeExample extends PhabricatorUIExample { ->setIcon('fa-bug') ->setHeader(pht('Task Master')) ->setSubhead(pht('Closed over 100 tasks')) - ->setQuality(PHUIBadgeView::UNCOMMON) + ->setQuality(PhabricatorBadgesQuality::UNCOMMON) ->setSource('Maniphest (automatic)') ->addByline(pht('Dec 31, 1969')) ->addByline('56 Awarded (43%)'); @@ -81,7 +81,7 @@ final class PHUIBadgeExample extends PhabricatorUIExample { ->setIcon('fa-star') ->setHeader(pht('Code Weaver')) ->setSubhead(pht('Landed 1,000 Commits')) - ->setQuality(PHUIBadgeView::RARE) + ->setQuality(PhabricatorBadgesQuality::RARE) ->setSource('Diffusion (automatic)') ->addByline(pht('Dec 31, 1969')) ->addByline('42 Awarded (20%)'); @@ -90,7 +90,7 @@ final class PHUIBadgeExample extends PhabricatorUIExample { ->setIcon('fa-users') ->setHeader(pht('Security Team')) ->setSubhead(pht('')) - ->setQuality(PHUIBadgeView::EPIC) + ->setQuality(PhabricatorBadgesQuality::EPIC) ->setSource('Projects (automatic)') ->addByline(pht('Dec 31, 1969')) ->addByline('21 Awarded (10%)'); @@ -99,7 +99,7 @@ final class PHUIBadgeExample extends PhabricatorUIExample { ->setIcon('fa-user') ->setHeader(pht('Adminstrator')) ->setSubhead(pht('Drew the short stick')) - ->setQuality(PHUIBadgeView::LEGENDARY) + ->setQuality(PhabricatorBadgesQuality::LEGENDARY) ->setSource(pht('People (automatic)')) ->addByline(pht('Dec 31, 1969')) ->addByline('3 Awarded (1.4%)'); @@ -108,7 +108,7 @@ final class PHUIBadgeExample extends PhabricatorUIExample { ->setIcon('fa-compass') ->setHeader(pht('Lead Developer')) ->setSubhead(pht('Lead Developer of Phabricator')) - ->setQuality(PHUIBadgeView::HEIRLOOM) + ->setQuality(PhabricatorBadgesQuality::HEIRLOOM) ->setSource(pht('Direct Award (epriestley)')) ->addByline(pht('Dec 31, 1969')) ->addByline('1 Awarded (0.4%)'); @@ -129,17 +129,17 @@ final class PHUIBadgeExample extends PhabricatorUIExample { $badges3[] = id(new PHUIBadgeMiniView()) ->setIcon('fa-heart') ->setHeader(pht('Funder')) - ->setQuality(PHUIBadgeView::UNCOMMON); + ->setQuality(PhabricatorBadgesQuality::UNCOMMON); $badges3[] = id(new PHUIBadgeMiniView()) ->setIcon('fa-user') ->setHeader(pht('Administrator')) - ->setQuality(PHUIBadgeView::RARE); + ->setQuality(PhabricatorBadgesQuality::RARE); $badges3[] = id(new PHUIBadgeMiniView()) ->setIcon('fa-camera-retro') ->setHeader(pht('Designer')) - ->setQuality(PHUIBadgeView::EPIC); + ->setQuality(PhabricatorBadgesQuality::EPIC); $flex1 = new PHUIBadgeBoxView(); $flex1->addItems($badges1); diff --git a/src/applications/uiexample/examples/PHUIBoxExample.php b/src/applications/uiexample/examples/PHUIBoxExample.php index 9634520e89..7a2674da94 100644 --- a/src/applications/uiexample/examples/PHUIBoxExample.php +++ b/src/applications/uiexample/examples/PHUIBoxExample.php @@ -75,7 +75,7 @@ final class PHUIBoxExample extends PhabricatorUIExample { $badge2 = id(new PHUIBadgeMiniView()) ->setIcon('fa-heart') ->setHeader(pht('Funder')) - ->setQuality(PHUIBadgeView::UNCOMMON); + ->setQuality(PhabricatorBadgesQuality::UNCOMMON); $header = id(new PHUIHeaderView()) ->setHeader(pht('Fancy Box')) diff --git a/src/applications/uiexample/examples/PHUITimelineExample.php b/src/applications/uiexample/examples/PHUITimelineExample.php index 661d2e6899..9bedffd9eb 100644 --- a/src/applications/uiexample/examples/PHUITimelineExample.php +++ b/src/applications/uiexample/examples/PHUITimelineExample.php @@ -1,5 +1,4 @@ setIcon('fa-camera-retro') ->setHeader(pht('Designer')) - ->setQuality(PHUIBadgeView::EPIC); + ->setQuality(PhabricatorBadgesQuality::EPIC); $admin = id(new PHUIBadgeMiniView()) ->setIcon('fa-user') ->setHeader(pht('Administrator')) - ->setQuality(PHUIBadgeView::RARE); + ->setQuality(PhabricatorBadgesQuality::RARE); $events = array(); diff --git a/src/docs/book/user.book b/src/docs/book/user.book index 20a72698be..fb2dccc578 100644 --- a/src/docs/book/user.book +++ b/src/docs/book/user.book @@ -32,6 +32,9 @@ "conduit": { "name": "API Documentation" }, + "cluster": { + "name": "Cluster Configuration" + }, "fieldmanual": { "name": "Field Manuals" }, diff --git a/src/docs/user/cluster/cluster.diviner b/src/docs/user/cluster/cluster.diviner new file mode 100644 index 0000000000..0180c7ff42 --- /dev/null +++ b/src/docs/user/cluster/cluster.diviner @@ -0,0 +1,289 @@ +@title Clustering Introduction +@group cluster + +Guide to configuring Phabricator across multiple hosts for availability and +performance. + +Overview +======== + +WARNING: This feature is a very early prototype; the features this document +describes are mostly speculative fantasy. + +Phabricator can be configured to run on multiple hosts with redundant services +to improve its availability and scalability, and make disaster recovery much +easier. + +Clustering is more complex to setup and maintain than running everything on a +single host, but greatly reduces the cost of recovering from hardware and +network failures. + +Each Phabricator service has an array of clustering options that can be +configured independently. Configuring a cluster is inherently complex, and this +is an advanced feature aimed at installs with large userbases and experienced +operations personnel who need this high degree of flexibility. + +The remainder of this document summarizes how to add redundancy to each +service and where your efforts are likely to have the greatest impact. + +For additional guidance on setting up a cluster, see "Overlaying Services" +and "Cluster Recipes" at the bottom of this document. + + +Preparing for Clustering +======================== + +To begin deploying Phabricator in cluster mode, set up `cluster.addresses` +in your configuration. + +This option should contain a list of network address blocks which are considered +to be part of the cluster. Hosts in this list are allowed to bend (or even +break) some of the security and policy rules when they make requests to other +hosts in the cluster, so this list should be as small as possible. See "Cluster +Whitelist Security" below for discussion. + +If you are deploying hardware in EC2, a reasonable approach is to launch a +dedicated Phabricator VPC, whitelist the whole VPC as a Phabricator cluster, +and then deploy only Phabricator services into that VPC. + +If you have additional auxiliary hosts which run builds and tests via Drydock, +you should //not// include them in the cluster address definition. For more +detailed discussion of the Drydock security model, see @{Drydock User Guide: +Security}. + +Most other clustering features will not work until you define a cluster by +configuring `cluster.addresses`. + + +Cluster Whitelist Security +======================== + +When you configure `cluster.addresses`, you should keep the list of trusted +cluster hosts as small as possible. Hosts on this list gain additional +capabilities, including these: + +**Trusted HTTP Headers**: Normally, Phabricator distrusts the load balancer +HTTP headers `X-Forwarded-For` and `X-Forwarded-Proto` because they may be +client-controlled and can be set to arbitrary values by an attacker if no load +balancer is deployed. In particular, clients can set `X-Forwarded-For` to any +value and spoof traffic from arbitrary remotes. + +These headers are trusted when they are received from a host on the cluster +address whitelist. This allows requests from cluster loadbalancers to be +interpreted correctly by default without requiring additional custom code or +configuration. + +**Intracluster HTTP**: Requests from cluster hosts are not required to use +HTTPS, even if `security.require-https` is enabled, because it is common to +terminate HTTPS on load balancers and use plain HTTP for requests within a +cluster. + +**Special Authentication Mechanisms**: Cluster hosts are allowed to connect to +other cluster hosts with "root credentials", and to impersonate any user +account. + +The use of root credentials is required because the daemons must be able to +bypass policies in order to function properly: they need to send mail about +private conversations and import commits in private repositories. + +The ability to impersonate users is required because SSH nodes must receive, +interpret, modify, and forward SSH traffic. They can not use the original +credentials to do this because SSH authentication is asymmetric and they do not +have the user's private key. Instead, they use root credentials and impersonate +the user within the cluster. + +These mechanisms are still authenticated (and use asymmetric keys, like SSH +does), so access to a host in the cluster address block does not mean that an +attacker can immediately compromise the cluster. However, an over-broad cluster +address whitelist may give an attacker who gains some access additional tools +to escalate access. + +Note that if an attacker gains access to an actual cluster host, these extra +powers are largely moot. Most cluster hosts must be able to connect to the +master database to function properly, so the attacker will just do that and +freely read or modify whatever data they want. + + +Cluster: Databases +================= + +Configuring multiple database hosts is moderately complex, but normally has the +highest impact on availability and resistance to data loss. This is usually the +most important service to make redundant if your focus is on availability and +disaster recovery. + +Configuring replicas allows Phabricator to run in read-only mode if you lose +the master and to quickly promote the replica as a replacement. + +For details, see @{article:Cluster: Databases}. + + +Cluster: Repositories +===================== + +Configuring multiple repository hosts is complex, but is required before you +can add multiple daemon or web hosts. + +Repository replicas are important for availability if you host repositories +on Phabricator, but less important if you host repositories elsewhere +(instead, you should focus on making that service more available). + +The distributed nature of Git and Mercurial tend to mean that they are +naturally somewhat resistant to data loss: every clone of a repository includes +the entire history. + +Repositories may become a scalability bottleneck, although this is rare unless +your install has an unusually heavy repository read volume. Slow clones/fetches +may hint at a repository capacity problem. Adding more repository hosts will +provide an approximately linear increase in capacity. + +For details, see @{article:Cluster: Repositories}. + + +Cluster: Daemons +================ + +Configuring multiple daemon hosts is straightforward, but you must configure +repositories first. + +With daemons running on multiple hosts, you can transparently survive the loss +of any subset of hosts without an interruption to daemon services, as long as +at least one host remains alive. Daemons are stateless, so spreading daemons +across multiple hosts provides no resistance to data loss. + +Daemons can become a bottleneck, particularly if your install sees a large +volume of write traffic to repositories. If the daemon task queue has a +backlog, that hints at a capacity problem. If existing hosts have unused +resources, increase `phd.taskmasters` until they are fully utilized. From +there, adding more daemon hosts will provide an approximately linear increase +in capacity. + +For details, see @{article:Cluster: Daemons}. + + +Cluster: Web Servers +==================== + +Configuring multiple web hosts is straightforward, but you must configure +repositories first. + +With multiple web hosts, you can transparently survive the loss of any subset +of hosts as long as at least one host remains alive. Web hosts are stateless, +so putting multiple hosts in service provides no resistance to data loss +because no data is at risk. + +Web hosts can become a bottleneck, particularly if you have a workload that is +heavily focused on reads from the web UI (like a public install with many +anonymous users). Slow responses to web requests may hint at a web capacity +problem. Adding more hosts will provide an approximately linear increase in +capacity. + +For details, see @{article:Cluster: Web Servers}. + + +Cluster: Notifications +====================== + +Configuring multiple notification hosts is simple and has no pre-requisites. + +With multiple notification hosts, you can survive the loss of any subset of +hosts as long as at least one host remains alive. Service may be breifly +disrupted directly after the incident which destroys the other hosts. + +Notifications are noncritical, so this normally has little practical impact +on service availability. Notifications are also stateless, so clustering this +service provides no resistance to data loss because no data is at risk. + +Notification delivery normally requires very few resources, so adding more +hosts is unlikely to have much impact on scalability. + +For details, see @{article:Cluster: Notifications}. + + +Overlaying Services +=================== + +Although hosts can run a single dedicated service type, certain groups of +services work well together. Phabricator clusters usually do not need to be +very large, so deploying a small number of hosts with multiple services is a +good place to start. + +In planning a cluster, consider these blended host types: + +**Everything**: Run HTTP, SSH, MySQL, notifications, repositories and daemons +on a single host. This is the starting point for single-node setups, and +usually also the best configuration when adding the second node. + +**Everything Except Databases**: Run HTTP, SSH, notifications, repositories and +daemons on one host, and MySQL on a different host. MySQL uses many of the same +resources that other services use. It's also simpler to separate than other +services, and tends to benefit the most from dedicated hardware. + +**Repositories and Daemons**: Run repositories and daemons on the same host. +Repository hosts //must// run daemons, and it normally makes sense to +completely overlay repositories and daemons. These services tend to use +different resources (repositories are heavier on I/O and lighter on CPU/RAM; +daemons are heavier on CPU/RAM and lighter on I/O). + +Repositories and daemons are also both less latency sensitive than other +service types, so there's a wider margin of error for under provisioning them +before performance is noticeably affected. + +These nodes tend to use system resources in a balanced way. Individual nodes +in this class do not need to be particularly powerful. + +**Frontend Servers**: Run HTTP and SSH on the same host. These are easy to set +up, stateless, and you can scale the pool up or down easily to meet demand. +Routing both types of ingress traffic through the same initial tier can +simplify load balancing. + +These nodes tend to need relatively little RAM. + + +Cluster Recipes +=============== + +This section provides some guidance on reasonable ways to scale up a cluster. + +The smallest possible cluster is **two hosts**. Run everything (web, ssh, +database, notifications, repositories, and daemons) on each host. One host will +serve as the master; the other will serve as a replica. + +Ideally, you should physically separate these hosts to reduce the chance that a +natural disaster or infrastructure disruption could disable or destroy both +hosts at the same time. + +From here, you can choose how you expand the cluster. + +To improve **scalability and performance**, separate loaded services onto +dedicated hosts and then add more hosts of that type to increase capacity. If +you have a two-node cluster, the best way to improve scalability by adding one +host is likely to separate the master database onto its own host. + +Note that increasing scale may //decrease// availability by leaving you with +too little capacity after a failure. If you have three hosts handling traffic +and one datacenter fails, too much traffic may be sent to the single remaining +host in the surviving datacenter. You can hedge against this by mirroring new +hosts in other datacenters (for example, also separate the replica database +onto its own host). + +After separating databases, separating repository + daemon nodes is likely +the next step to consider. + +To improve **availability**, add another copy of everything you run in one +datacenter to a new datacenter. For example, if you have a two-node cluster, +the best way to improve availability is to run everything on a third host in a +third datacenter. If you have a 6-node cluster with a web node, a database node +and a repo + daemon node in two datacenters, add 3 more nodes to create a copy +of each node in a third datacenter. + +You can continue adding hosts until you run out of hosts. + + +Next Steps +========== + +Continue by: + + - learning how Phacility configures and operates a large, multi-tenant + production cluster in ((cluster)). diff --git a/src/docs/user/cluster/cluster_daemons.diviner b/src/docs/user/cluster/cluster_daemons.diviner new file mode 100644 index 0000000000..f6aa1cbe74 --- /dev/null +++ b/src/docs/user/cluster/cluster_daemons.diviner @@ -0,0 +1,59 @@ +@title Cluster: Daemons +@group intro + +Configuring Phabricator to use multiple daemon hosts. + +Overview +======== + +WARNING: This feature is a very early prototype; the features this document +describes are mostly speculative fantasy. + +You can run daemons on multiple hosts. The advantages of doing this are: + + - you can completely survive the loss of multiple daemon hosts; and + - worker queue throughput may improve. + +This configuration is simple, but you must configure repositories first. For +details, see @{article:Cluster: Repositories}. + +Since repository hosts must run daemons anyway, you usually do not need to do +any additional work and can skip this entirely. + + +Adding Daemon Hosts +=================== + +After configuring repositories for clustering, launch daemons on every +repository host according to the documentation in +@{article:Cluster: Repositories}. These daemons are necessary: repositories +will not fetch, update, or synchronize properly without them. + +If your repository clustering is redundant (you have at least two repository +hosts), these daemons are also likely to be sufficient in most cases. If you +want to launch additional hosts anyway (for example, to increase queue capacity +for unusual workloads), see "Dedicated Daemon Hosts" below. + + +Dedicated Daemon Hosts +====================== + +You can launch additional daemon hosts without any special configuration. +Daemon hosts must be able to reach other hosts on the network, but do not need +to run any services (like HTTP or SSH). Simply deploy the Phabricator software +and configuration and start the daemons. + +Normally, there is little reason to deploy dedicated daemon hosts. They can +improve queue capacity, but generally do not improve availability or increase +resistance to data loss on their own. Instead, consider deploying more +repository hosts: repository hosts run daemons, so this will increase queue +capacity but also improve repository availability and cluster resistance. + + +Next Steps +========== + +Continue by: + + - returning to @{article:Clustering Introduction}; or + - configuring repositories first with @{article:Cluster: Repositories}. diff --git a/src/docs/user/cluster/cluster_databases.diviner b/src/docs/user/cluster/cluster_databases.diviner new file mode 100644 index 0000000000..5192138257 --- /dev/null +++ b/src/docs/user/cluster/cluster_databases.diviner @@ -0,0 +1,327 @@ +@title Cluster: Databases +@group intro + +Configuring Phabricator to use multiple database hosts. + +Overview +======== + +WARNING: This feature is a very early prototype; the features this document +describes are mostly speculative fantasy. + +You can deploy Phabricator with multiple database hosts, configured as a master +and a set of replicas. The advantages of doing this are: + + - faster recovery from disasters by promoting a replica; + - graceful degradation if the master fails; + - reduced load on the master; and + - some tools to help monitor and manage replica health. + +This configuration is complex, and many installs do not need to pursue it. + +Phabricator can not currently be configured into a multi-master mode, nor can +it be configured to automatically promote a replica to become the new master. + +If you lose the master, Phabricator can degrade automatically into read-only +mode and remain available, but can not fully recover without operational +intervention unless the master recovers on its own. + + +Setting up MySQL Replication +============================ + +TODO: Write this section. + + +Configuring Replicas +==================== + +Once your replicas are in working order, tell Phabricator about them by +configuring the `cluster.databases` option. This option must be configured from +the command line or in configuration files because Phabricator needs to read +it //before// it can connect to databases. + +This option value will list all of the database hosts that you want Phabricator +to interact with: your master and all your replicas. Each entry in the list +should have these keys: + + - `host`: //Required string.// The database host name. + - `role`: //Required string.// The cluster role of this host, one of + `master` or `replica`. + - `port`: //Optional int.// The port to connect to. If omitted, the default + port from `mysql.port` will be used. + - `user`: //Optional string.// The MySQL username to use to connect to this + host. If omitted, the default from `mysql.user` will be used. + - `pass`: //Optional string.// The password to use to connect to this host. + If omitted, the default from `mysql.pass` will be used. + - `disabled`: //Optional bool.// If set to `true`, Phabricator will not + connect to this host. You can use this to temporarily take a host out + of service. + +When `cluster.databases` is configured the `mysql.host` option is not used. +The other MySQL connection configuration options (`mysql.port`, `mysql.user`, +`mysql.pass`) are used only to provide defaults. + +Once you've configured this option, restart Phabricator for the changes to take +effect, then continue to "Monitoring Replicas" to verify the configuration. + + +Monitoring Replicas +=================== + +You can monitor replicas in {nav Config > Database Servers}. This interface +shows you a quick overview of replicas and their health, and can detect some +common issues with replication. + +The table on this page shows each database and current status. + +NOTE: This page runs its diagnostics //from the web server that is serving the +request//. If you are recovering from a disaster, the view this page shows +may be partial or misleading, and two requests served by different servers may +see different views of the cluster. + +**Connection**: Phabricator tries to connect to each configured database, then +shows the result in this column. If it fails, a brief diagnostic message with +details about the error is shown. If it succeeds, the column shows a rough +measurement of latency from the current webserver to the database. + +**Replication**: This is a summary of replication status on the database. If +things are properly configured and stable, the replicas should be actively +replicating and no more than a few seconds behind master, and the master +should //not// be replicating from another database. + +To report this status, the user Phabricator is connecting as must have the +`REPLICATION CLIENT` privilege (or the `SUPER` privilege) so it can run the +`SHOW SLAVE STATUS` command. The `REPLICATION CLIENT` privilege only enables +the user to run diagnostic commands so it should be reasonable to grant it in +most cases, but it is not required. If you choose not to grant it, this page +can not show any useful diagnostic information about replication status but +everything else will still work. + +If a replica is more than a second behind master, this page will show the +current replication delay. If the replication delay is more than 30 seconds, +it will report "Slow Replication" with a warning icon. + +If replication is delayed, data is at risk: if you lose the master and can not +later recover it (for example, because a meteor has obliterated the datacenter +housing the physical host), data which did not make it to the replica will be +lost forever. + +Beyond the risk of data loss, any read-only traffic sent to the replica will +see an older view of the world which could be confusing for users: it may +appear that their data has been lost, even if it is safe and just hasn't +replicated yet. + +Phabricator will attempt to prevent clients from seeing out-of-date views, but +sometimes sending traffic to a delayed replica is the best available option +(for example, if the master can not be reached). + +**Health**: This column shows the result of recent health checks against the +server. After several checks in a row fail, Phabricator will mark the server +as unhealthy and stop sending traffic to it until several checks in a row +later succeed. + +Note that each web server tracks database health independently, so if you have +several servers they may have different views of database health. This is +normal and not problematic. + +For more information on health checks, see "Unreachable Masters" below. + +**Messages**: This column has additional details about any errors shown in the +other columns. These messages can help you understand or resolve problems. + + +Testing Replicas +================ + +To test that your configuration can survive a disaster, turn off the master +database. Do this with great ceremony, making a cool explosion sound as you +run the `mysqld stop` command. + +If things have been set up properly, Phabricator should degrade to a temporary +read-only mode immediately. After a brief period of unresponsiveness, it will +degrade further into a longer-term read-only mode. For details on how this +works internally, see "Unreachable Masters" below. + +Once satisfied, turn the master back on. After a brief delay, Phabricator +should recognize that the master is healthy again and recover fully. + +Throughout this process, the {nav Database Servers} console will show a +current view of the world from the perspective of the web server handling the +request. You can use it to monitor state. + +You can perform a more narrow test by enabling `cluster.read-only` in +configuration. This will put Phabricator into read-only mode immediately +without turning off any databases. + +You can use this mode to understand which capabilities will and will not be +available in read-only mode, and make sure any information you want to remain +accessible in a disaster (like wiki pages or contact information) is really +accessible. + +See the next section, "Degradation to Read Only Mode", for more details about +when, why, and how Phabricator degrades. + +If you run custom code or extensions, they may not accommodate read-only mode +properly. You should specifically test that they function correctly in +read-only mode and do not prevent you from accessing important information. + + +Degradation to Read-Only Mode +============================= + +Phabricator will degrade to read-only mode when any of these conditions occur: + + - you turn it on explicitly; + - you configure cluster mode, but don't set up any masters; + - the master can not be reached while handling a request; or + - recent attempts to connect to the master have consistently failed. + +When Phabricator is running in read-only mode, users can still read data and +browse and clone repositories, but they can not edit, update, or push new +changes. For example, users can still read disaster recovery information on +the wiki or emergency contact information on user profiles. + +You can enable this mode explicitly by configuring `cluster.read-only`. Some +reasons you might want to do this include: + + - to test that the mode works like you expect it to; + - to make sure that information you need will be available; + - to prevent new writes while performing database maintenance; or + - to permanently archive a Phabricator install. + +You can also enable this mode implicitly by configuring `cluster.databases` +but disabling the master, or by not specifying any host as a master. This may +be more convenient than turning it on explicitly during the course of +operations work. + +If Phabricator is unable to reach the master database, it will degrade into +read-only mode automatically. See "Unreachable Masters" below for details on +how this process works. + +If you end up in a situation where you have lost the master and can not get it +back online (or can not restore it quickly) you can promote a replica to become +the new master. See the next section, "Promoting a Replica", for details. + + +Promoting a Replica +=================== + +TODO: Write this section. + + +Unreachable Masters +=================== + +This section describes how Phabricator determines that a master has been lost, +marks it unreachable, and degrades into read-only mode. + +Phabricator degrades into read-only mode automatically in two ways: very +briefly in response to a single connection failure, or more permanently in +response to a series of connection failures. + +In the first case, if a request needs to connect to the master but is not able +to, Phabricator will temporarily degrade into read-only mode for the remainder +of that request. The alternative is to fail abruptly, but Phabricator can +sometimes degrade successfully and still respond to the user's request, so it +makes an effort to finish serving the request from replicas. + +If the request was a write (like posting a comment) it will fail anyway, but +if it was a read that did not actually need to use the master it may succeed. + +This temporary mode is intended to recover as gracefully as possible from brief +interruptions in service (a few seconds), like a server being restarted, a +network link becoming temporarily unavailable, or brief periods of load-related +disruption. If the anomaly is temporary, Phabricator should recover immediately +(on the next request once service is restored). + +This mode can be slow for users (they need to wait on connection attempts to +the master which fail) and does not reduce load on the master (requests still +attempt to connect to it). + +The second way Phabricator degrades is by running periodic health checks +against databases, and marking them unhealthy if they fail over a longer period +of time. This mechanism is very similar to the health checks that most HTTP +load balancers perform against web servers. + +If a database fails several health checks in a row, Phabricator will mark it as +unhealthy and stop sending all traffic (except for more health checks) to it. +This improves performance during a service interruption and reduces load on the +master, which may help it recover from load problems. + +You can monitor the status of health checks in the {nav Database Servers} +console. The "Health" column shows how many checks have run recently and +how many have succeeded. + +Health checks run every 3 seconds, and 5 checks in a row must fail or succeed +before Phabricator marks the database as healthy or unhealthy, so it will +generally take about 15 seconds for a database to change state after it goes +down or comes up. + +If all of the recent checks fail, Phabricator will mark the database as +unhealthy and stop sending traffic to it. If the master was the database that +was marked as unhealthy, Phabricator will actively degrade into read-only mode +until it recovers. + +This mode only attempts to connect to the unhealthy database once every few +seconds to see if it is recovering, so performance will be better on average +(users rarely need to wait for bad connections to fail or time out) and the +database will receive less load. + +Once all of the recent checks succeed, Phabricator will mark the database as +healthy again and continue sending traffic to it. + +Health checks are tracked individually for each web server, so some web servers +may see a host as healthy while others see it as unhealthy. This is normal, and +can accurately reflect the state of the world: for example, the link between +datacenters may have been lost, so hosts in one datacenter can no longer see +the master, while hosts in the other datacenter still have a healthy link to +it. + + +Backups +====== + +Even if you configure replication, you should still retain separate backup +snapshots. Replicas protect you from data loss if you lose a host, but they do +not let you recover from data mutation mistakes. + +If something issues `DELETE` or `UPDATE` statements and destroys data on the +master, the mutation will propagate to the replicas almost immediately and the +data will be gone forever. Normally, the only way to recover this data is from +backup snapshots. + +Although you should still have a backup process, your backup process can +safely pull dumps from a replica instead of the master. This operation can +be slow, so offloading it to a replica can make the performance of the master +more consistent. + +To dump from a replica, you can use `bin/storage dump --host ` to +control which host the command connects to. (You may still want to execute +this command //from// that host, to avoid sending the whole dump over the +network). + +With the `--for-replica` flag, the `bin/storage dump` command creates dumps +with `--master-data`, which includes a `CHANGE MASTER` statement in the output. +This may be helpful when initially setting up new replicas, as it can make it +easier to change the binlog coordinates to the correct position for the dump. + +With recent versions of MySQL, it is also possible to configure a //delayed// +replica which intentionally lags behind the master (say, by 12 hours). In the +event of a bad mutation, this could give you a larger window of time to +recognize the issue and recover the lost data from the delayed replica (which +might be quick) without needing to restore backups (which might be very slow). + +Delayed replication is outside the scope of this document, but may be worth +considering as an additional data security step on top of backup snapshots +depending on your resources and needs. If you configure a delayed replica, do +not add it to the `cluster.databases` configuration: Phabricator should never +send traffic to it, and does not need to know about it. + + +Next Steps +========== + +Continue by: + + - returning to @{article:Clustering Introduction}. diff --git a/src/docs/user/cluster/cluster_notifications.diviner b/src/docs/user/cluster/cluster_notifications.diviner new file mode 100644 index 0000000000..f3837c869e --- /dev/null +++ b/src/docs/user/cluster/cluster_notifications.diviner @@ -0,0 +1,174 @@ +@title Cluster: Notifications +@group intro + +Configuring Phabricator to use multiple notification servers. + +Overview +======== + +WARNING: This feature is a very early prototype; the features this document +describes are mostly speculative fantasy. + +You can run multiple notification servers. The advantages of doing this +are: + + - you can completely survive the loss of any subset so long as one + remains standing; and + - performance and capacity may improve. + +This configuration is relatively simple, but has a small impact on availability +and does nothing to increase resitance to data loss. + + +Clustering Design Goals +======================= + +Notification clustering aims to restore service automatically after the loss +of some nodes. It does **not** attempt to guarantee that every message is +delivered. + +Notification messages provide timely information about events, but they are +never authoritative and never the only way for users to learn about events. +For example, if a notification about a task update is not delivered, the next +page you load will still show the notification in your notification menu. + +Generally, Phabricator works fine without notifications configured at all, so +clustering assumes that losing some messages during a disruption is acceptable. + + +How Clustering Works +==================== + +Notification clustering is very simple: notification servers relay every +message they receive to a list of peers. + +When you configure clustering, you'll run multiple servers and tell them that +the other servers exist. When any server receives a message, it retransmits it +to all the severs it knows about. + +When a server is lost, clients will automatically reconnect after a brief +delay. They may lose some notifications while their client is reconnecting, +but normally this should only last for a few seconds. + + +Configuring Aphlict +=================== + +To configure clustering on the server side, add a `cluster` key to your +Aphlict configuration file. For more details about configuring Aphlict, +see @{article:Notifications User Guide: Setup and Configuration}. + +The `cluster` key should contain a list of `"admin"` server locations. Every +message the server receives will be retransmitted to all nodes in the list. + +The server is smart enough to avoid sending messages in a cycle, and to avoid +sending messages to itself. You can safely list every server you run in the +configuration file, including the current server. + +You do not need to configure servers in an acyclic graph or only list //other// +servers: just list everything on every server and Aphlict will figure things +out from there. + +A simple example with two servers might look like this: + +```lang=json, name="aphlict.json (Cluster)" +{ + ... + "cluster": [ + { + "host": "notify001.mycompany.com", + "port": 22281, + "protocol": "http" + }, + { + "host": "notify002.mycompany.com", + "port": 22281, + "protocol": "http" + } + ] + ... +} +``` + + +Configuring Phabricator +======================= + +To configure clustering on the client side, add every service you run to +`notification.servers`. Generally, this will be twice as many entries as +you run actual servers, since each server runs a `"client"` service and an +`"admin"` service. + +A simple example with the two servers above (providing four total services) +might look like this: + +```lang=json, name="notification.servers (Cluster)" +[ + { + "type": "client", + "host": "notify001.mycompany.com", + "port": 22280, + "protocol": "https" + }, + { + "type": "client", + "host": "notify002.mycompany.com", + "port": 22280, + "protocol": "https" + }, + { + "type": "admin", + "host": "notify001.mycompany.com", + "port": 22281, + "protocol": "http" + }, + { + "type": "admin", + "host": "notify002.mycompany.com", + "port": 22281, + "protocol": "http" + } +] +``` + +If you put all of the `"client"` servers behind a load balancer, you would +just list the load balancer and let it handle pulling nodes in and out of +service. + +```lang=json, name="notification.servers (Cluster + Load Balancer)" +[ + { + "type": "client", + "host": "notify-lb.mycompany.com", + "port": 22280, + "protocol": "https" + }, + { + "type": "admin", + "host": "notify001.mycompany.com", + "port": 22281, + "protocol": "http" + }, + { + "type": "admin", + "host": "notify002.mycompany.com", + "port": 22281, + "protocol": "http" + } +] +``` + +Notification hosts do not need to run any additional services, although they +are free to do so. The notification server generally consumes few resources +and is resistant to most other loads on the machine, so it's reasonable to +overlay these on top of other services wherever it is convenient. + + +Next Steps +========== + +Continue by: + + - reviewing notification configuration with + @{article:Notifications User Guide: Setup and Configuration}; or + - returning to @{article:Clustering Introduction}. diff --git a/src/docs/user/cluster/cluster_repositories.diviner b/src/docs/user/cluster/cluster_repositories.diviner new file mode 100644 index 0000000000..c5179666a7 --- /dev/null +++ b/src/docs/user/cluster/cluster_repositories.diviner @@ -0,0 +1,112 @@ +@title Cluster: Repositories +@group intro + +Configuring Phabricator to use multiple repository hosts. + +Overview +======== + +WARNING: This feature is a very early prototype; the features this document +describes are mostly speculative fantasy. + +If you use Git or Mercurial, you can deploy Phabricator with multiple +repository hosts, configured so that each host is readable and writable. The +advantages of doing this are: + + - you can completely survive the loss of repository hosts; + - reads and writes can scale across multiple machines; and + - read and write performance across multiple geographic regions may improve. + +This configuration is complex, and many installs do not need to pursue it. + +This configuration is not currently supported with Subversion. + + +Repository Hosts +================ + +Repository hosts must run a complete, fully configured copy of Phabricator, +including a webserver. If you make repositories available over SSH, they must +also run a properly configured `sshd`. + +Generally, these hosts will run the same set of services and configuration that +web hosts run. If you prefer, you can overlay these services and put web and +repository services on the same hosts. + +When a user requests information about a repository that can only be satisfied +by examining a repository working copy, the webserver receiving the request +will make an HTTP service call to a repository server which hosts the +repository to retrieve the data it needs. It will use the result of this query +to respond to the user. + + +How Reads and Writes Work +========================= + +Phabricator repository replicas are multi-master: every node is readable and +writable, and a cluster of nodes can (almost always) survive the loss of any +arbitrary subset of nodes so long as at least one node is still alive. + +Phabricator maintains an internal version for each repository, and increments +it when the repository is mutated. + +Before responding to a read, replicas make sure their version of the repository +is up to date (no node in the cluster has a newer version of the repository). +If it isn't, they block the read until they can complete a fetch. + +Before responding to a write, replicas obtain a global lock, perform the same +version check and fetch if necessary, then allow the write to continue. + + +HTTP vs HTTPS +============= + +Intracluster requests (from the daemons to repository servers, or from +webservers to repository servers) are permitted to use HTTP, even if you have +set `security.require-https` in your configuration. + +It is common to terminate SSL at a load balancer and use plain HTTP beyond +that, and the `security.require-https` feature is primarily focused on making +client browser behavior more convenient for users, so it does not apply to +intracluster traffic. + +Using HTTP within the cluster leaves you vulnerable to attackers who can +observe traffic within a datacenter, or observe traffic between datacenters. +This is normally very difficult, but within reach for state-level adversaries +like the NSA. + +If you are concerned about these attackers, you can terminate HTTPS on +repository hosts and bind to them with the "https" protocol. Just be aware that +the `security.require-https` setting won't prevent you from making +configuration mistakes, as it doesn't cover intracluster traffic. + +Other mitigations are possible, but securing a network against the NSA and +similar agents of other rogue nations is beyond the scope of this document. + + +Backups +====== + +Even if you configure clustering, you should still consider retaining separate +backup snapshots. Replicas protect you from data loss if you lose a host, but +they do not let you rewind time to recover from data mutation mistakes. + +If something issues a `--force` push that destroys branch heads, the mutation +will propagate to the replicas. + +You may be able to manually restore the branches by using tools like the +Phabricator push log or the Git reflog so it is less important to retain +repository snapshots than database snapshots, but it is still possible for +data to be lost permanently, especially if you don't notice the problem for +some time. + +Retaining separate backup snapshots will improve your ability to recover more +data more easily in a wider range of disaster situations. + + +Next Steps +========== + +Continue by: + + - returning to @{article:Clustering Introduction}. diff --git a/src/docs/user/cluster/cluster_webservers.diviner b/src/docs/user/cluster/cluster_webservers.diviner new file mode 100644 index 0000000000..a1ebc9491b --- /dev/null +++ b/src/docs/user/cluster/cluster_webservers.diviner @@ -0,0 +1,42 @@ +@title Cluster: Web Servers +@group intro + +Configuring Phabricator to use multiple web servers. + +Overview +======== + +WARNING: This feature is a very early prototype; the features this document +describes are mostly speculative fantasy. + +You can run Phabricator on multiple web servers. The advantages of doing this +are: + + - you can completely survive the loss of multiple web hosts; and + - performance and capacity may improve. + +This configuration is simple, but you must configure repositories first. For +details, see @{article:Cluster: Repositories}. + + +Adding Web Hosts +================ + +After configuring repositories in cluster mode, you can add more web hosts +at any time: simply deploy the Phabricator software and configuration to a +host, start the web server, and then add the host to the load balancer pool. + +Phabricator web servers are stateless, so you can pull them in and out of +production freely. + +You may also want to run SSH services on these hosts, since the service is very +similar to HTTP, also stateless, and it may be simpler to load balance the +services together. + + +Next Steps +========== + +Continue by: + + - returning to @{article:Clustering Introduction}. diff --git a/src/docs/user/configuration/cluster.diviner b/src/docs/user/configuration/cluster.diviner deleted file mode 100644 index c11565dc4d..0000000000 --- a/src/docs/user/configuration/cluster.diviner +++ /dev/null @@ -1,50 +0,0 @@ -@title User Guide: Phabricator Clusters -@group config - -Guide on scaling Phabricator across multiple machines. - -Overview -======== - -IMPORTANT: Phabricator clustering is in its infancy and does not work at all -yet. This document is mostly a placeholder. - -IMPORTANT: DO NOT CONFIGURE CLUSTER SERVICES UNLESS YOU HAVE **TWENTY YEARS OF -EXPERIENCE WITH PHABRICATOR** AND **A MINIMUM OF 17 PHABRICATOR PHDs**. YOU -WILL BREAK YOUR INSTALL AND BE UNABLE TO REPAIR IT. - -See also @{article:Almanac User Guide}. - - -Managing Cluster Configuration -============================== - -Cluster configuration is managed primarily from the **Almanac** application. - -To define cluster services and create or edit cluster configuration, you must -have the **Can Manage Cluster Services** application permission in Almanac. If -you do not have this permission, all cluster services and all connected devices -will be locked and not editable. - -The **Can Manage Cluster Services** permission is stronger than service and -device policies, and overrides them. You can never edit a cluster service if -you don't have this permission, even if the **Can Edit** policy on the service -itself is very permissive. - - -Locking Cluster Configuration -============================= - -IMPORTANT: Managing cluster services is **dangerous** and **fragile**. - -If you make a mistake, you can break your install. Because the install is -broken, you will be unable to load the web interface in order to repair it. - -IMPORTANT: Currently, broken clusters must be repaired by manually fixing them -in the database. There are no instructions available on how to do this, and no -tools to help you. Do not configure cluster services. - -If an attacker gains access to an account with permission to manage cluster -services, they can add devices they control as database servers. These servers -will then receive sensitive data and traffic, and allow the attacker to -escalate their access and completely compromise an install. diff --git a/src/docs/user/configuration/managing_daemons.diviner b/src/docs/user/configuration/managing_daemons.diviner index 4382e12c8a..0a732d5836 100644 --- a/src/docs/user/configuration/managing_daemons.diviner +++ b/src/docs/user/configuration/managing_daemons.diviner @@ -113,25 +113,16 @@ This daemon will daemonize and run normally. - See @{article:Diffusion User Guide} for details about tuning the repository daemon. -== Multiple Machines == -If you have multiple machines, you should use `phd launch` to tweak which -daemons launch, and split daemons across machines like this: +Multiple Hosts +============== - - `PhabricatorRepositoryPullLocalDaemon`: Run one copy on any machine. - On each web frontend which is not running a normal copy, run a copy - with the `--no-discovery` flag. - - `PhabricatorTriggerDaemon`: Run one copy on any machine. - - `PhabricatorTaskmasterDaemon`: Run as many copies as you need to keep - tasks from backing up. You can run them all on one machine or split them - across machines. +For information about running daemons on multiple hosts, see +@{article:Cluster: Daemons}. -A gratuitously wasteful install might have a dedicated daemon machine which -runs `phd start` with a large pool of taskmasters set in the config, and then -runs `phd launch PhabricatorRepositoryPullLocalDaemon -- --no-discovery` on each -web server. This is grossly excessive in normal cases. -= Next Steps = +Next Steps +========== Continue by: diff --git a/src/docs/user/configuration/notifications.diviner b/src/docs/user/configuration/notifications.diviner index dda3c1e0af..c5cd1f7974 100644 --- a/src/docs/user/configuration/notifications.diviner +++ b/src/docs/user/configuration/notifications.diviner @@ -11,13 +11,13 @@ tasks or commenting on code reviews) through email and in-application notifications. Phabricator can also be configured to deliver notifications in real time, by -popping up a message in any open browser windows if something has -happened or an object has been updated. +popping up a message in any open browser windows if something has happened or +an object has been updated. To enable real-time notifications: - - Set `notification.enabled` in your configuration to true. - - Run the notification server, as described below. + - Configure and start the notification server, as described below. + - Adjust `notification.servers` to point at it. This document describes the process in detail. @@ -59,40 +59,104 @@ After installing Node.js, you can control the notification server with the phabricator/ $ bin/aphlict start -The server must be able to listen on port **22280** for Aphlict to work. In -particular, if you're running in EC2, you need to unblock this port in the -server's security group configuration. You can change this port in the -`notification.client-uri` config. +By default, the server must be able to listen on port `22280`. If you're using +a host firewall (like a security group in EC2), make sure traffic can reach the +server. -You may need to adjust these settings: +The server configuration is controlled by a configuration file, which is +separate from Phabricator's configuration settings. The default file can +be found at `phabricator/conf/aphlict/aphlict.default.json`. - - `notification.ssl-cert` Point this at an SSL certificate for secure - WebSockets. - - `notification.ssl-key` Point this at an SSL keyfile for secure WebSockets. +To make adjustments to the default configuration, either copy this file to +create `aphlict.custom.json` in the same directory (this file will be used if +it exists) or specify a configuration file explicitly with the `--config` flag: -In particular, if your server uses HTTPS, you **must** configure these options. -Browsers will not allow you to use non-SSL websockets from an SSL web page. + phabricator/ $ bin/aphlict start --config path/to/config.json -You may also want to adjust these settings: +The configuration file has these settings: - - `notification.client-uri` Externally-facing host and port that browsers will - connect to in order to listen for notifications. - - `notification.server-uri` Internally-facing host and port that Phabricator - will connect to in order to publish notifications. - - `notification.log` Log file location for the server. - - `notification.pidfile` Pidfile location used to stop any running server when - aphlict is restarted. + - `servers`: //Required list.// A list of servers to start. + - `logs`: //Optional list.// A list of logs to write to. + - `cluster`: //Optional list.// A list of cluster peers. This is an advanced + feature. + - `pidfile`: //Required string.// Path to a PID file. + - `memory.hint`: //Optional int.// Suggestion to `node` about how much + memory to use, via `--max-old-stack-size`. In most cases, this can be + left unspecified. + +Each server in the `servers` list should be an object with these keys: + + - `type`: //Required string.// The type of server to start. Options are + `admin` or `client`. Normally, you should run one of each. + - `port`: //Required int.// The port this server should listen on. + - `listen`: //Optional string.// Which interface to bind to. By default, + the `admin` server is bound to `127.0.0.1` (so only other services on the + local machine can connect to it), while the `client` server is bound + to `0.0.0.0` (so any client can connect). + - `ssl.key`: //Optional string.// If you want to use SSL on this port, + the path to an SSL key. + - `ssl.cert`: //Optional string.// If you want to use SSL on this port, + the path to an SSL certificate. + - `ssl.chain`: //Optional string.// If you have configured SSL on this + port, an optional path to a certificate chain file. + +Each log in the `logs` list should be an object with these keys: + + - `path`: //Required string.// Path to the log file. + +Each peer in the `cluster` list should be an object with these keys: + + - `host`: //Required string.// The peer host address. + - `port`: //Required int.// The peer port. + - `protocol`: //Required string.// The protocol to connect with, one of + `"http"` or `"https"`. + +Cluster configuration is an advanced topic and can be omitted for most +installs. For more information on how to configure a cluster, see +@{article:Clustering Introduction} and @{article:Cluster: Notifications}. + +The defaults are appropriate for simple cases, but you may need to adjust them +if you are running a more complex configuration. + +Configuring Phabricator +======================= + +After starting the server, configure Phabricator to connect to it by adjusting +`notification.servers`. This configuration option should have a list of servers +that Phabricator should interact with. + +Normally, you'll list one client server and one admin server, like this: + +```lang=json +[ + { + "type": "client", + "host": "phabricator.mycompany.com", + "port": 22280, + "protocol": "https" + }, + { + "type": "admin", + "host": "127.0.0.1", + "port": 22281, + "protocol": "http" + } +] +``` + +This definition defines which services the user's browser will attempt to +connect to. Most of the time, it will be very similar to the services defined +in the Aphlict configuration. However, if you are sending traffic through a +load balancer or terminating SSL somewhere before traffic reaches Aphlict, +the services the browser connects to may need to have different hosts, ports +or protocols than the underlying server listens on. Verifying Server Status ======================= -Access `/notification/status/` to verify the server is operational. You should -see a table showing stats like "uptime" and connection/message counts if the -server is working. If it isn't working, you should see an error. - -You can also send a test notification by clicking the button in the upper right -corner of this screen. +After configuring `notification.servers`, navigate to +{nav Config > Notification Servers} to verify that things are operational. Troubleshooting @@ -106,31 +170,61 @@ Because the notification server uses WebSockets, your browser error console may also have information that is useful in figuring out what's wrong. The server also generates a log, by default in `/var/log/aphlict.log`. You can -change this location by changing `notification.log` in your configuration. The -log may contain information useful in resolving issues. +change this location by adjusting configuration. The log may contain +information that is useful in resolving issues. -Advanced Usage -============== +SSL and HTTPS +============= -It is possible to route the WebSockets traffic for Aphlict through a reverse -proxy such as `nginx` (see @{article:Configuration Guide} for instructions on -configuring `nginx`). In order to do this with `nginx`, you will require at -least version 1.3. You can read some more information about using `nginx` with -WebSockets at http://nginx.com/blog/websocket-nginx/. +If you serve Phabricator over HTTPS, you must also serve websockets over HTTPS. +Browsers will refuse to connect to `ws://` websockets from HTTPS pages. -There are a few benefits of this approach: +If a client connects to Phabricator over HTTPS, Phabricator will automatically +select an appropriate HTTPS service from `notification.servers` and instruct +the browser to open a websocket connection with `wss://`. - - SSL is terminated at the `nginx` layer and consequently there is no need to - configure `notificaton.ssl-cert` and `notification.ssl-key` (in fact, with - this approach you should //not// configure these options because otherwise - the Aphlict server will not accept HTTP traffic). - - You don't have to open up a separate port on the server. - - Clients don't need to be able to connect to Aphlict over a non-standard - port which may be blocked by a firewall or anti-virus software. +The simplest way to do this is configure Aphlict with an SSL key and +certificate and let it terminate SSL directly. -The following files show an example `nginx` configuration. Note that this is an -example only and you may need to adjust this to suit your own setup. +If you prefer not to do this, two other options are: + + - run the websocket through a websocket-capable loadbalancer and terminate + SSL there; or + - run the websocket through `nginx` over the same socket as the rest of + your web traffic. + +See the next sections for more detail. + + +Terminating SSL with a Load Balancer +==================================== + +If you want to terminate SSL in front of the notification server with a +traditional load balancer or a similar device, do this: + + - Point `notification.servers` at your load balancer or reverse proxy, + specifying that the protocol is `https`. + - On the load balancer or proxy, terminate SSL and forward traffic to the + Aphlict server. + - In the Aphlict configuration, listen on the target port with `http`. + + +Terminating SSL with Nginx +========================== + +If you use `nginx`, you can send websocket traffic to the same port as normal +HTTP traffic and have `nginx` proxy it selectively based on the request path. + +This requires `nginx` 1.3 or greater. See the `nginx` documentation for +details: + +> http://nginx.com/blog/websocket-nginx/ + +This is very complex, but allows you to support notifications without opening +additional ports. + +An example `nginx` configuration might look something like this: ```lang=nginx, name=/etc/nginx/conf.d/connection_upgrade.conf map $http_upgrade $connection_upgrade { @@ -163,8 +257,23 @@ server { } ``` -With this approach, you should set `notification.client-uri` to -`http://localhost/ws/`. Additionally, there is no need for the Aphlict server -to bind to `0.0.0.0` anymore (which is the default behavior), so you could -start the Aphlict server with `./bin/aphlict start --client-host=localhost` -instead. +With this approach, you should make these additional adjustments: + +**Phabricator Configuration**: The entry in `notification.servers` with type +`"client"` should have these adjustments made: + + - Set `host` to the Phabricator host. + - Set `port` to the standard HTTPS port (usually `443`). + - Set `protocol` to `"https"`. + - Set `path` to `/ws/`, so it matches the special `location` in your + `nginx` config. + +You do not need to adjust the `"admin"` server. + +**Aphlict**: Your Aphlict configuration should make these adjustments to +the `"client"` server: + + - The `protocol` should be `"http"`: `nginx` will send plain HTTP traffic + to Aphlict. + - Optionally, you can `listen` on `127.0.0.1` instead of `0.0.0.0`, because + the server will no longer receive external traffic. diff --git a/src/docs/user/userguide/arcanist_commit_ranges.diviner b/src/docs/user/userguide/arcanist_commit_ranges.diviner index d456c679ce..46afcba977 100644 --- a/src/docs/user/userguide/arcanist_commit_ranges.diviner +++ b/src/docs/user/userguide/arcanist_commit_ranges.diviner @@ -193,12 +193,14 @@ or when it reaches the merge-base commit. This rule works well for trees that look like this: +``` | * Commit B1, on branch "subfeature" (HEAD) | / | * Commit A1, on branch "feature" |/ * Commit M1, on branch "master" | +``` This tree represents using feature branches to develop one feature ("feature"), and then creating a sub-branch to develop a dependent feature ("subfeature"). @@ -218,6 +220,7 @@ The rule will also do the right thing when run from "feature" in this case. However, this rule will select the wrong commit range in some cases. For instance, it will do the wrong thing in this tree: +``` | | * Commit A2, on branch "feature" (HEAD) | | @@ -227,6 +230,7 @@ instance, it will do the wrong thing in this tree: |/ * Commit M1, on branch "master" | +``` This tree represents making another commit (`A2`) on "feature", on top of `A1`. @@ -240,6 +244,7 @@ commits, or by rebasing "subfeature" before running `arc diff`. This rule will also select the wrong commit range in a tree like this: +``` | | * Commit A1', on branch "feature", created by amending A1 | | @@ -249,6 +254,7 @@ This rule will also select the wrong commit range in a tree like this: |/ * Commit M1, on branch "master" | +``` This tree represents amending `A1` without rebasing "subfeature", so that `A1` is no longer on "feature" (replaced with `A1'`) but still on "subfeature". In @@ -269,6 +275,7 @@ This rule operates like `arc:outgoing`, but then walks the commits between `.` and the selected base commit. It stops when it encounters a bookmark. For example, if you have a tree like this: +``` | | * C4 (outgoing, bookmark: stripes) | | @@ -278,6 +285,7 @@ example, if you have a tree like this: |/ * C1 (pushed, no bookmark) | +``` When run from `C4`, this rule will select just `C4`, stopping on `C3` because it has a different bookmark. When run from `C3`, it will select `C2` and `C3`. diff --git a/src/infrastructure/cluster/PhabricatorClusterDatabasesConfigOptionType.php b/src/infrastructure/cluster/PhabricatorClusterDatabasesConfigOptionType.php new file mode 100644 index 0000000000..85df807f3e --- /dev/null +++ b/src/infrastructure/cluster/PhabricatorClusterDatabasesConfigOptionType.php @@ -0,0 +1,98 @@ + $spec) { + if (!is_array($spec)) { + throw new Exception( + pht( + 'Database cluster configuration is not valid: each entry in the '. + 'list must be a dictionary describing a database host, but '. + 'the value with index "%s" is not a dictionary.', + $index)); + } + } + + $masters = array(); + $map = array(); + foreach ($value as $index => $spec) { + try { + PhutilTypeSpec::checkMap( + $spec, + array( + 'host' => 'string', + 'role' => 'string', + 'port' => 'optional int', + 'user' => 'optional string', + 'pass' => 'optional string', + 'disabled' => 'optional bool', + )); + } catch (Exception $ex) { + throw new Exception( + pht( + 'Database cluster configuration has an invalid host '. + 'specification (at index "%s"): %s.', + $index, + $ex->getMessage())); + } + + $role = $spec['role']; + $host = $spec['host']; + $port = idx($spec, 'port'); + + switch ($role) { + case 'master': + case 'replica': + break; + default: + throw new Exception( + pht( + 'Database cluster configuration describes an invalid '. + 'host ("%s", at index "%s") with an unrecognized role ("%s"). '. + 'Valid roles are "%s" or "%s".', + $spec['host'], + $index, + $spec['role'], + 'master', + 'replica')); + } + + if ($role === 'master') { + $masters[] = $host; + } + + // We can't guarantee that you didn't just give the same host two + // different names in DNS, but this check can catch silly copy/paste + // mistakes. + $key = "{$host}:{$port}"; + if (isset($map[$key])) { + throw new Exception( + pht( + 'Database cluster configuration is invalid: it describes the '. + 'same host ("%s") multiple times. Each host should appear only '. + 'once in the list.', + $host)); + } + $map[$key] = true; + } + + if (count($masters) > 1) { + throw new Exception( + pht( + 'Database cluster configuration is invalid: it describes multiple '. + 'masters. No more than one host may be a master. Hosts currently '. + 'configured as masters: %s.', + implode(', ', $masters))); + } + } + +} diff --git a/src/infrastructure/cluster/PhabricatorClusterException.php b/src/infrastructure/cluster/PhabricatorClusterException.php new file mode 100644 index 0000000000..bd93de3a93 --- /dev/null +++ b/src/infrastructure/cluster/PhabricatorClusterException.php @@ -0,0 +1,8 @@ +getViewer($request); + + $title = $ex->getExceptionTitle(); + + $dialog = id(new AphrontDialogView()) + ->setTitle($title) + ->setUser($viewer) + ->appendParagraph($ex->getMessage()) + ->addCancelButton('/', pht('Proceed With Caution')); + + return id(new AphrontDialogResponse()) + ->setDialog($dialog) + ->setHTTPResponseCode(500); + } + +} diff --git a/src/infrastructure/cluster/PhabricatorClusterImpossibleWriteException.php b/src/infrastructure/cluster/PhabricatorClusterImpossibleWriteException.php new file mode 100644 index 0000000000..20b0ab3062 --- /dev/null +++ b/src/infrastructure/cluster/PhabricatorClusterImpossibleWriteException.php @@ -0,0 +1,10 @@ +ref = $ref; + $this->readState(); + } + + + /** + * Is the database currently healthy? + */ + public function getIsHealthy() { + return $this->isHealthy; + } + + + /** + * Should this request check database health? + */ + public function getShouldCheck() { + return $this->shouldCheck; + } + + + /** + * How many recent health checks were successful? + */ + public function getUpEventCount() { + return $this->upEventCount; + } + + + /** + * How many recent health checks failed? + */ + public function getDownEventCount() { + return $this->downEventCount; + } + + + /** + * Number of failures or successes we need to see in a row before we change + * the state. + */ + public function getRequiredEventCount() { + // NOTE: If you change this value, update the "Cluster: Databases" docs. + return 5; + } + + + /** + * Seconds to wait between health checks. + */ + public function getHealthCheckFrequency() { + // NOTE: If you change this value, update the "Cluster: Databases" docs. + return 3; + } + + + public function didHealthCheck($result) { + $now = microtime(true); + $check_frequency = $this->getHealthCheckFrequency(); + $event_count = $this->getRequiredEventCount(); + + $record = $this->readHealthRecord(); + + $log = $record['log']; + foreach ($log as $key => $event) { + $when = idx($event, 'timestamp'); + + // If the log already has another nearby event, just ignore this one. + // We raced with another process and our result can just be thrown away. + if (($now - $when) <= $check_frequency) { + return $this; + } + } + + $log[] = array( + 'timestamp' => $now, + 'up' => $result, + ); + + // Throw away older events which are now obsolete. + $log = array_slice($log, -$event_count); + + $count_up = 0; + $count_down = 0; + foreach ($log as $event) { + if ($event['up']) { + $count_up++; + } else { + $count_down++; + } + } + + // If all of the events are the same, change the state. + if ($count_up == $event_count) { + $record['up'] = true; + } else if ($count_down == $event_count) { + $record['up'] = false; + } + + $record['log'] = $log; + + $this->writeHealthRecord($record); + + $this->isHealthy = $record['up']; + $this->shouldCheck = false; + $this->updateStatistics($record); + + return $this; + } + + + private function readState() { + $now = microtime(true); + $check_frequency = $this->getHealthCheckFrequency(); + + $record = $this->readHealthRecord(); + + $last_check = $record['lastCheck']; + + if (($now - $last_check) >= $check_frequency) { + $record['lastCheck'] = $now; + $this->writeHealthRecord($record); + $this->shouldCheck = true; + } else { + $this->shouldCheck = false; + } + + $this->isHealthy = $record['up']; + $this->updateStatistics($record); + } + + private function updateStatistics(array $record) { + $this->upEventCount = 0; + $this->downEventCount = 0; + foreach ($record['log'] as $event) { + if ($event['up']) { + $this->upEventCount++; + } else { + $this->downEventCount++; + } + } + } + + private function getHealthRecordCacheKey() { + $ref = $this->ref; + + $host = $ref->getHost(); + $port = $ref->getPort(); + + return "cluster.db.health({$host}, {$port})"; + } + + private function readHealthRecord() { + $cache = PhabricatorCaches::getSetupCache(); + $cache_key = $this->getHealthRecordCacheKey(); + $health_record = $cache->getKey($cache_key); + + if (!is_array($health_record)) { + $health_record = array( + 'up' => true, + 'lastCheck' => 0, + 'log' => array(), + ); + } + + return $health_record; + } + + private function writeHealthRecord(array $record) { + $cache = PhabricatorCaches::getSetupCache(); + $cache_key = $this->getHealthRecordCacheKey(); + $cache->setKey($cache_key, $record); + } + +} diff --git a/src/infrastructure/cluster/PhabricatorDatabaseRef.php b/src/infrastructure/cluster/PhabricatorDatabaseRef.php new file mode 100644 index 0000000000..f8ca7a79a8 --- /dev/null +++ b/src/infrastructure/cluster/PhabricatorDatabaseRef.php @@ -0,0 +1,521 @@ +host = $host; + return $this; + } + + public function getHost() { + return $this->host; + } + + public function setPort($port) { + $this->port = $port; + return $this; + } + + public function getPort() { + return $this->port; + } + + public function setUser($user) { + $this->user = $user; + return $this; + } + + public function getUser() { + return $this->user; + } + + public function setPass(PhutilOpaqueEnvelope $pass) { + $this->pass = $pass; + return $this; + } + + public function getPass() { + return $this->pass; + } + + public function setIsMaster($is_master) { + $this->isMaster = $is_master; + return $this; + } + + public function getIsMaster() { + return $this->isMaster; + } + + public function setDisabled($disabled) { + $this->disabled = $disabled; + return $this; + } + + public function getDisabled() { + return $this->disabled; + } + + public function setConnectionLatency($connection_latency) { + $this->connectionLatency = $connection_latency; + return $this; + } + + public function getConnectionLatency() { + return $this->connectionLatency; + } + + public function setConnectionStatus($connection_status) { + $this->connectionStatus = $connection_status; + return $this; + } + + public function getConnectionStatus() { + if ($this->connectionStatus === null) { + throw new PhutilInvalidStateException('queryAll'); + } + + return $this->connectionStatus; + } + + public function setConnectionMessage($connection_message) { + $this->connectionMessage = $connection_message; + return $this; + } + + public function getConnectionMessage() { + return $this->connectionMessage; + } + + public function setReplicaStatus($replica_status) { + $this->replicaStatus = $replica_status; + return $this; + } + + public function getReplicaStatus() { + return $this->replicaStatus; + } + + public function setReplicaMessage($replica_message) { + $this->replicaMessage = $replica_message; + return $this; + } + + public function getReplicaMessage() { + return $this->replicaMessage; + } + + public function setReplicaDelay($replica_delay) { + $this->replicaDelay = $replica_delay; + return $this; + } + + public function getReplicaDelay() { + return $this->replicaDelay; + } + + public function setIsIndividual($is_individual) { + $this->isIndividual = $is_individual; + return $this; + } + + public function getIsIndividual() { + return $this->isIndividual; + } + + public static function getConnectionStatusMap() { + return array( + self::STATUS_OKAY => array( + 'icon' => 'fa-exchange', + 'color' => 'green', + 'label' => pht('Okay'), + ), + self::STATUS_FAIL => array( + 'icon' => 'fa-times', + 'color' => 'red', + 'label' => pht('Failed'), + ), + self::STATUS_AUTH => array( + 'icon' => 'fa-key', + 'color' => 'red', + 'label' => pht('Invalid Credentials'), + ), + self::STATUS_REPLICATION_CLIENT => array( + 'icon' => 'fa-eye-slash', + 'color' => 'yellow', + 'label' => pht('Missing Permission'), + ), + ); + } + + public static function getReplicaStatusMap() { + return array( + self::REPLICATION_OKAY => array( + 'icon' => 'fa-download', + 'color' => 'green', + 'label' => pht('Okay'), + ), + self::REPLICATION_MASTER_REPLICA => array( + 'icon' => 'fa-database', + 'color' => 'red', + 'label' => pht('Replicating Master'), + ), + self::REPLICATION_REPLICA_NONE => array( + 'icon' => 'fa-download', + 'color' => 'red', + 'label' => pht('Not Replicating'), + ), + self::REPLICATION_SLOW => array( + 'icon' => 'fa-hourglass', + 'color' => 'red', + 'label' => pht('Slow Replication'), + ), + ); + } + + public static function getLiveRefs() { + $cache = PhabricatorCaches::getRequestCache(); + + $refs = $cache->getKey(self::KEY_REFS); + if (!$refs) { + $refs = self::newRefs(); + $cache->setKey(self::KEY_REFS, $refs); + } + + return $refs; + } + + public static function getLiveIndividualRef() { + $cache = PhabricatorCaches::getRequestCache(); + + $ref = $cache->getKey(self::KEY_INDIVIDUAL); + if (!$ref) { + $ref = self::newIndividualRef(); + $cache->setKey(self::KEY_INDIVIDUAL, $ref); + } + + return $ref; + } + + public static function newRefs() { + $refs = array(); + + $default_port = PhabricatorEnv::getEnvConfig('mysql.port'); + $default_port = nonempty($default_port, 3306); + + $default_user = PhabricatorEnv::getEnvConfig('mysql.user'); + + $default_pass = PhabricatorEnv::getEnvConfig('mysql.pass'); + $default_pass = new PhutilOpaqueEnvelope($default_pass); + + $config = PhabricatorEnv::getEnvConfig('cluster.databases'); + foreach ($config as $server) { + $host = $server['host']; + $port = idx($server, 'port', $default_port); + $user = idx($server, 'user', $default_user); + $disabled = idx($server, 'disabled', false); + + $pass = idx($server, 'pass'); + if ($pass) { + $pass = new PhutilOpaqueEnvelope($pass); + } else { + $pass = clone $default_pass; + } + + $role = $server['role']; + + $ref = id(new self()) + ->setHost($host) + ->setPort($port) + ->setUser($user) + ->setPass($pass) + ->setDisabled($disabled) + ->setIsMaster(($role == 'master')); + + $refs[] = $ref; + } + + return $refs; + } + + public static function queryAll() { + $refs = self::newRefs(); + + foreach ($refs as $ref) { + if ($ref->getDisabled()) { + continue; + } + + $conn = $ref->newManagementConnection(); + + $t_start = microtime(true); + $replica_status = false; + try { + $replica_status = queryfx_one($conn, 'SHOW SLAVE STATUS'); + $ref->setConnectionStatus(self::STATUS_OKAY); + } catch (AphrontAccessDeniedQueryException $ex) { + $ref->setConnectionStatus(self::STATUS_REPLICATION_CLIENT); + $ref->setConnectionMessage( + pht( + 'No permission to run "SHOW SLAVE STATUS". Grant this user '. + '"REPLICATION CLIENT" permission to allow Phabricator to '. + 'monitor replica health.')); + } catch (AphrontInvalidCredentialsQueryException $ex) { + $ref->setConnectionStatus(self::STATUS_AUTH); + $ref->setConnectionMessage($ex->getMessage()); + } catch (AphrontQueryException $ex) { + $ref->setConnectionStatus(self::STATUS_FAIL); + + $class = get_class($ex); + $message = $ex->getMessage(); + $ref->setConnectionMessage( + pht( + '%s: %s', + get_class($ex), + $ex->getMessage())); + } + $t_end = microtime(true); + $ref->setConnectionLatency($t_end - $t_start); + + if ($replica_status !== false) { + $is_replica = (bool)$replica_status; + if ($ref->getIsMaster() && $is_replica) { + $ref->setReplicaStatus(self::REPLICATION_MASTER_REPLICA); + $ref->setReplicaMessage( + pht( + 'This host has a "master" role, but is replicating data from '. + 'another host ("%s")!', + idx($replica_status, 'Master_Host'))); + } else if (!$ref->getIsMaster() && !$is_replica) { + $ref->setReplicaStatus(self::REPLICATION_REPLICA_NONE); + $ref->setReplicaMessage( + pht( + 'This host has a "replica" role, but is not replicating data '. + 'from a master (no output from "SHOW SLAVE STATUS").')); + } else { + $ref->setReplicaStatus(self::REPLICATION_OKAY); + } + + if ($is_replica) { + $latency = (int)idx($replica_status, 'Seconds_Behind_Master'); + $ref->setReplicaDelay($latency); + if ($latency > 30) { + $ref->setReplicaStatus(self::REPLICATION_SLOW); + $ref->setReplicaMessage( + pht( + 'This replica is lagging far behind the master. Data is at '. + 'risk!')); + } + } + } + } + + return $refs; + } + + public function newManagementConnection() { + return $this->newConnection( + array( + 'retries' => 0, + 'timeout' => 2, + )); + } + + public function newApplicationConnection($database) { + return $this->newConnection( + array( + 'database' => $database, + )); + } + + public function isSevered() { + // If we only have an individual database, never sever our connection to + // it, at least for now. It's possible that using the same severing rules + // might eventually make sense to help alleviate load-related failures, + // but we should wait for all the cluster stuff to stabilize first. + if ($this->getIsIndividual()) { + return false; + } + + if ($this->didFailToConnect) { + return true; + } + + $record = $this->getHealthRecord(); + $is_healthy = $record->getIsHealthy(); + if (!$is_healthy) { + return true; + } + + return false; + } + + public function isReachable(AphrontDatabaseConnection $connection) { + $record = $this->getHealthRecord(); + $should_check = $record->getShouldCheck(); + + if ($this->isSevered() && !$should_check) { + return false; + } + + try { + $connection->openConnection(); + $reachable = true; + } catch (Exception $ex) { + $reachable = false; + } + + if ($should_check) { + $record->didHealthCheck($reachable); + } + + if (!$reachable) { + $this->didFailToConnect = true; + } + + return $reachable; + } + + public function checkHealth() { + $health = $this->getHealthRecord(); + + $should_check = $health->getShouldCheck(); + if ($should_check) { + // This does an implicit health update. + $connection = $this->newManagementConnection(); + $this->isReachable($connection); + } + + return $this; + } + + public function getHealthRecord() { + if (!$this->healthRecord) { + $this->healthRecord = new PhabricatorDatabaseHealthRecord($this); + } + return $this->healthRecord; + } + + public static function getMasterDatabaseRef() { + $refs = self::getLiveRefs(); + + if (!$refs) { + return self::getLiveIndividualRef(); + } + + $master = null; + foreach ($refs as $ref) { + if ($ref->getDisabled()) { + continue; + } + if ($ref->getIsMaster()) { + return $ref; + } + } + + return null; + } + + public static function newIndividualRef() { + $conf = PhabricatorEnv::newObjectFromConfig( + 'mysql.configuration-provider', + array(null, 'w', null)); + + return id(new self()) + ->setHost($conf->getHost()) + ->setPort($conf->getPort()) + ->setUser($conf->getUser()) + ->setPass($conf->getPassword()) + ->setIsIndividual(true) + ->setIsMaster(true); + } + + public static function getReplicaDatabaseRef() { + $refs = self::getLiveRefs(); + + if (!$refs) { + return null; + } + + // TODO: We may have multiple replicas to choose from, and could make + // more of an effort to pick the "best" one here instead of always + // picking the first one. Once we've picked one, we should try to use + // the same replica for the rest of the request, though. + + foreach ($refs as $ref) { + if ($ref->getDisabled()) { + continue; + } + if ($ref->getIsMaster()) { + continue; + } + return $ref; + } + + return null; + } + + private function newConnection(array $options) { + // If we believe the database is unhealthy, don't spend as much time + // trying to connect to it, since it's likely to continue to fail and + // hammering it can only make the problem worse. + $record = $this->getHealthRecord(); + if ($record->getIsHealthy()) { + $default_retries = 3; + $default_timeout = 10; + } else { + $default_retries = 0; + $default_timeout = 2; + } + + $spec = $options + array( + 'user' => $this->getUser(), + 'pass' => $this->getPass(), + 'host' => $this->getHost(), + 'port' => $this->getPort(), + 'database' => null, + 'retries' => $default_retries, + 'timeout' => $default_timeout, + ); + + return PhabricatorEnv::newObjectFromConfig( + 'mysql.implementation', + array( + $spec, + )); + } + +} diff --git a/src/infrastructure/env/PhabricatorEnv.php b/src/infrastructure/env/PhabricatorEnv.php index 421d7eca25..60bd9e4595 100644 --- a/src/infrastructure/env/PhabricatorEnv.php +++ b/src/infrastructure/env/PhabricatorEnv.php @@ -56,6 +56,13 @@ final class PhabricatorEnv extends Phobject { private static $requestBaseURI; private static $cache; private static $localeCode; + private static $readOnly; + private static $readOnlyReason; + + const READONLY_CONFIG = 'config'; + const READONLY_UNREACHABLE = 'unreachable'; + const READONLY_SEVERED = 'severed'; + const READONLY_MASTERLESS = 'masterless'; /** * @phutil-external-symbol class PhabricatorStartup @@ -84,6 +91,7 @@ final class PhabricatorEnv extends Phobject { private static function initializeCommonEnvironment() { PhutilErrorHandler::initialize(); + self::resetUmask(); self::buildConfigurationSourceStack(); // Force a valid timezone. If both PHP and Phabricator configuration are @@ -209,6 +217,16 @@ final class PhabricatorEnv extends Phobject { $stack->pushSource($site_source); } + $master = PhabricatorDatabaseRef::getMasterDatabaseRef(); + if (!$master) { + self::setReadOnly(true, self::READONLY_MASTERLESS); + } else if ($master->isSevered()) { + $master->checkHealth(); + if ($master->isSevered()) { + self::setReadOnly(true, self::READONLY_SEVERED); + } + } + try { $stack->pushSource( id(new PhabricatorConfigDatabaseSource('default')) @@ -439,6 +457,55 @@ final class PhabricatorEnv extends Phobject { self::$requestBaseURI = $uri; } + public static function isReadOnly() { + if (self::$readOnly !== null) { + return self::$readOnly; + } + return self::getEnvConfig('cluster.read-only'); + } + + public static function setReadOnly($read_only, $reason) { + self::$readOnly = $read_only; + self::$readOnlyReason = $reason; + } + + public static function getReadOnlyMessage() { + $reason = self::getReadOnlyReason(); + switch ($reason) { + case self::READONLY_MASTERLESS: + return pht( + 'Phabricator is in read-only mode (no writable database '. + 'is configured).'); + case self::READONLY_UNREACHABLE: + return pht( + 'Phabricator is in read-only mode (unreachable master).'); + case self::READONLY_SEVERED: + return pht( + 'Phabricator is in read-only mode (major interruption).'); + } + + return pht('Phabricator is in read-only mode.'); + } + + public static function getReadOnlyURI() { + return urisprintf( + '/readonly/%s/', + self::getReadOnlyReason()); + } + + public static function getReadOnlyReason() { + if (!self::isReadOnly()) { + return null; + } + + if (self::$readOnlyReason !== null) { + return self::$readOnlyReason; + } + + return self::READONLY_CONFIG; + } + + /* -( Unit Test Support )-------------------------------------------------- */ @@ -716,6 +783,11 @@ final class PhabricatorEnv extends Phobject { } public static function isClusterRemoteAddress() { + $cluster_addresses = self::getEnvConfig('cluster.addresses'); + if (!$cluster_addresses) { + return false; + } + $address = idx($_SERVER, 'REMOTE_ADDR'); if (!$address) { throw new Exception( @@ -792,4 +864,17 @@ final class PhabricatorEnv extends Phobject { self::$cache = array(); } + private static function resetUmask() { + // Reset the umask to the common standard umask. The umask controls default + // permissions when files are created and propagates to subprocesses. + + // "022" is the most common umask, but sometimes it is set to something + // unusual by the calling environment. + + // Since various things rely on this umask to work properly and we are + // not aware of any legitimate reasons to adjust it, unconditionally + // normalize it until such reasons arise. See T7475 for discussion. + umask(022); + } + } diff --git a/src/infrastructure/markup/PhabricatorMarkupEngine.php b/src/infrastructure/markup/PhabricatorMarkupEngine.php index 38eb0c500e..f570ee7e51 100644 --- a/src/infrastructure/markup/PhabricatorMarkupEngine.php +++ b/src/infrastructure/markup/PhabricatorMarkupEngine.php @@ -254,6 +254,8 @@ final class PhabricatorMarkupEngine extends Phobject { } } + $is_readonly = PhabricatorEnv::isReadOnly(); + foreach ($objects as $key => $info) { // False check in case MySQL doesn't support unicode characters // in the string (T1191), resulting in unserialize returning false. @@ -279,7 +281,7 @@ final class PhabricatorMarkupEngine extends Phobject { ->setCacheData($data) ->setMetadata($metadata); - if (isset($use_cache[$key])) { + if (isset($use_cache[$key]) && !$is_readonly) { // This is just filling a cache and always safe, even on a read pathway. $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); $blocks[$key]->replace(); diff --git a/src/infrastructure/storage/lisk/PhabricatorLiskDAO.php b/src/infrastructure/storage/lisk/PhabricatorLiskDAO.php index 534c38b7dc..610bc06f59 100644 --- a/src/infrastructure/storage/lisk/PhabricatorLiskDAO.php +++ b/src/infrastructure/storage/lisk/PhabricatorLiskDAO.php @@ -52,7 +52,33 @@ abstract class PhabricatorLiskDAO extends LiskDAO { */ protected function establishLiveConnection($mode) { $namespace = self::getStorageNamespace(); + $database = $namespace.'_'.$this->getApplicationName(); + $is_readonly = PhabricatorEnv::isReadOnly(); + + if ($is_readonly && ($mode != 'r')) { + $this->raiseImproperWrite($database); + } + + $is_cluster = (bool)PhabricatorEnv::getEnvConfig('cluster.databases'); + if ($is_cluster) { + $connection = $this->newClusterConnection($database, $mode); + } else { + $connection = $this->newBasicConnection($database, $mode, $namespace); + } + + // TODO: This should be testing if the mode is "r", but that would proably + // break a lot of things. Perform a more narrow test for readonly mode + // until we have greater certainty that this works correctly most of the + // time. + if ($is_readonly) { + $connection->setReadOnly(true); + } + + return $connection; + } + + private function newBasicConnection($database, $mode, $namespace) { $conf = PhabricatorEnv::newObjectFromConfig( 'mysql.configuration-provider', array($this, $mode, $namespace)); @@ -65,12 +91,71 @@ abstract class PhabricatorLiskDAO extends LiskDAO { 'pass' => $conf->getPassword(), 'host' => $conf->getHost(), 'port' => $conf->getPort(), - 'database' => $conf->getDatabase(), + 'database' => $database, 'retries' => 3, ), )); } + private function newClusterConnection($database, $mode) { + $master = PhabricatorDatabaseRef::getMasterDatabaseRef(); + + if ($master && !$master->isSevered()) { + $connection = $master->newApplicationConnection($database); + if ($master->isReachable($connection)) { + return $connection; + } else { + if ($mode == 'w') { + $this->raiseImpossibleWrite($database); + } + PhabricatorEnv::setReadOnly( + true, + PhabricatorEnv::READONLY_UNREACHABLE); + } + } + + $replica = PhabricatorDatabaseRef::getReplicaDatabaseRef(); + if (!$replica) { + throw new Exception( + pht('No valid databases are configured!')); + } + + $connection = $replica->newApplicationConnection($database); + $connection->setReadOnly(true); + if ($replica->isReachable($connection)) { + return $connection; + } + + $this->raiseUnreachable($database); + } + + private function raiseImproperWrite($database) { + throw new PhabricatorClusterImproperWriteException( + pht( + 'Unable to establish a write-mode connection (to application '. + 'database "%s") because Phabricator is in read-only mode. Whatever '. + 'you are trying to do does not function correctly in read-only mode.', + $database)); + } + + private function raiseImpossibleWrite($database) { + throw new PhabricatorClusterImpossibleWriteException( + pht( + 'Unable to connect to master database ("%s"). This is a severe '. + 'failure; your request did not complete.', + $database)); + } + + private function raiseUnreachable($database) { + throw new PhabricatorClusterStrandedException( + pht( + 'Unable to establish a connection to ANY database host '. + '(while trying "%s"). All masters and replicas are completely '. + 'unreachable.', + $database)); + } + + /** * @task config */ diff --git a/src/infrastructure/storage/management/workflow/PhabricatorStorageManagementDumpWorkflow.php b/src/infrastructure/storage/management/workflow/PhabricatorStorageManagementDumpWorkflow.php index 38ce117a08..4933bd70bb 100644 --- a/src/infrastructure/storage/management/workflow/PhabricatorStorageManagementDumpWorkflow.php +++ b/src/infrastructure/storage/management/workflow/PhabricatorStorageManagementDumpWorkflow.php @@ -7,11 +7,20 @@ final class PhabricatorStorageManagementDumpWorkflow $this ->setName('dump') ->setExamples('**dump** [__options__]') - ->setSynopsis(pht('Dump all data in storage to stdout.')); + ->setSynopsis(pht('Dump all data in storage to stdout.')) + ->setArguments( + array( + array( + 'name' => 'for-replica', + 'help' => pht( + 'Add __--master-data__ to the __mysqldump__ command, '. + 'generating a CHANGE MASTER statement in the output.'), + ), + )); } public function didExecute(PhutilArgumentParser $args) { - $api = $this->getAPI(); + $api = $this->getAPI(); $patches = $this->getPatches(); $console = PhutilConsole::getConsole(); @@ -33,26 +42,46 @@ final class PhabricatorStorageManagementDumpWorkflow list($host, $port) = $this->getBareHostAndPort($api->getHost()); - $flag_password = ''; + $has_password = false; + $password = $api->getPassword(); if ($password) { if (strlen($password->openEnvelope())) { - $flag_password = csprintf('-p%P', $password); + $has_password = true; } } - $flag_port = $port - ? csprintf('--port %d', $port) - : ''; + $argv = array(); + $argv[] = '--hex-blob'; + $argv[] = '--single-transaction'; + $argv[] = '--default-character-set=utf8'; - return phutil_passthru( - 'mysqldump --hex-blob --single-transaction --default-character-set=utf8 '. - '-u %s %C -h %s %C --databases %Ls', - $api->getUser(), - $flag_password, - $host, - $flag_port, - $databases); + if ($args->getArg('for-replica')) { + $argv[] = '--master-data'; + } + + $argv[] = '-u'; + $argv[] = $api->getUser(); + $argv[] = '-h'; + $argv[] = $host; + + if ($port) { + $argv[] = '--port'; + $argv[] = $port; + } + + $argv[] = '--databases'; + foreach ($databases as $database) { + $argv[] = $database; + } + + if ($has_password) { + $err = phutil_passthru('mysqldump -p%P %Ls', $password, $argv); + } else { + $err = phutil_passthru('mysqldump %Ls', $argv); + } + + return $err; } } diff --git a/src/infrastructure/storage/management/workflow/PhabricatorStorageManagementWorkflow.php b/src/infrastructure/storage/management/workflow/PhabricatorStorageManagementWorkflow.php index cc917215ee..b500599956 100644 --- a/src/infrastructure/storage/management/workflow/PhabricatorStorageManagementWorkflow.php +++ b/src/infrastructure/storage/management/workflow/PhabricatorStorageManagementWorkflow.php @@ -50,6 +50,17 @@ abstract class PhabricatorStorageManagementWorkflow $this->setDryRun($args->getArg('dryrun')); $this->setForce($args->getArg('force')); + if (PhabricatorEnv::isReadOnly()) { + if ($this->isForce()) { + PhabricatorEnv::setReadOnly(false, null); + } else { + throw new PhutilArgumentUsageException( + pht( + 'Phabricator is currently in read-only mode. Use --force to '. + 'override this mode.')); + } + } + $this->didExecute($args); } diff --git a/src/infrastructure/testing/PhabricatorTestCase.php b/src/infrastructure/testing/PhabricatorTestCase.php index c9a6ba986c..4af75d157e 100644 --- a/src/infrastructure/testing/PhabricatorTestCase.php +++ b/src/infrastructure/testing/PhabricatorTestCase.php @@ -113,8 +113,8 @@ abstract class PhabricatorTestCase extends PhutilTestCase { // We can't stub this service right now, and it's not generally useful // to publish notifications about test execution. $this->env->overrideEnvConfig( - 'notification.enabled', - false); + 'notification.servers', + array()); $this->env->overrideEnvConfig( 'phabricator.base-uri', @@ -126,6 +126,8 @@ abstract class PhabricatorTestCase extends PhutilTestCase { // Tests do their own stubbing/voiding for events. $this->env->overrideEnvConfig('phabricator.silent', false); + + $this->env->overrideEnvConfig('cluster.read-only', false); } protected function didRunTests() { diff --git a/src/view/form/control/AphrontFormDateControlValue.php b/src/view/form/control/AphrontFormDateControlValue.php index db2c7235c4..f5bfda1cc9 100644 --- a/src/view/form/control/AphrontFormDateControlValue.php +++ b/src/view/form/control/AphrontFormDateControlValue.php @@ -84,20 +84,14 @@ final class AphrontFormDateControlValue extends Phobject { $value = new AphrontFormDateControlValue(); $value->viewer = $request->getViewer(); - $datetime = $request->getStr($key); - if (strlen($datetime)) { - $date = $datetime; - $time = null; - } else { - $date = $request->getStr($key.'_d'); - $time = $request->getStr($key.'_t'); - } + $date = $request->getStr($key.'_d'); + $time = $request->getStr($key.'_t'); - // If this looks like an epoch timestamp, prefix it with "@" so that - // DateTime() reads it as one. Assume small numbers are a "Ymd" digit - // string instead of an epoch timestamp for a time in 1970. - if (ctype_digit($date) && ($date > 30000000)) { - $date = '@'.$date; + // If we have the individual parts, we read them preferentially. If we do + // not, try to read the key as a raw value. This makes it so that HTTP + // prefilling is overwritten by the control value if the user changes it. + if (!strlen($date) && !strlen($time)) { + $date = $request->getStr($key); $time = null; } @@ -239,16 +233,19 @@ final class AphrontFormDateControlValue extends Phobject { private function newDateTime($date, $time) { $date = $this->getStandardDateFormat($date); $time = $this->getStandardTimeFormat($time); + try { - $datetime = new DateTime("{$date} {$time}"); + // We need to provide the timezone in the constructor, and also set it + // explicitly. If the date is an epoch timestamp, the timezone in the + // constructor is ignored. If the date is not an epoch timestamp, it is + // used to parse the date. + $zone = $this->getTimezone(); + $datetime = new DateTime("{$date} {$time}", $zone); + $datetime->setTimezone($zone); } catch (Exception $ex) { return null; } - // Set the timezone explicitly because it is ignored in the constructor - // if the date is an epoch timestamp. - $zone = $this->getTimezone(); - $datetime->setTimezone($zone); return $datetime; } @@ -312,6 +309,13 @@ final class AphrontFormDateControlValue extends Phobject { return $colloquial[$normalized]; } + // If this looks like an epoch timestamp, prefix it with "@" so that + // DateTime() reads it as one. Assume small numbers are a "Ymd" digit + // string instead of an epoch timestamp for a time in 1970. + if (ctype_digit($date) && ($date > 30000000)) { + $date = '@'.$date; + } + $separator = $this->getFormatSeparator(); $parts = preg_split('@[,./:-]@', $date); return implode($separator, $parts); diff --git a/src/view/page/PhabricatorStandardPageView.php b/src/view/page/PhabricatorStandardPageView.php index f38bf3c18b..08f0c0b3c4 100644 --- a/src/view/page/PhabricatorStandardPageView.php +++ b/src/view/page/PhabricatorStandardPageView.php @@ -272,6 +272,15 @@ final class PhabricatorStandardPageView extends PhabricatorBarePageView 'high-security-warning', $this->getHighSecurityWarningConfig()); + if (PhabricatorEnv::isReadOnly()) { + Javelin::initBehavior( + 'read-only-warning', + array( + 'message' => PhabricatorEnv::getReadOnlyMessage(), + 'uri' => PhabricatorEnv::getReadOnlyURI(), + )); + } + if ($console) { require_celerity_resource('aphront-dark-console-css'); @@ -519,22 +528,23 @@ final class PhabricatorStandardPageView extends PhabricatorBarePageView $response = CelerityAPI::getStaticResourceResponse(); - if (PhabricatorEnv::getEnvConfig('notification.enabled')) { + if ($request->isHTTPS()) { + $with_protocol = 'https'; + } else { + $with_protocol = 'http'; + } + + $servers = PhabricatorNotificationServerRef::getEnabledClientServers( + $with_protocol); + + if ($servers) { if ($user && $user->isLoggedIn()) { + // TODO: We could tell the browser about all the servers and let it + // do random reconnects to improve reliability. + shuffle($servers); + $server = head($servers); - $client_uri = PhabricatorEnv::getEnvConfig('notification.client-uri'); - $client_uri = new PhutilURI($client_uri); - if ($client_uri->getDomain() == 'localhost') { - $this_host = $this->getRequest()->getHost(); - $this_host = new PhutilURI('http://'.$this_host.'/'); - $client_uri->setDomain($this_host->getDomain()); - } - - if ($request->isHTTPS()) { - $client_uri->setProtocol('wss'); - } else { - $client_uri->setProtocol('ws'); - } + $client_uri = $server->getWebsocketURI(); Javelin::initBehavior( 'aphlict-listen', diff --git a/src/view/phui/PHUIBadgeView.php b/src/view/phui/PHUIBadgeView.php index 7594b6cb30..5ff6e297b0 100644 --- a/src/view/phui/PHUIBadgeView.php +++ b/src/view/phui/PHUIBadgeView.php @@ -61,6 +61,8 @@ final class PHUIBadgeView extends AphrontTagView { require_celerity_resource('phui-badge-view-css'); $id = celerity_generate_unique_node_id(); + Javelin::initBehavior('badge-view', array()); + $classes = array(); $classes[] = 'phui-badge-view'; if ($this->quality) { @@ -70,7 +72,7 @@ final class PHUIBadgeView extends AphrontTagView { return array( 'class' => implode(' ', $classes), - 'sigil' => 'jx-toggle-class', + 'sigil' => 'jx-badge-view', 'id' => $id, 'meta' => array( 'map' => array( diff --git a/src/view/phui/PHUIIconView.php b/src/view/phui/PHUIIconView.php index adfd33187d..af9af313ee 100644 --- a/src/view/phui/PHUIIconView.php +++ b/src/view/phui/PHUIIconView.php @@ -744,6 +744,33 @@ final class PHUIIconView extends AphrontTagView { 'fa-bluetooth', 'fa-bluetooth-b', 'fa-percent', + 'fa-gitlab', + 'fa-wpbeginner', + 'fa-wpforms', + 'fa-envira', + 'fa-universal-access', + 'fa-wheelchair-alt', + 'fa-question-circle-o', + 'fa-blind', + 'fa-audio-description', + 'fa-volume-control-phone', + 'fa-braille', + 'fa-assistive-listening-systems', + 'fa-asl-interpreting', + 'fa-american-sign-language-interpreting', + 'fa-deafness', + 'fa-hard-of-hearing', + 'fa-deaf', + 'fa-glide', + 'fa-glide-g', + 'fa-signing', + 'fa-sign-language', + 'fa-low-vision', + 'fa-viadeo', + 'fa-viadeo-square', + 'fa-snapchat', + 'fa-snapchat-ghost', + 'fa-snapchat-square', ); } diff --git a/support/aphlict/server/aphlict_server.js b/support/aphlict/server/aphlict_server.js index a567b0874f..a4089ef54a 100644 --- a/support/aphlict/server/aphlict_server.js +++ b/support/aphlict/server/aphlict_server.js @@ -7,15 +7,9 @@ var util = require('util'); var fs = require('fs'); function parse_command_line_arguments(argv) { - var config = { - 'client-port': 22280, - 'admin-port': 22281, - 'client-host': '0.0.0.0', - 'admin-host': '127.0.0.1', - log: '/var/log/aphlict.log', - 'ssl-key': null, - 'ssl-cert': null, - test: false + var args = { + test: false, + config: null }; for (var ii = 2; ii < argv.length; ii++) { @@ -24,16 +18,18 @@ function parse_command_line_arguments(argv) { if (!matches) { throw new Error('Unknown argument "' + arg + '"!'); } - if (!(matches[1] in config)) { + if (!(matches[1] in args)) { throw new Error('Unknown argument "' + matches[1] + '"!'); } - config[matches[1]] = matches[2]; + args[matches[1]] = matches[2]; } - config['client-port'] = parseInt(config['client-port'], 10); - config['admin-port'] = parseInt(config['admin-port'], 10); + return args; +} - return config; +function parse_config(args) { + var data = fs.readFileSync(args.config); + return JSON.parse(data); } require('./lib/AphlictLog'); @@ -41,7 +37,8 @@ require('./lib/AphlictLog'); var debug = new JX.AphlictLog() .addConsole(console); -var config = parse_command_line_arguments(process.argv); +var args = parse_command_line_arguments(process.argv); +var config = parse_config(args); function set_exit_code(code) { process.on('exit', function() { @@ -51,9 +48,9 @@ function set_exit_code(code) { process.on('uncaughtException', function(err) { var context = null; - if (err.code == 'EACCES' && err.path == config.log) { + if (err.code == 'EACCES') { context = util.format( - 'Unable to open logfile ("%s"). Check that permissions are set ' + + 'Unable to open file ("%s"). Check that permissions are set ' + 'correctly.', err.path); } @@ -70,11 +67,6 @@ process.on('uncaughtException', function(err) { set_exit_code(1); }); -// Add the logfile so we'll fail if we can't write to it. -if (config.log) { - debug.addLog(config.log); -} - try { require('ws'); } catch (ex) { @@ -89,47 +81,119 @@ try { require('./lib/AphlictAdminServer'); require('./lib/AphlictClientServer'); +require('./lib/AphlictPeerList'); +require('./lib/AphlictPeer'); -var ssl_config = { - enabled: (config['ssl-key'] || config['ssl-cert']) -}; +var ii; -// Load the SSL certificates (if any were provided) now, so that runs with -// `--test` will see any errors. -if (ssl_config.enabled) { - ssl_config.key = fs.readFileSync(config['ssl-key']); - ssl_config.cert = fs.readFileSync(config['ssl-cert']); +var logs = config.logs || []; +for (ii = 0; ii < logs.length; ii++) { + debug.addLog(logs[ii].path); +} + +var servers = []; +for (ii = 0; ii < config.servers.length; ii++) { + var spec = config.servers[ii]; + + spec.listen = spec.listen || '0.0.0.0'; + + if (spec['ssl.key']) { + spec['ssl.key'] = fs.readFileSync(spec['ssl.key']); + } + + if (spec['ssl.cert']){ + spec['ssl.cert'] = fs.readFileSync(spec['ssl.cert']); + } + + if (spec['ssl.chain']){ + spec['ssl.chain'] = fs.readFileSync(spec['ssl.chain']); + } + + servers.push(spec); } // If we're just doing a configuration test, exit here before starting any // servers. -if (config.test) { +if (args.test) { debug.log('Configuration test OK.'); set_exit_code(0); return; } -var server; -if (ssl_config.enabled) { - server = https.createServer({ - key: ssl_config.key, - cert: ssl_config.cert - }, function(req, res) { - res.writeHead(501); - res.end('HTTP/501 Use Websockets\n'); - }); -} else { - server = http.createServer(function() {}); +debug.log('Starting servers (service PID %d).', process.pid); + +for (ii = 0; ii < logs.length; ii++) { + debug.log('Logging to "%s".', logs[ii].path); } -var client_server = new JX.AphlictClientServer(server); -var admin_server = new JX.AphlictAdminServer(); +var aphlict_servers = []; +var aphlict_clients = []; +var aphlict_admins = []; +for (ii = 0; ii < servers.length; ii++) { + var server = servers[ii]; + var is_client = (server.type == 'client'); -client_server.setLogger(debug); -admin_server.setLogger(debug); -admin_server.setClientServer(client_server); + var http_server; + if (server['ssl.key']) { + var https_config = { + key: server['ssl.key'], + cert: server['ssl.cert'], + }; -client_server.listen(config['client-port'], config['client-host']); -admin_server.listen(config['admin-port'], config['admin-host']); + if (server['ssl.chain']) { + https_config.ca = server['ssl.chain']; + } -debug.log('Started Server (PID %d)', process.pid); + http_server = https.createServer(https_config); + } else { + http_server = http.createServer(); + } + + var aphlict_server; + if (is_client) { + aphlict_server = new JX.AphlictClientServer(http_server); + } else { + aphlict_server = new JX.AphlictAdminServer(http_server); + } + + aphlict_server.setLogger(debug); + aphlict_server.listen(server.port, server.listen); + + debug.log( + 'Started %s server (Port %d, %s).', + server.type, + server.port, + server['ssl.key'] ? 'With SSL' : 'No SSL'); + + aphlict_servers.push(aphlict_server); + + if (is_client) { + aphlict_clients.push(aphlict_server); + } else { + aphlict_admins.push(aphlict_server); + } +} + +var peer_list = new JX.AphlictPeerList(); + +debug.log( + 'This server has fingerprint "%s".', + peer_list.getFingerprint()); + +var cluster = config.cluster || []; +for (ii = 0; ii < cluster.length; ii++) { + var peer = cluster[ii]; + + var peer_client = new JX.AphlictPeer() + .setHost(peer.host) + .setPort(peer.port) + .setProtocol(peer.protocol); + + peer_list.addPeer(peer_client); +} + +for (ii = 0; ii < aphlict_admins.length; ii++) { + var admin_server = aphlict_admins[ii]; + admin_server.setClientServers(aphlict_clients); + admin_server.setPeerList(peer_list); +} diff --git a/support/aphlict/server/lib/AphlictAdminServer.js b/support/aphlict/server/lib/AphlictAdminServer.js index f9caddc2bb..3cac0be3b5 100644 --- a/support/aphlict/server/lib/AphlictAdminServer.js +++ b/support/aphlict/server/lib/AphlictAdminServer.js @@ -9,15 +9,20 @@ var url = require('url'); JX.install('AphlictAdminServer', { - construct: function() { - this.setLogger(new JX.AphlictLog()); - + construct: function(server) { this._startTime = new Date().getTime(); this._messagesIn = 0; this._messagesOut = 0; - var handler = this._handler.bind(this); - this._server = http.createServer(handler); + server.on('request', JX.bind(this, this._onrequest)); + this._server = server; + this._clientServers = []; + }, + + properties: { + clientServers: null, + logger: null, + peerList: null }, members: { @@ -26,18 +31,35 @@ JX.install('AphlictAdminServer', { _server: null, _startTime: null, - getListenerList: function(instance) { - return this.getClientServer().getListenerList(instance); + getListenerLists: function(instance) { + var clients = this.getClientServers(); + + var lists = []; + for (var ii = 0; ii < clients.length; ii++) { + lists.push(clients[ii].getListenerList(instance)); + } + return lists; + }, + + log: function() { + var logger = this.getLogger(); + if (!logger) { + return; + } + + logger.log.apply(logger, arguments); + + return this; }, listen: function() { return this._server.listen.apply(this._server, arguments); }, - _handler: function(request, response) { + _onrequest: function(request, response) { var self = this; var u = url.parse(request.url, true); - var instance = u.query.instance || '/'; + var instance = u.query.instance || 'default'; // Publishing a notification. if (u.pathname == '/') { @@ -52,23 +74,22 @@ JX.install('AphlictAdminServer', { try { var msg = JSON.parse(body); - self.getLogger().log( + self.log( 'Received notification (' + instance + '): ' + JSON.stringify(msg)); ++self._messagesIn; try { - self._transmit(instance, msg); - response.writeHead(200, {'Content-Type': 'text/plain'}); + self._transmit(instance, msg, response); } catch (err) { - self.getLogger().log( + self.log( '<%s> Internal Server Error! %s', request.socket.remoteAddress, err); response.writeHead(500, 'Internal Server Error'); } } catch (err) { - self.getLogger().log( + self.log( '<%s> Bad Request! %s', request.socket.remoteAddress, err); @@ -82,61 +103,95 @@ JX.install('AphlictAdminServer', { response.end(); } } else if (u.pathname == '/status/') { - var status = { - 'instance': instance, - 'uptime': (new Date().getTime() - this._startTime), - 'clients.active': this.getListenerList(instance) - .getActiveListenerCount(), - 'clients.total': this.getListenerList(instance) - .getTotalListenerCount(), - 'messages.in': this._messagesIn, - 'messages.out': this._messagesOut, - 'version': 7 - }; - - response.writeHead(200, {'Content-Type': 'application/json'}); - response.write(JSON.stringify(status)); - response.end(); + this._handleStatusRequest(request, response, instance); } else { response.writeHead(404, 'Not Found'); response.end(); } }, + _handleStatusRequest: function(request, response, instance) { + var active_count = 0; + var total_count = 0; + + var lists = this.getListenerLists(instance); + for (var ii = 0; ii < lists.length; ii++) { + var list = lists[ii]; + active_count += list.getActiveListenerCount(); + total_count += list.getTotalListenerCount(); + } + + var server_status = { + 'instance': instance, + 'uptime': (new Date().getTime() - this._startTime), + 'clients.active': active_count, + 'clients.total': total_count, + 'messages.in': this._messagesIn, + 'messages.out': this._messagesOut, + 'version': 7 + }; + + response.writeHead(200, {'Content-Type': 'application/json'}); + response.write(JSON.stringify(server_status)); + response.end(); + }, + /** * Transmits a message to all subscribed listeners. */ - _transmit: function(instance, message) { - var listeners = this.getListenerList(instance) - .getListeners() - .filter(function(client) { - return client.isSubscribedToAny(message.subscribers); - }); + _transmit: function(instance, message, response) { + var peer_list = this.getPeerList(); - for (var i = 0; i < listeners.length; i++) { - var listener = listeners[i]; + message = peer_list.addFingerprint(message); + if (message) { + var lists = this.getListenerLists(instance); + + for (var ii = 0; ii < lists.length; ii++) { + var list = lists[ii]; + var listeners = list.getListeners(); + this._transmitToListeners(list, listeners, message); + } + + peer_list.broadcastMessage(instance, message); + } + + // Respond to the caller with our fingerprint so it can stop sending + // us traffic we don't need to know about if it's a peer. In particular, + // this stops us from broadcasting messages to ourselves if we appear + // in the cluster list. + var receipt = { + fingerprint: this.getPeerList().getFingerprint() + }; + + response.writeHead(200, {'Content-Type': 'application/json'}); + response.write(JSON.stringify(receipt)); + }, + + _transmitToListeners: function(list, listeners, message) { + for (var ii = 0; ii < listeners.length; ii++) { + var listener = listeners[ii]; + + if (!listener.isSubscribedToAny(message.subscribers)) { + continue; + } try { listener.writeMessage(message); ++this._messagesOut; - this.getLogger().log( + this.log( '<%s> Wrote Message', listener.getDescription()); } catch (error) { - this.getListenerList(instance).removeListener(listener); - this.getLogger().log( + list.removeListener(listener); + + this.log( '<%s> Write Error: %s', listener.getDescription(), error); } } - }, - }, - - properties: { - clientServer: null, - logger: null, + } } }); diff --git a/support/aphlict/server/lib/AphlictClientServer.js b/support/aphlict/server/lib/AphlictClientServer.js index 0d54297c12..1d4375cbba 100644 --- a/support/aphlict/server/lib/AphlictClientServer.js +++ b/support/aphlict/server/lib/AphlictClientServer.js @@ -12,20 +12,63 @@ var WebSocket = require('ws'); JX.install('AphlictClientServer', { construct: function(server) { - this.setLogger(new JX.AphlictLog()); + server.on('request', JX.bind(this, this._onrequest)); + this._server = server; this._lists = {}; }, + properties: { + logger: null, + }, + members: { _server: null, _lists: null, - getListenerList: function(path) { - if (!this._lists[path]) { - this._lists[path] = new JX.AphlictListenerList(path); + getListenerList: function(instance) { + if (!this._lists[instance]) { + this._lists[instance] = new JX.AphlictListenerList(instance); } - return this._lists[path]; + return this._lists[instance]; + }, + + log: function() { + var logger = this.getLogger(); + if (!logger) { + return; + } + + logger.log.apply(logger, arguments); + + return this; + }, + + _onrequest: function(request, response) { + // The websocket code upgrades connections before they get here, so + // this only handles normal HTTP connections. We just fail them with + // a 501 response. + response.writeHead(501); + response.end('HTTP/501 Use Websockets\n'); + }, + + _parseInstanceFromPath: function(path) { + // If there's no "~" marker in the path, it's not an instance name. + // Users sometimes configure nginx or Apache to proxy based on the + // path. + if (path.indexOf('~') === -1) { + return 'default'; + } + + var instance = path.split('~')[1]; + + // Remove any "/" characters. + instance = instance.replace(/\//g, ''); + if (!instance.length) { + return 'default'; + } + + return instance; }, listen: function() { @@ -35,10 +78,12 @@ JX.install('AphlictClientServer', { wss.on('connection', function(ws) { var path = url.parse(ws.upgradeReq.url).pathname; - var listener = self.getListenerList(path).addListener(ws); + var instance = self._parseInstanceFromPath(path); + + var listener = self.getListenerList(instance).addListener(ws); function log() { - self.getLogger().log( + self.log( util.format('<%s>', listener.getDescription()) + ' ' + util.format.apply(null, arguments)); @@ -80,27 +125,12 @@ JX.install('AphlictClientServer', { }); ws.on('close', function() { - self.getListenerList(path).removeListener(listener); + self.getListenerList(instance).removeListener(listener); log('Disconnected.'); }); - - wss.on('close', function() { - self.getListenerList(path).removeListener(listener); - log('Disconnected.'); - }); - - wss.on('error', function(err) { - log('Error: %s', err.message); - }); - }); - }, - - }, - - properties: { - logger: null, + } } }); diff --git a/support/aphlict/server/lib/AphlictListener.js b/support/aphlict/server/lib/AphlictListener.js index 4c62e4aa7e..94a5c4fb50 100644 --- a/support/aphlict/server/lib/AphlictListener.js +++ b/support/aphlict/server/lib/AphlictListener.js @@ -50,7 +50,7 @@ JX.install('AphlictListener', { }, getDescription: function() { - return 'Listener/' + this.getID() + this._path; + return 'Listener/' + this.getID() + '/' + this._path; }, writeMessage: function(message) { diff --git a/support/aphlict/server/lib/AphlictPeer.js b/support/aphlict/server/lib/AphlictPeer.js new file mode 100644 index 0000000000..068977992c --- /dev/null +++ b/support/aphlict/server/lib/AphlictPeer.js @@ -0,0 +1,80 @@ +'use strict'; + +var JX = require('./javelin').JX; + +var http = require('http'); +var https = require('https'); + +JX.install('AphlictPeer', { + + construct: function() { + }, + + properties: { + host: null, + port: null, + protocol: null, + fingerprint: null + }, + + members: { + broadcastMessage: function(instance, message) { + var data; + try { + data = JSON.stringify(message); + } catch (error) { + return; + } + + // TODO: Maybe use "agent" stuff to pool connections? + + var options = { + hostname: this.getHost(), + port: this.getPort(), + method: 'POST', + path: '/?instance=' + instance, + headers: { + 'Content-Type': 'application/json', + 'Content-Length': data.length + } + }; + + var onresponse = JX.bind(this, this._onresponse); + + var request; + if (this.getProtocol() == 'https') { + request = https.request(options, onresponse); + } else { + request = http.request(options, onresponse); + } + + request.write(data); + request.end(); + }, + + _onresponse: function(response) { + var peer = this; + var data = ''; + + response.on('data', function(bytes) { + data += bytes; + }); + + response.on('end', function() { + var message; + try { + message = JSON.parse(data); + } catch (error) { + return; + } + + // If we got a valid receipt, update the fingerprint for this server. + var fingerprint = message.fingerprint; + if (fingerprint) { + peer.setFingerprint(fingerprint); + } + }); + } + } + +}); diff --git a/support/aphlict/server/lib/AphlictPeerList.js b/support/aphlict/server/lib/AphlictPeerList.js new file mode 100644 index 0000000000..9c8c707894 --- /dev/null +++ b/support/aphlict/server/lib/AphlictPeerList.js @@ -0,0 +1,86 @@ +'use strict'; + +var JX = require('./javelin').JX; + +JX.install('AphlictPeerList', { + + construct: function() { + this._peers = []; + + // Generate a new unique identify for this server. We just use this to + // identify messages we have already seen and figure out which peer is + // actually us, so we don't bounce messages around the cluster forever. + this._fingerprint = this._generateFingerprint(); + }, + + properties: { + }, + + members: { + _peers: null, + _fingerprint: null, + + addPeer: function(peer) { + this._peers.push(peer); + return this; + }, + + addFingerprint: function(message) { + var fingerprint = this.getFingerprint(); + + // Check if we've already touched this message. If we have, we do not + // broadcast it again. If we haven't, we add our fingerprint and then + // broadcast the modified version. + var touched = message.touched || []; + for (var ii = 0; ii < touched.length; ii++) { + if (touched[ii] == fingerprint) { + return null; + } + } + touched.push(fingerprint); + + message.touched = touched; + return message; + }, + + broadcastMessage: function(instance, message) { + var ii; + + var touches = {}; + var touched = message.touched; + for (ii = 0; ii < touched.length; ii++) { + touches[touched[ii]] = true; + } + + var peers = this._peers; + for (ii = 0; ii < peers.length; ii++) { + var peer = peers[ii]; + + // If we know the peer's fingerprint and it has already touched + // this message, don't broadcast it. + var fingerprint = peer.getFingerprint(); + if (fingerprint && touches[fingerprint]) { + continue; + } + + peer.broadcastMessage(instance, message); + } + }, + + getFingerprint: function() { + return this._fingerprint; + }, + + _generateFingerprint: function() { + var src = '23456789abcdefghjkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ'; + var len = 16; + var out = []; + for (var ii = 0; ii < len; ii++) { + var idx = Math.floor(Math.random() * src.length); + out.push(src[idx]); + } + return out.join(''); + } + } + +}); diff --git a/webroot/rsrc/css/aphront/notification.css b/webroot/rsrc/css/aphront/notification.css index 1940309569..ee835bba76 100644 --- a/webroot/rsrc/css/aphront/notification.css +++ b/webroot/rsrc/css/aphront/notification.css @@ -52,6 +52,11 @@ border: 1px solid {$violet}; } +.jx-notification-read-only { + background: {$greybackground}; + border: 1px solid {$darkgreyborder}; +} + .jx-notification-container .phabricator-notification { padding: 0; } diff --git a/webroot/rsrc/css/font/font-awesome.css b/webroot/rsrc/css/font/font-awesome.css index ca910e2cc8..4157229cb3 100644 --- a/webroot/rsrc/css/font/font-awesome.css +++ b/webroot/rsrc/css/font/font-awesome.css @@ -3,15 +3,15 @@ */ /*! - * Font Awesome 4.5.0 by @davegandy - http://fontawesome.io - @fontawesome + * Font Awesome 4.6.0 by @davegandy - http://fontawesome.io - @fontawesome * License - http://fontawesome.io/license (Font: SIL OFL 1.1, CSS: MIT License) */ /* FONT PATH * -------------------------- */ @font-face { font-family: 'FontAwesome'; - src: url('/rsrc/externals/font/fontawesome/fontawesome-webfont.eot?v=4.5.0'); - src: url('/rsrc/externals/font/fontawesome/fontawesome-webfont.eot?#iefix&v=4.5.0') format('embedded-opentype'), url('/rsrc/externals/font/fontawesome/fontawesome-webfont.woff2?v=4.5.0') format('woff2'), url('/rsrc/externals/font/fontawesome/fontawesome-webfont.woff?v=4.5.0') format('woff'), url('/rsrc/externals/font/fontawesome/fontawesome-webfont.ttf?v=4.5.0') format('truetype'); + src: url('/rsrc/externals/font/fontawesome/fontawesome-webfont.eot?v=4.6.0'); + src: url('/rsrc/externals/font/fontawesome/fontawesome-webfont.eot?#iefix&v=4.6.0') format('embedded-opentype'), url('/rsrc/externals/font/fontawesome/fontawesome-webfont.woff2?v=4.6.0') format('woff2'), url('/rsrc/externals/font/fontawesome/fontawesome-webfont.woff?v=4.6.0') format('woff'), url('/rsrc/externals/font/fontawesome/fontawesome-webfont.ttf?v=4.6.0') format('truetype'), url('/rsrc/externals/font/fontawesome/fontawesome-webfont.svg?v=4.6.0#fontawesomeregular') format('svg'); font-weight: normal; font-style: normal; } @@ -1947,3 +1947,95 @@ .fa-percent:before { content: "\f295"; } +.fa-gitlab:before { + content: "\f296"; +} +.fa-wpbeginner:before { + content: "\f297"; +} +.fa-wpforms:before { + content: "\f298"; +} +.fa-envira:before { + content: "\f299"; +} +.fa-universal-access:before { + content: "\f29a"; +} +.fa-wheelchair-alt:before { + content: "\f29b"; +} +.fa-question-circle-o:before { + content: "\f29c"; +} +.fa-blind:before { + content: "\f29d"; +} +.fa-audio-description:before { + content: "\f29e"; +} +.fa-volume-control-phone:before { + content: "\f2a0"; +} +.fa-braille:before { + content: "\f2a1"; +} +.fa-assistive-listening-systems:before { + content: "\f2a2"; +} +.fa-asl-interpreting:before, +.fa-american-sign-language-interpreting:before { + content: "\f2a3"; +} +.fa-deafness:before, +.fa-hard-of-hearing:before, +.fa-deaf:before { + content: "\f2a4"; +} +.fa-glide:before { + content: "\f2a5"; +} +.fa-glide-g:before { + content: "\f2a6"; +} +.fa-signing:before, +.fa-sign-language:before { + content: "\f2a7"; +} +.fa-low-vision:before { + content: "\f2a8"; +} +.fa-viadeo:before { + content: "\f2a9"; +} +.fa-viadeo-square:before { + content: "\f2aa"; +} +.fa-snapchat:before { + content: "\f2ab"; +} +.fa-snapchat-ghost:before { + content: "\f2ac"; +} +.fa-snapchat-square:before { + content: "\f2ad"; +} +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + border: 0; +} +.sr-only-focusable:active, +.sr-only-focusable:focus { + position: static; + width: auto; + height: auto; + margin: 0; + overflow: visible; + clip: auto; +} diff --git a/webroot/rsrc/css/phui/phui-badge.css b/webroot/rsrc/css/phui/phui-badge.css index fdb396b539..b702d3e6c5 100644 --- a/webroot/rsrc/css/phui/phui-badge.css +++ b/webroot/rsrc/css/phui/phui-badge.css @@ -50,6 +50,10 @@ background-color: {$lightbluebackground}; } +.phui-badge-card a { + color: {$darkbluetext}; +} + .card-flipped .phui-badge-card-container { transform: translateX( -100% ) rotateY( -180deg ); -webkit-transform: translateX( -100% ) rotateY( -180deg ); diff --git a/webroot/rsrc/css/phui/phui-profile-menu.css b/webroot/rsrc/css/phui/phui-profile-menu.css index 30855f0a05..41efc4cfb3 100644 --- a/webroot/rsrc/css/phui/phui-profile-menu.css +++ b/webroot/rsrc/css/phui/phui-profile-menu.css @@ -152,9 +152,17 @@ .phui-profile-menu .phabricator-side-menu .phui-profile-menu-error { color: rgba({$alphawhite}, 0.5); font-size: {$smallerfontsize}; - padding: 18px 15px; + padding: 16px; } +.phui-profile-menu .phui-profile-menu-collapsed .phabricator-side-menu + .phui-profile-menu-error { + padding: 16px 8px; + overflow: hidden; + text-overflow: ellipsis; +} + + .phui-profile-menu .phabricator-side-menu .phui-list-item-disabled .phui-list-item-href, .phui-profile-menu .phui-list-sidenav .phui-list-item-disabled diff --git a/webroot/rsrc/externals/font/fontawesome/fontawesome-webfont.eot b/webroot/rsrc/externals/font/fontawesome/fontawesome-webfont.eot index 9b6afaedc0..b15a0f5131 100644 Binary files a/webroot/rsrc/externals/font/fontawesome/fontawesome-webfont.eot and b/webroot/rsrc/externals/font/fontawesome/fontawesome-webfont.eot differ diff --git a/webroot/rsrc/externals/font/fontawesome/fontawesome-webfont.ttf b/webroot/rsrc/externals/font/fontawesome/fontawesome-webfont.ttf index 26dea7951a..cec09e000a 100644 Binary files a/webroot/rsrc/externals/font/fontawesome/fontawesome-webfont.ttf and b/webroot/rsrc/externals/font/fontawesome/fontawesome-webfont.ttf differ diff --git a/webroot/rsrc/externals/font/fontawesome/fontawesome-webfont.woff b/webroot/rsrc/externals/font/fontawesome/fontawesome-webfont.woff index dc35ce3c2c..52d8116589 100644 Binary files a/webroot/rsrc/externals/font/fontawesome/fontawesome-webfont.woff and b/webroot/rsrc/externals/font/fontawesome/fontawesome-webfont.woff differ diff --git a/webroot/rsrc/externals/font/fontawesome/fontawesome-webfont.woff2 b/webroot/rsrc/externals/font/fontawesome/fontawesome-webfont.woff2 index 500e517253..65aeb01eb7 100644 Binary files a/webroot/rsrc/externals/font/fontawesome/fontawesome-webfont.woff2 and b/webroot/rsrc/externals/font/fontawesome/fontawesome-webfont.woff2 differ diff --git a/webroot/rsrc/externals/javelin/lib/Leader.js b/webroot/rsrc/externals/javelin/lib/Leader.js index aaa37846e9..e0b71e2e48 100644 --- a/webroot/rsrc/externals/javelin/lib/Leader.js +++ b/webroot/rsrc/externals/javelin/lib/Leader.js @@ -34,6 +34,7 @@ JX.install('Leader', { statics: { _interval: null, + _timeout: null, _broadcastKey: 'JX.Leader.broadcast', _leaderKey: 'JX.Leader.id', @@ -63,7 +64,7 @@ JX.install('Leader', { */ start: function() { var self = JX.Leader; - self.callIfLeader(JX.bag); + self.call(JX.bag); }, /** @@ -132,8 +133,21 @@ JX.install('Leader', { self._becomeLeader(); leader_callback(); } else { + + // Set a callback to try to become the leader shortly after the + // current lease expires. This lets us recover from cases where the + // leader goes missing quickly. + if (self._timeoout) { + window.clearTimeout(self._timeout); + self._timeout = null; + } + self._timeout = window.setTimeout( + self._usurp, + (lease.until - now) + 50); + follower_callback(); } + return; } @@ -285,6 +299,16 @@ JX.install('Leader', { new JX.Leader().invoke('onBecomeLeader'); }, + + /** + * Try to usurp leadership position after a lease expiration. + */ + _usurp: function() { + var self = JX.Leader; + self.call(JX.bag); + }, + + /** * Mark a message as seen. * diff --git a/webroot/rsrc/js/application/diffusion/behavior-diffusion-browse-file.js b/webroot/rsrc/js/application/diffusion/behavior-diffusion-browse-file.js new file mode 100644 index 0000000000..41ff0cfcc3 --- /dev/null +++ b/webroot/rsrc/js/application/diffusion/behavior-diffusion-browse-file.js @@ -0,0 +1,47 @@ +/** + * @provides javelin-behavior-diffusion-browse-file + * @requires javelin-behavior + * javelin-dom + * javelin-util + * phabricator-tooltip + */ + +JX.behavior('diffusion-browse-file', function(config, statics) { + if (statics.installed) { + return; + } + statics.installed = true; + + var map = config.labels; + + JX.Stratcom.listen( + ['mouseover', 'mouseout'], + ['phabricator-source', 'tag:td'], + function(e) { + var target = e.getTarget(); + + // NOTE: We're using raw classnames instead of sigils and metadata here + // because these elements are unusual: there are a lot of them on the + // page, and rendering all the extra metadata to do this in a normal way + // would be needlessly expensive. This is an unusual case. + + if (!target.className.match(/cov-/)) { + return; + } + + if (e.getType() == 'mouseout') { + JX.Tooltip.hide(); + return; + } + + for (var k in map) { + if (!target.className.match(k)) { + continue; + } + + var label = map[k]; + JX.Tooltip.show(target, 300, 'E', label); + break; + } + }); +}); diff --git a/webroot/rsrc/js/core/behavior-badge-view.js b/webroot/rsrc/js/core/behavior-badge-view.js new file mode 100644 index 0000000000..92c7623e9f --- /dev/null +++ b/webroot/rsrc/js/core/behavior-badge-view.js @@ -0,0 +1,41 @@ +/** + * @provides javelin-behavior-badge-view + * @requires javelin-behavior + * javelin-stratcom + * javelin-dom + */ + +/** + * Toggle CSS classes when an element is clicked. This behavior is activated + * by adding the sigil `jx-badge-view` to an element, and a key `map` to its + * data. The `map` should be a map from element IDs to the classes that should + * be toggled on them. + * + * Optionally, you may provide a `state` key to set the default state of the + * element. + */ +JX.behavior('badge-view', function(config, statics) { + function install() { + JX.Stratcom.listen( + ['click'], + 'jx-badge-view', + function(e) { + if (e.getNode('tag:a')) { + // If the event has a 'tag:a' node on it, that means the user + // either clicked a link or some other node inside a link. + return; + } + + var t = e.getNodeData('jx-badge-view'); + t.state = !t.state; + for (var k in t.map) { + JX.DOM.alterClass(JX.$(k), t.map[k], t.state); + } + e.kill(); + }); + + return true; + } + + statics.install = statics.install || install(); +}); diff --git a/webroot/rsrc/js/core/behavior-read-only-warning.js b/webroot/rsrc/js/core/behavior-read-only-warning.js new file mode 100644 index 0000000000..7c333be978 --- /dev/null +++ b/webroot/rsrc/js/core/behavior-read-only-warning.js @@ -0,0 +1,23 @@ +/** + * @provides javelin-behavior-read-only-warning + * @requires javelin-behavior + * javelin-uri + * phabricator-notification + */ + +JX.behavior('read-only-warning', function(config) { + + var n = new JX.Notification() + .setContent(config.message) + .setDuration(0) + .alterClassName('jx-notification-read-only', true); + + n.listen( + 'activate', + function() { + JX.$U(config.uri).go(); + }); + + n.show(); + +}); diff --git a/webroot/rsrc/js/core/behavior-time-typeahead.js b/webroot/rsrc/js/core/behavior-time-typeahead.js index bbd5e08699..6bee5a14ef 100644 --- a/webroot/rsrc/js/core/behavior-time-typeahead.js +++ b/webroot/rsrc/js/core/behavior-time-typeahead.js @@ -136,7 +136,10 @@ JX.behavior('time-typeahead', function(config) { } end_minutes = end_time%60; - end_minutes = (end_minutes < 9) ? end_minutes : ('0' + end_minutes); + if (end_minutes < 9) { + end_minutes = '0' + end_minutes; + } + end_value = end_hours + ':' + end_minutes + ' ' + end_meridian; } diff --git a/webroot/rsrc/js/core/behavior-toggle-class.js b/webroot/rsrc/js/core/behavior-toggle-class.js index d8f7ef5bb5..d4756eb6bb 100644 --- a/webroot/rsrc/js/core/behavior-toggle-class.js +++ b/webroot/rsrc/js/core/behavior-toggle-class.js @@ -15,8 +15,6 @@ * element. */ JX.behavior('toggle-class', function(config, statics) { - statics.install = statics.install || install(); - function install() { JX.Stratcom.listen( ['touchstart', 'mousedown'], @@ -42,4 +40,6 @@ JX.behavior('toggle-class', function(config, statics) { return true; } + + statics.install = statics.install || install(); });