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 @@ -1,247 +1,247 @@ getViewer(); $name = $request->getURIData('name'); $device = id(new AlmanacDeviceQuery()) ->setViewer($viewer) ->withNames(array($name)) ->needProperties(true) ->executeOne(); if (!$device) { return new Aphront404Response(); } $title = pht('Device %s', $device->getName()); $curtain = $this->buildCurtain($device); $header = id(new PHUIHeaderView()) ->setUser($viewer) ->setHeader($device->getName()) ->setPolicyObject($device) ->setHeaderIcon('fa-server'); $issue = null; if ($device->isClusterDevice()) { $issue = $this->addClusterMessage( pht('This device is bound to a cluster service.'), pht( 'This device is bound to a cluster service. You do not have '. 'permission to manage cluster services, so the device can not '. 'be edited.')); } $interfaces = $this->buildInterfaceList($device); $crumbs = $this->buildApplicationCrumbs(); $crumbs->addTextCrumb($device->getName()); $crumbs->setBorder(true); $timeline = $this->buildTransactionTimeline( $device, new AlmanacDeviceTransactionQuery()); $timeline->setShouldTerminate(true); $view = id(new PHUITwoColumnView()) ->setHeader($header) ->setCurtain($curtain) ->setMainColumn(array( $issue, $interfaces, $this->buildAlmanacPropertiesTable($device), $this->buildSSHKeysTable($device), $this->buildServicesTable($device), $timeline, )); return $this->newPage() ->setTitle($title) ->setCrumbs($crumbs) ->appendChild( array( $view, )); } private function buildCurtain(AlmanacDevice $device) { $viewer = $this->getViewer(); $can_edit = PhabricatorPolicyFilter::hasCapability( $viewer, $device, PhabricatorPolicyCapability::CAN_EDIT); $id = $device->getID(); $edit_uri = $this->getApplicationURI("device/edit/{$id}/"); $curtain = $this->newCurtainView($device); $curtain->addAction( id(new PhabricatorActionView()) ->setIcon('fa-pencil') ->setName(pht('Edit Device')) ->setHref($edit_uri) ->setWorkflow(!$can_edit) ->setDisabled(!$can_edit)); return $curtain; } private function buildInterfaceList(AlmanacDevice $device) { $viewer = $this->getViewer(); $id = $device->getID(); $can_edit = PhabricatorPolicyFilter::hasCapability( $viewer, $device, PhabricatorPolicyCapability::CAN_EDIT); $interfaces = id(new AlmanacInterfaceQuery()) ->setViewer($viewer) ->withDevicePHIDs(array($device->getPHID())) ->execute(); $table = id(new AlmanacInterfaceTableView()) ->setUser($viewer) ->setInterfaces($interfaces) ->setCanEdit($can_edit); $header = id(new PHUIHeaderView()) - ->setHeader(pht('DEVICE INTERFACES')) + ->setHeader(pht('Device Interfaces')) ->addActionLink( id(new PHUIButtonView()) ->setTag('a') ->setHref($this->getApplicationURI("interface/edit/?deviceID={$id}")) ->setWorkflow(!$can_edit) ->setDisabled(!$can_edit) ->setText(pht('Add Interface')) ->setIcon('fa-plus')); return id(new PHUIObjectBoxView()) ->setHeader($header) ->setBackground(PHUIObjectBoxView::BLUE_PROPERTY) ->setTable($table); } private function buildSSHKeysTable(AlmanacDevice $device) { $viewer = $this->getViewer(); $id = $device->getID(); $device_phid = $device->getPHID(); $can_edit = PhabricatorPolicyFilter::hasCapability( $viewer, $device, PhabricatorPolicyCapability::CAN_EDIT); $keys = id(new PhabricatorAuthSSHKeyQuery()) ->setViewer($viewer) ->withObjectPHIDs(array($device_phid)) ->execute(); $table = id(new PhabricatorAuthSSHKeyTableView()) ->setUser($viewer) ->setKeys($keys) ->setCanEdit($can_edit) ->setShowID(true) ->setShowTrusted(true) ->setNoDataString(pht('This device has no associated SSH public keys.')); try { PhabricatorSSHKeyGenerator::assertCanGenerateKeypair(); $can_generate = true; } catch (Exception $ex) { $can_generate = false; } $generate_uri = '/auth/sshkey/generate/?objectPHID='.$device_phid; $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') ->setHref($generate_uri) ->setWorkflow(true) ->setDisabled(!$can_edit || !$can_generate) ->setText(pht('Generate Keypair')) ->setIcon( id(new PHUIIconView()) ->setIcon('fa-lock'))) ->addActionLink( id(new PHUIButtonView()) ->setTag('a') ->setHref($upload_uri) ->setWorkflow(true) ->setDisabled(!$can_edit) ->setText(pht('Upload Public Key')) ->setIcon( id(new PHUIIconView()) ->setIcon('fa-upload'))); return id(new PHUIObjectBoxView()) ->setHeader($header) ->setBackground(PHUIObjectBoxView::BLUE_PROPERTY) ->setTable($table); } private function buildServicesTable(AlmanacDevice $device) { $viewer = $this->getViewer(); // NOTE: We're loading all services so we can show hidden, locked services. // In general, we let you know about all the things the device is bound to, // even if you don't have permission to see their details. This is similar // to exposing the existence of edges in other applications, with the // addition of always letting you see that locks exist. $services = id(new AlmanacServiceQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->withDevicePHIDs(array($device->getPHID())) ->execute(); $handles = $viewer->loadHandles(mpull($services, 'getPHID')); $icon_cluster = id(new PHUIIconView()) ->setIcon('fa-sitemap'); $rows = array(); foreach ($services as $service) { $rows[] = array( ($service->isClusterService() ? $icon_cluster : null), $handles->renderHandle($service->getPHID()), ); } $table = id(new AphrontTableView($rows)) ->setNoDataString(pht('No services are bound to this device.')) ->setHeaders( array( null, pht('Service'), )) ->setColumnClasses( array( null, 'wide pri', )); 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 @@ -1,22 +1,51 @@ 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/repository/daemon/PhabricatorRepositoryPullLocalDaemon.php b/src/applications/repository/daemon/PhabricatorRepositoryPullLocalDaemon.php index 21b0e13413..1eb3eda929 100644 --- a/src/applications/repository/daemon/PhabricatorRepositoryPullLocalDaemon.php +++ b/src/applications/repository/daemon/PhabricatorRepositoryPullLocalDaemon.php @@ -1,445 +1,552 @@ getArgv(); array_unshift($argv, __CLASS__); $args = new PhutilArgumentParser($argv); $args->parse( array( array( 'name' => 'no-discovery', 'help' => pht('Pull only, without discovering commits.'), ), array( 'name' => 'not', 'param' => 'repository', 'repeat' => true, 'help' => pht('Do not pull __repository__.'), ), array( 'name' => 'repositories', 'wildcard' => true, 'help' => pht('Pull specific __repositories__ instead of all.'), ), )); $no_discovery = $args->getArg('no-discovery'); $include = $args->getArg('repositories'); $exclude = $args->getArg('not'); // Each repository has an individual pull frequency; after we pull it, // wait that long to pull it again. When we start up, try to pull everything // serially. $retry_after = array(); $min_sleep = 15; $max_futures = 4; $futures = array(); $queue = array(); 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. $need_update_messages = $this->loadRepositoryUpdateMessages(true); foreach ($need_update_messages as $message) { $repo = idx($pullable, $message->getRepositoryID()); if (!$repo) { continue; } $this->log( pht( 'Got an update message for repository "%s"!', $repo->getMonogram())); $retry_after[$message->getRepositoryID()] = time(); } // If any repositories were deleted, remove them from the retry timer map // so we don't end up with a retry timer that never gets updated and // causes us to sleep for the minimum amount of time. $retry_after = array_select_keys( $retry_after, array_keys($pullable)); // Figure out which repositories we need to queue for an update. foreach ($pullable as $id => $repository) { $monogram = $repository->getMonogram(); if (isset($futures[$id])) { $this->log(pht('Repository "%s" is currently updating.', $monogram)); continue; } if (isset($queue[$id])) { $this->log(pht('Repository "%s" is already queued.', $monogram)); continue; } $after = idx($retry_after, $id, 0); if ($after > time()) { $this->log( pht( 'Repository "%s" is not due for an update for %s second(s).', $monogram, new PhutilNumber($after - time()))); continue; } if (!$after) { $this->log( pht( 'Scheduling repository "%s" for an initial update.', $monogram)); } else { $this->log( pht( 'Scheduling repository "%s" for an update (%s seconds overdue).', $monogram, new PhutilNumber(time() - $after))); } $queue[$id] = $after; } // Process repositories in the order they became candidates for updates. asort($queue); // Dequeue repositories until we hit maximum parallelism. while ($queue && (count($futures) < $max_futures)) { foreach ($queue as $id => $time) { $repository = idx($pullable, $id); if (!$repository) { $this->log( pht('Repository %s is no longer pullable; skipping.', $id)); unset($queue[$id]); continue; } $monogram = $repository->getMonogram(); $this->log(pht('Starting update for repository "%s".', $monogram)); unset($queue[$id]); $futures[$id] = $this->buildUpdateFuture( $repository, $no_discovery); break; } } if ($queue) { $this->log( pht( 'Not enough process slots to schedule the other %s '. 'repository(s) for updates yet.', phutil_count($queue))); } if ($futures) { $iterator = id(new FutureIterator($futures)) ->setUpdateInterval($min_sleep); foreach ($iterator as $id => $future) { $this->stillWorking(); if ($future === null) { $this->log(pht('Waiting for updates to complete...')); $this->stillWorking(); if ($this->loadRepositoryUpdateMessages()) { $this->log(pht('Interrupted by pending updates!')); break; } continue; } unset($futures[$id]); $retry_after[$id] = $this->resolveUpdateFuture( $pullable[$id], $future, $min_sleep); // We have a free slot now, so go try to fill it. break; } // Jump back into prioritization if we had any futures to deal with. continue; } $this->waitForUpdates($min_sleep, $retry_after); } } /** * @task pull */ private function buildUpdateFuture( PhabricatorRepository $repository, $no_discovery) { $bin = dirname(phutil_get_library_root('phabricator')).'/bin/repository'; $flags = array(); if ($no_discovery) { $flags[] = '--no-discovery'; } $monogram = $repository->getMonogram(); $future = new ExecFuture('%s update %Ls -- %s', $bin, $flags, $monogram); // Sometimes, the underlying VCS commands will hang indefinitely. We've // observed this occasionally with GitHub, and other users have observed // it with other VCS servers. // To limit the damage this can cause, kill the update out after a // reasonable amount of time, under the assumption that it has hung. // Since it's hard to know what a "reasonable" amount of time is given that // users may be downloading a repository full of pirated movies over a // potato, these limits are fairly generous. Repositories exceeding these // limits can be manually pulled with `bin/repository update X`, which can // just run for as long as it wants. if ($repository->isImporting()) { $timeout = phutil_units('4 hours in seconds'); } else { $timeout = phutil_units('15 minutes in seconds'); } $future->setTimeout($timeout); return $future; } /** * Check for repositories that should be updated immediately. * * With the `$consume` flag, an internal cursor will also be incremented so * that these messages are not returned by subsequent calls. * * @param bool Pass `true` to consume these messages, so the process will * not see them again. * @return list Pending update messages. * * @task pull */ private function loadRepositoryUpdateMessages($consume = false) { $type_need_update = PhabricatorRepositoryStatusMessage::TYPE_NEEDS_UPDATE; $messages = id(new PhabricatorRepositoryStatusMessage())->loadAllWhere( 'statusType = %s AND id > %d', $type_need_update, $this->statusMessageCursor); // Keep track of messages we've seen so that we don't load them again. // If we reload messages, we can get stuck a loop if we have a failing // repository: we update immediately in response to the message, but do // not clear the message because the update does not succeed. We then // immediately retry. Instead, messages are only permitted to trigger // an immediate update once. if ($consume) { foreach ($messages as $message) { $this->statusMessageCursor = max( $this->statusMessageCursor, $message->getID()); } } return $messages; } /** * @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()); if ($include) { $query->withIdentifiers($include); } $repositories = $query->execute(); $repositories = mpull($repositories, null, 'getPHID'); if ($include) { $map = $query->getIdentifierMap(); foreach ($include as $identifier) { if (empty($map[$identifier])) { throw new Exception( pht( 'No repository "%s" exists!', $identifier)); } } } if ($exclude) { $xquery = id(new PhabricatorRepositoryQuery()) ->setViewer($this->getViewer()) ->withIdentifiers($exclude); $excluded_repos = $xquery->execute(); $xmap = $xquery->getIdentifierMap(); foreach ($exclude as $identifier) { if (empty($xmap[$identifier])) { throw new Exception( pht( 'No repository "%s" exists!', $identifier)); } } foreach ($excluded_repos as $excluded_repo) { unset($repositories[$excluded_repo->getPHID()]); } } foreach ($repositories as $key => $repository) { if (!$repository->isTracked()) { unset($repositories[$key]); } } + $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) + ->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 = mpull($bindings, null, 'getDevicePHID'); + $binding = idx($bindings, $device_phid); + if (!$binding) { + $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; + } + + if ($binding->getIsDisabled()) { + $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. shuffle($repositories); $repositories = mpull($repositories, null, 'getID'); return $repositories; } /** * @task pull */ private function resolveUpdateFuture( PhabricatorRepository $repository, ExecFuture $future, $min_sleep) { $monogram = $repository->getMonogram(); $this->log(pht('Resolving update for "%s".', $monogram)); try { list($stdout, $stderr) = $future->resolvex(); } catch (Exception $ex) { $proxy = new PhutilProxyException( pht( 'Error while updating the "%s" repository.', $repository->getMonogram()), $ex); phlog($proxy); return time() + $min_sleep; } if (strlen($stderr)) { $stderr_msg = pht( 'Unexpected output while updating repository "%s": %s', $monogram, $stderr); phlog($stderr_msg); } $smart_wait = $repository->loadUpdateInterval($min_sleep); $this->log( pht( 'Based on activity in repository "%s", considering a wait of %s '. 'seconds before update.', $repository->getMonogram(), new PhutilNumber($smart_wait))); return time() + $smart_wait; } /** * Sleep for a short period of time, waiting for update messages from the * * * @task pull */ private function waitForUpdates($min_sleep, array $retry_after) { $this->log( pht('No repositories need updates right now, sleeping...')); $sleep_until = time() + $min_sleep; if ($retry_after) { $sleep_until = min($sleep_until, min($retry_after)); } while (($sleep_until - time()) > 0) { $sleep_duration = ($sleep_until - time()); $this->log( pht( 'Sleeping for %s more second(s)...', new PhutilNumber($sleep_duration))); $this->sleep(1); if ($this->shouldExit()) { $this->log(pht('Awakened from sleep by graceful shutdown!')); return; } if ($this->loadRepositoryUpdateMessages()) { $this->log(pht('Awakened from sleep by pending updates!')); break; } } } }