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/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/config/option/PhabricatorClusterConfigOptions.php b/src/applications/config/option/PhabricatorClusterConfigOptions.php index 0b7974904c..dcef50a8ed 100644 --- a/src/applications/config/option/PhabricatorClusterConfigOptions.php +++ b/src/applications/config/option/PhabricatorClusterConfigOptions.php @@ -73,6 +73,22 @@ 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.')), + ); } 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/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/infrastructure/env/PhabricatorEnv.php b/src/infrastructure/env/PhabricatorEnv.php index 421d7eca25..e0e968fbdb 100644 --- a/src/infrastructure/env/PhabricatorEnv.php +++ b/src/infrastructure/env/PhabricatorEnv.php @@ -56,6 +56,7 @@ final class PhabricatorEnv extends Phobject { private static $requestBaseURI; private static $cache; private static $localeCode; + private static $readOnly; /** * @phutil-external-symbol class PhabricatorStartup @@ -439,6 +440,18 @@ 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) { + self::$readOnly = $read_only; + } + + /* -( Unit Test Support )-------------------------------------------------- */ diff --git a/src/infrastructure/storage/lisk/PhabricatorLiskDAO.php b/src/infrastructure/storage/lisk/PhabricatorLiskDAO.php index 534c38b7dc..afd2a922e9 100644 --- a/src/infrastructure/storage/lisk/PhabricatorLiskDAO.php +++ b/src/infrastructure/storage/lisk/PhabricatorLiskDAO.php @@ -57,7 +57,16 @@ abstract class PhabricatorLiskDAO extends LiskDAO { 'mysql.configuration-provider', array($this, $mode, $namespace)); - return PhabricatorEnv::newObjectFromConfig( + $is_readonly = PhabricatorEnv::isReadOnly(); + if ($is_readonly && ($mode != 'r')) { + throw new Exception( + pht( + 'Attempting to establish write-mode connection from a read-only '. + 'page (to database "%s").', + $conf->getDatabase())); + } + + $connection = PhabricatorEnv::newObjectFromConfig( 'mysql.implementation', array( array( @@ -69,6 +78,16 @@ abstract class PhabricatorLiskDAO extends LiskDAO { 'retries' => 3, ), )); + + // 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; } /** diff --git a/src/infrastructure/storage/management/workflow/PhabricatorStorageManagementWorkflow.php b/src/infrastructure/storage/management/workflow/PhabricatorStorageManagementWorkflow.php index cc917215ee..d09a7ae131 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); + } 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..4f2fedeca6 100644 --- a/src/infrastructure/testing/PhabricatorTestCase.php +++ b/src/infrastructure/testing/PhabricatorTestCase.php @@ -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/page/PhabricatorStandardPageView.php b/src/view/page/PhabricatorStandardPageView.php index f38bf3c18b..6dc67a1447 100644 --- a/src/view/page/PhabricatorStandardPageView.php +++ b/src/view/page/PhabricatorStandardPageView.php @@ -272,6 +272,14 @@ final class PhabricatorStandardPageView extends PhabricatorBarePageView 'high-security-warning', $this->getHighSecurityWarningConfig()); + if (PhabricatorEnv::isReadOnly()) { + Javelin::initBehavior( + 'read-only-warning', + array( + 'message' => pht('This install is currently in read-only mode.'), + )); + } + if ($console) { require_celerity_resource('aphront-dark-console-css'); 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/js/core/behavior-read-only-warning.js b/webroot/rsrc/js/core/behavior-read-only-warning.js new file mode 100644 index 0000000000..a59d37f730 --- /dev/null +++ b/webroot/rsrc/js/core/behavior-read-only-warning.js @@ -0,0 +1,16 @@ +/** + * @provides javelin-behavior-read-only-warning + * @requires javelin-behavior + * javelin-uri + * phabricator-notification + */ + +JX.behavior('read-only-warning', function(config) { + + new JX.Notification() + .setContent(config.message) + .setDuration(0) + .alterClassName('jx-notification-read-only', true) + .show(); + +});