chromium/chrome/browser/ash/crostini/crostini_installer.cc

// Copyright 2019 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

#include "chrome/browser/ash/crostini/crostini_installer.h"

#include <algorithm>
#include <string>

#include "base/functional/bind.h"
#include "base/metrics/histogram_functions.h"
#include "base/metrics/histogram_macros.h"
#include "base/no_destructor.h"
#include "base/system/sys_info.h"
#include "base/task/sequenced_task_runner.h"
#include "base/task/thread_pool.h"
#include "base/time/time.h"
#include "chrome/browser/ash/crostini/ansible/ansible_management_service_factory.h"
#include "chrome/browser/ash/crostini/crostini_disk.h"
#include "chrome/browser/ash/crostini/crostini_features.h"
#include "chrome/browser/ash/crostini/crostini_manager_factory.h"
#include "chrome/browser/ash/crostini/crostini_pref_names.h"
#include "chrome/browser/ash/crostini/crostini_types.mojom.h"
#include "chrome/browser/ash/crostini/crostini_util.h"
#include "chrome/browser/ash/guest_os/guest_os_terminal.h"
#include "chrome/browser/ash/login/startup_utils.h"
#include "chrome/browser/profiles/profile.h"
#include "chrome/browser/profiles/profile_keyed_service_factory.h"
#include "chrome/browser/ui/webui/ash/crostini_installer/crostini_installer_dialog.h"
#include "chromeos/ash/components/dbus/spaced/spaced_client.h"
#include "components/keyed_service/core/keyed_service.h"
#include "components/prefs/pref_service.h"
#include "content/public/browser/browser_context.h"
#include "content/public/browser/browser_task_traits.h"
#include "content/public/browser/browser_thread.h"
#include "content/public/browser/network_service_instance.h"
#include "crostini_util.h"
#include "ui/display/types/display_constants.h"

using crostini::mojom::InstallerError;
using crostini::mojom::InstallerState;

namespace crostini {

namespace {
using SetupResult = CrostiniInstaller::SetupResult;
constexpr char kCrostiniSetupSourceHistogram[] = "Crostini.SetupSource";

class CrostiniInstallerFactory : public ProfileKeyedServiceFactory {
 public:
  static crostini::CrostiniInstaller* GetForProfile(Profile* profile) {
    return static_cast<crostini::CrostiniInstaller*>(
        GetInstance()->GetServiceForBrowserContext(profile, true));
  }

  static CrostiniInstallerFactory* GetInstance() {
    static base::NoDestructor<CrostiniInstallerFactory> factory;
    return factory.get();
  }

 private:
  friend class base::NoDestructor<CrostiniInstallerFactory>;

  CrostiniInstallerFactory()
      : ProfileKeyedServiceFactory(
            "CrostiniInstallerService",
            ProfileSelections::Builder()
                .WithRegular(ProfileSelection::kOriginalOnly)
                // TODO(crbug.com/40257657): Check if this service is needed in
                // Guest mode.
                .WithGuest(ProfileSelection::kOriginalOnly)
                // TODO(crbug.com/41488885): Check if this service is needed for
                // Ash Internals.
                .WithAshInternals(ProfileSelection::kOriginalOnly)
                .Build()) {
    DependsOn(crostini::CrostiniManagerFactory::GetInstance());
  }

  // BrowserContextKeyedServiceFactory:
  KeyedService* BuildServiceInstanceFor(
      content::BrowserContext* context) const override {
    Profile* profile = Profile::FromBrowserContext(context);
    return new crostini::CrostiniInstaller(profile);
  }
};

constexpr int kUninitializedDiskSpace = -1;

constexpr char kCrostiniSetupResultHistogram[] = "Crostini.SetupResult";
constexpr char kCrostiniTimeFromDeviceSetupToInstall[] =
    "Crostini.TimeFromDeviceSetupToInstall";
constexpr char kCrostiniTimeToInstallSuccess[] =
    "Crostini.TimeToInstallSuccess";
constexpr char kCrostiniTimeToInstallCancel[] = "Crostini.TimeToInstallCancel";
constexpr char kCrostiniTimeToInstallError[] = "Crostini.TimeToInstallError";
constexpr char kCrostiniAvailableDiskSuccess[] =
    "Crostini.AvailableDiskSuccess";
constexpr char kCrostiniAvailableDiskCancel[] = "Crostini.AvailableDiskCancel";
constexpr char kCrostiniAvailableDiskError[] = "Crostini.AvailableDiskError";

void RecordTimeFromDeviceSetupToInstallMetric() {
  base::ThreadPool::PostTaskAndReplyWithResult(
      FROM_HERE, {base::MayBlock()},
      base::BindOnce(&ash::StartupUtils::GetTimeSinceOobeFlagFileCreation),
      base::BindOnce([](base::TimeDelta time_from_device_setup) {
        if (time_from_device_setup.is_zero()) {
          return;
        }

        // The magic number 1471228928 is used for legacy reasons and changing
        // it would invalidate already logged data.
        base::UmaHistogramCustomTimes(kCrostiniTimeFromDeviceSetupToInstall,
                                      time_from_device_setup, base::Minutes(1),
                                      base::Milliseconds(1471228928), 50);
      }));
}

SetupResult ErrorToSetupResult(InstallerError error) {
  switch (error) {
    case InstallerError::kNone:
      return SetupResult::kSuccess;
    case InstallerError::kErrorLoadingTermina:
      return SetupResult::kErrorLoadingTermina;
    case InstallerError::kNeedUpdate:
      return SetupResult::kNeedUpdate;
    case InstallerError::kErrorCreatingDiskImage:
      return SetupResult::kErrorCreatingDiskImage;
    case InstallerError::kErrorStartingTermina:
      return SetupResult::kErrorStartingTermina;
    case InstallerError::kErrorStartingLxd:
      return SetupResult::kErrorStartingLxd;
    case InstallerError::kErrorStartingContainer:
      return SetupResult::kErrorStartingContainer;
    case InstallerError::kErrorConfiguringContainer:
      return SetupResult::kErrorConfiguringContainer;
    case InstallerError::kErrorOffline:
      return SetupResult::kErrorOffline;
    case InstallerError::kErrorSettingUpContainer:
      return SetupResult::kErrorSettingUpContainer;
    case InstallerError::kErrorInsufficientDiskSpace:
      return SetupResult::kErrorInsufficientDiskSpace;
    case InstallerError::kErrorCreateContainer:
      return SetupResult::kErrorCreateContainer;
    case InstallerError::kErrorUnknown:
      return SetupResult::kErrorUnknown;
  }

  NOTREACHED_IN_MIGRATION();
}

SetupResult InstallStateToCancelledSetupResult(
    InstallerState installing_state) {
  switch (installing_state) {
    case InstallerState::kStart:
      return SetupResult::kUserCancelledStart;
    case InstallerState::kInstallImageLoader:
      return SetupResult::kUserCancelledInstallImageLoader;
    case InstallerState::kCreateDiskImage:
      return SetupResult::kUserCancelledCreateDiskImage;
    case InstallerState::kStartTerminaVm:
      return SetupResult::kUserCancelledStartTerminaVm;
    case InstallerState::kStartLxd:
      return SetupResult::kUserCancelledStartLxd;
    case InstallerState::kCreateContainer:
      return SetupResult::kUserCancelledCreateContainer;
    case InstallerState::kSetupContainer:
      return SetupResult::kUserCancelledSetupContainer;
    case InstallerState::kStartContainer:
      return SetupResult::kUserCancelledStartContainer;
    case InstallerState::kConfigureContainer:
      return SetupResult::kUserCancelledConfiguringContainer;
  }

  NOTREACHED_IN_MIGRATION();
}

crostini::mojom::InstallerError CrostiniResultToInstallerError(
    crostini::CrostiniResult result,
    InstallerState installer_state) {
  DCHECK_NE(result, CrostiniResult::SUCCESS);

  bool offline = content::GetNetworkConnectionTracker()->IsOffline();
  if (offline) {
    LOG(WARNING)
        << "Crostini installation may have failed due to being offline.";
  }

  switch (installer_state) {
    default:
    case InstallerState::kStart:
      NOTREACHED_IN_MIGRATION();
      return InstallerError::kErrorUnknown;
    case InstallerState::kInstallImageLoader:
      if (offline) {
        return InstallerError::kErrorOffline;
      } else if (result == CrostiniResult::NEED_UPDATE) {
        return InstallerError::kNeedUpdate;
      } else {
        return InstallerError::kErrorLoadingTermina;
      }
    case InstallerState::kCreateDiskImage:
      return InstallerError::kErrorCreatingDiskImage;
    case InstallerState::kStartTerminaVm:
      return InstallerError::kErrorStartingTermina;
    case InstallerState::kStartLxd:
      return InstallerError::kErrorStartingLxd;
    case InstallerState::kCreateContainer:
      if (offline) {
        return InstallerError::kErrorOffline;
      } else {
        return InstallerError::kErrorCreateContainer;
      }
    case InstallerState::kSetupContainer:
      if (offline) {
        return InstallerError::kErrorOffline;
      } else {
        return InstallerError::kErrorSettingUpContainer;
      }
    case InstallerState::kStartContainer:
      return InstallerError::kErrorStartingContainer;
    case InstallerState::kConfigureContainer:
      return InstallerError::kErrorConfiguringContainer;
  }
}

}  // namespace

CrostiniInstaller* CrostiniInstaller::GetForProfile(Profile* profile) {
  return CrostiniInstallerFactory::GetForProfile(profile);
}

CrostiniInstaller::CrostiniInstaller(Profile* profile) : profile_(profile) {}

CrostiniInstaller::~CrostiniInstaller() {
  // Guaranteed by |Shutdown()|.
  DCHECK_EQ(restart_id_, CrostiniManager::kUninitializedRestartId);
}

void CrostiniInstaller::Shutdown() {
  if (restart_id_ != CrostiniManager::kUninitializedRestartId) {
    CrostiniManager::GetForProfile(profile_)->CancelRestartCrostini(
        restart_id_);
    restart_id_ = CrostiniManager::kUninitializedRestartId;
  }
}

void CrostiniInstaller::ShowDialog(CrostiniUISurface ui_surface) {
  // Defensive check to prevent showing the installer when crostini is not
  // allowed.
  if (!CrostiniFeatures::Get()->IsAllowedNow(profile_)) {
    return;
  }
  base::UmaHistogramEnumeration(kCrostiniSetupSourceHistogram, ui_surface,
                                crostini::CrostiniUISurface::kCount);

  // TODO(lxj): We should pass the dialog |this| here instead of letting the
  // webui to call |GetForProfile()| later.
  ash::CrostiniInstallerDialog::Show(profile_);
}

void CrostiniInstaller::Install(CrostiniManager::RestartOptions options,
                                ProgressCallback progress_callback,
                                ResultCallback result_callback) {
  DCHECK_CURRENTLY_ON(content::BrowserThread::UI);

  if (!CanInstall()) {
    LOG(ERROR)
        << "Tried to start crostini installation in invalid state. state_="
        << static_cast<int>(state_);
    return;
  }

  restart_options_ = std::move(options);
  restart_options_.restart_source = RestartSource::kInstaller;
  progress_callback_ = std::move(progress_callback);
  result_callback_ = std::move(result_callback);

  // Check if there's additional setup required in the case of enterprise
  // specifying an Ansible playbook to be run for a pre-determined configuration
  // on the container.
  if (ShouldConfigureDefaultContainer(profile_)) {
    restart_options_.ansible_playbook = profile_->GetPrefs()->GetFilePath(
        prefs::kCrostiniAnsiblePlaybookFilePath);
  }

  install_start_time_ = base::TimeTicks::Now();
  require_cleanup_ = true;
  free_disk_space_ = kUninitializedDiskSpace;
  container_download_percent_ = 0;
  UpdateState(State::INSTALLING);

  // The spaced D-Bus client needs to be called from the ui thread
  base::SequencedTaskRunner::GetCurrentDefault()->PostTask(
      FROM_HERE,
      base::BindOnce(&ash::SpacedClient::GetFreeDiskSpace,
                     base::Unretained(ash::SpacedClient::Get()),
                     crostini::kHomeDirectory,
                     base::BindOnce(&CrostiniInstaller::OnAvailableDiskSpace,
                                    weak_ptr_factory_.GetWeakPtr())));

  // Reset mic permissions, we don't want it to persist across
  // re-installation.
  profile_->GetPrefs()->SetBoolean(prefs::kCrostiniMicAllowed, false);
}

void CrostiniInstaller::Cancel(base::OnceClosure callback) {
  DCHECK_CURRENTLY_ON(content::BrowserThread::UI);

  if (state_ != State::INSTALLING) {
    LOG(ERROR) << "Tried to cancel in non-cancelable state. state_="
               << static_cast<int>(state_);
    return;
  }

  UMA_HISTOGRAM_LONG_TIMES(kCrostiniTimeToInstallCancel,
                           base::TimeTicks::Now() - install_start_time_);

  result_callback_.Reset();
  progress_callback_.Reset();
  cancel_callback_ = std::move(callback);

  if (installing_state_ == InstallerState::kStart) {
    // We have not called |RestartCrostini()| yet.
    DCHECK_EQ(restart_id_, CrostiniManager::kUninitializedRestartId);
    // OnAvailableDiskSpace() will take care of |cancel_callback_|.
    UpdateState(State::CANCEL_ABORT_CHECK_DISK);
    return;
  }

  DCHECK_NE(restart_id_, CrostiniManager::kUninitializedRestartId);

  if (free_disk_space_ != kUninitializedDiskSpace) {
    base::UmaHistogramCounts1M(kCrostiniAvailableDiskCancel,
                               free_disk_space_ >> 20);
  }

  // Abort the long-running flow, and RestartObserver methods will not be called
  // again until next installation.
  auto* crostini_manager = crostini::CrostiniManager::GetForProfile(profile_);
  crostini_manager->CancelRestartCrostini(restart_id_);
  restart_id_ = CrostiniManager::kUninitializedRestartId;
  RecordSetupResult(InstallStateToCancelledSetupResult(installing_state_));

  if (require_cleanup_) {
    // Remove anything that got installed
    content::GetUIThreadTaskRunner({})->PostTask(
        FROM_HERE,
        base::BindOnce(&crostini::CrostiniManager::RemoveCrostini,
                       crostini_manager->GetWeakPtr(),
                       crostini::kCrostiniDefaultVmName,
                       base::BindOnce(&CrostiniInstaller::FinishCleanup,
                                      weak_ptr_factory_.GetWeakPtr())));
    UpdateState(State::CANCEL_CLEANUP);
  } else {
    content::GetUIThreadTaskRunner({})->PostTask(FROM_HERE,
                                                 std::move(cancel_callback_));
    UpdateState(State::IDLE);
  }
}

void CrostiniInstaller::CancelBeforeStart() {
  DCHECK_CURRENTLY_ON(content::BrowserThread::UI);

  if (!CanInstall()) {
    LOG(ERROR) << "Not in pre-install state. state_="
               << static_cast<int>(state_);
    return;
  }
  RecordSetupResult(SetupResult::kNotStarted);
}

void CrostiniInstaller::OnStageStarted(InstallerState stage) {
  if (stage == InstallerState::kStart ||
      stage == InstallerState::kInstallImageLoader) {
    // Drop these as we manually set our internal state to kInstallImageLoader
    // upon starting the restart.
    return;
  }

  UpdateInstallingState(stage);
}

void CrostiniInstaller::OnDiskImageCreated(bool success,
                                           CrostiniResult result,
                                           int64_t disk_size_available) {
  if (result == CrostiniResult::CREATE_DISK_IMAGE_ALREADY_EXISTS) {
    require_cleanup_ = false;
  }
}

void CrostiniInstaller::OnContainerDownloading(int32_t download_percent) {
  container_download_percent_ = std::clamp(download_percent, 0, 100);
  RunProgressCallback();
}

bool CrostiniInstaller::CanInstall() {
  // Allow to start from State::ERROR. In that case, we're doing a Retry.
  return state_ == State::IDLE || state_ == State::ERROR;
}

void CrostiniInstaller::RunProgressCallback() {
  DCHECK_CURRENTLY_ON(content::BrowserThread::UI);
  DCHECK_EQ(state_, State::INSTALLING);

  base::TimeDelta time_in_state =
      base::Time::Now() - installing_state_start_time_;

  double state_start_mark = 0;
  double state_end_mark = 0;
  auto state_max_time = base::Seconds(1);

  switch (installing_state_) {
    case InstallerState::kStart:
      state_start_mark = 0;
      state_end_mark = 0;
      break;
    case InstallerState::kInstallImageLoader:
      state_start_mark = 0.0;
      state_end_mark = 0.20;
      state_max_time = base::Seconds(30);
      break;
    case InstallerState::kCreateDiskImage:
      state_start_mark = 0.20;
      state_end_mark = 0.22;
      break;
    case InstallerState::kStartTerminaVm:
      state_start_mark = 0.22;
      state_end_mark = 0.28;
      state_max_time = base::Seconds(8);
      break;
    case InstallerState::kStartLxd:
      state_start_mark = 0.28;
      state_end_mark = 0.30;
      state_max_time = base::Seconds(2);
      break;
    case InstallerState::kCreateContainer:
      state_start_mark = 0.30;
      state_end_mark = 0.72;
      state_max_time = base::Seconds(180);
      break;
    case InstallerState::kSetupContainer:
      state_start_mark = 0.72;
      state_end_mark = 0.76;
      state_max_time = base::Seconds(8);
      break;
    case InstallerState::kStartContainer:
      state_start_mark = 0.76;
      state_end_mark = 0.79;
      state_max_time = base::Seconds(8);
      break;
    case InstallerState::kConfigureContainer:
      state_start_mark = 0.79;
      state_end_mark = 1;
      // Ansible installation and playbook application.
      state_max_time = base::Seconds(140 + 300);
      break;
    default:
      NOTREACHED_IN_MIGRATION();
  }

  double state_fraction = time_in_state / state_max_time;

  if (installing_state_ == InstallerState::kCreateContainer) {
    // In CREATE_CONTAINER, consume half the progress bar with downloading,
    // the rest with time.
    state_fraction =
        0.5 * (state_fraction + 0.01 * container_download_percent_);
  }
  // TODO(crbug.com/40645509): Calculate configure container step
  // progress based on real progress.

  double progress = state_start_mark + std::clamp(state_fraction, 0.0, 1.0) *
                                           (state_end_mark - state_start_mark);
  progress_callback_.Run(installing_state_, progress);
}

void CrostiniInstaller::UpdateState(State new_state) {
  DCHECK_CURRENTLY_ON(content::BrowserThread::UI);
  DCHECK_NE(state_, new_state);

  state_ = new_state;
  if (state_ == State::INSTALLING) {
    // We are not calling the progress callback here because 1) there is nothing
    // interesting to report; 2) We reach here from |Install()|, so calling the
    // callback risk reentering |Install()|'s caller.
    UpdateInstallingState(InstallerState::kStart,
                          /*run_callback=*/false);

    state_progress_timer_.Start(
        FROM_HERE, base::Milliseconds(500),
        base::BindRepeating(&CrostiniInstaller::RunProgressCallback,
                            weak_ptr_factory_.GetWeakPtr()));
  } else {
    state_progress_timer_.AbandonAndStop();
  }
}

void CrostiniInstaller::UpdateInstallingState(
    InstallerState new_installing_state,
    bool run_callback) {
  DCHECK_EQ(state_, State::INSTALLING);
  installing_state_start_time_ = base::Time::Now();
  installing_state_ = new_installing_state;

  if (run_callback) {
    RunProgressCallback();
  }
}

void CrostiniInstaller::HandleError(InstallerError error) {
  DCHECK_EQ(state_, State::INSTALLING);
  DCHECK_NE(error, InstallerError::kNone);

  UMA_HISTOGRAM_LONG_TIMES(kCrostiniTimeToInstallError,
                           base::TimeTicks::Now() - install_start_time_);
  if (free_disk_space_ != kUninitializedDiskSpace) {
    base::UmaHistogramCounts1M(kCrostiniAvailableDiskError,
                               free_disk_space_ >> 20);
  }

  RecordSetupResult(ErrorToSetupResult(error));

  // |restart_id_| is reset in |OnCrostiniRestartFinished()|.
  UpdateState(State::ERROR);

  std::move(result_callback_).Run(error);
  progress_callback_.Reset();
}

void CrostiniInstaller::FinishCleanup(crostini::CrostiniResult result) {
  if (result != CrostiniResult::SUCCESS) {
    LOG(ERROR) << "Failed to cleanup aborted crostini install";
  }
  UpdateState(State::IDLE);
  std::move(cancel_callback_).Run();
}

void CrostiniInstaller::RecordSetupResult(SetupResult result) {
  base::UmaHistogramEnumeration(kCrostiniSetupResultHistogram, result);
}

void CrostiniInstaller::OnCrostiniRestartFinished(CrostiniResult result) {
  DCHECK_CURRENTLY_ON(content::BrowserThread::UI);
  restart_id_ = CrostiniManager::kUninitializedRestartId;

  if (result == CrostiniResult::RESTART_ABORTED ||
      result == CrostiniResult::RESTART_REQUEST_CANCELLED) {
    return;
  }

  if (result != CrostiniResult::SUCCESS) {
    DCHECK_EQ(state_, State::INSTALLING);
    HandleError(CrostiniResultToInstallerError(result, installing_state_));
    return;
  }

  // Reset state to allow |Install()| again in case the user remove and
  // re-install crostini.
  UpdateState(State::IDLE);

  RecordSetupResult(SetupResult::kSuccess);
  crostini::CrostiniManager::GetForProfile(profile_)
      ->UpdateLaunchMetricsForEnterpriseReporting();
  RecordTimeFromDeviceSetupToInstallMetric();
  UMA_HISTOGRAM_LONG_TIMES(kCrostiniTimeToInstallSuccess,
                           base::TimeTicks::Now() - install_start_time_);
  if (free_disk_space_ != kUninitializedDiskSpace) {
    base::UmaHistogramCounts1M(kCrostiniAvailableDiskSuccess,
                               free_disk_space_ >> 20);
  }

  std::move(result_callback_).Run(InstallerError::kNone);
  progress_callback_.Reset();

  if (!skip_launching_terminal_for_testing_) {
    // kInvalidDisplayId will launch terminal on the current active display.
    guest_os::LaunchTerminal(profile_, display::kInvalidDisplayId,
                             crostini::DefaultContainerId());
  }
}

void CrostiniInstaller::OnAvailableDiskSpace(std::optional<int64_t> bytes) {
  DCHECK_CURRENTLY_ON(content::BrowserThread::UI);

  // |Cancel()| might be called immediately after |Install()|.
  if (state_ == State::CANCEL_ABORT_CHECK_DISK) {
    UpdateState(State::IDLE);
    RecordSetupResult(SetupResult::kNotStarted);
    std::move(cancel_callback_).Run();
    return;
  }

  DCHECK_EQ(installing_state_, InstallerState::kStart);

  if (bytes.has_value()) {
    free_disk_space_ = bytes.value();
  }
  // Don't enforce minimum disk size on dev box or trybots because
  // base::SysInfo::AmountOfFreeDiskSpace returns zero in testing.
  if (base::SysInfo::IsRunningOnChromeOS() &&
      free_disk_space_ < restart_options_.disk_size_bytes.value_or(
                             crostini::disk::kDiskHeadroomBytes +
                             crostini::disk::kMinimumDiskSizeBytes)) {
    HandleError(InstallerError::kErrorInsufficientDiskSpace);
    return;
  }

  if (content::GetNetworkConnectionTracker()->IsOffline()) {
    HandleError(InstallerError::kErrorOffline);
    return;
  }

  UpdateInstallingState(InstallerState::kInstallImageLoader);

  // Kick off the Crostini Restart sequence. We will be added as an observer.
  restart_id_ =
      crostini::CrostiniManager::GetForProfile(profile_)
          ->RestartCrostiniWithOptions(
              crostini::DefaultContainerId(), std::move(restart_options_),
              base::BindOnce(&CrostiniInstaller::OnCrostiniRestartFinished,
                             weak_ptr_factory_.GetWeakPtr()),
              this);

  // |restart_id| will be invalid when |CrostiniManager::RestartCrostini()|
  // decides to fail immediately and calls |OnCrostiniRestartFinished()|, which
  // subsequently set |state_| to |ERROR|.
  DCHECK_EQ(restart_id_ == CrostiniManager::kUninitializedRestartId,
            state_ == State::ERROR);
}

// static
void CrostiniInstaller::EnsureFactoryBuilt() {
  CrostiniInstallerFactory::GetInstance();
}

}  // namespace crostini