toolkit/components/normandy/lib/RecipeRunner.jsm
author Michael Cooper <mcooper@mozilla.com>
Thu, 25 Oct 2018 17:20:25 +0000
changeset 491359 f0ed99b29aafb5fd7f613a000634b53927af4259
parent 482613 d7fcfbc15cfe5e33cce5a12ff009e9b6aec07811
child 491364 d08c28ed42f1bef0b77d38064759b797f377a998
permissions -rw-r--r--
Bug 1498940 - Reflect study opt-out in about:studies when Normandy is generally enabled. r=Gijs Differential Revision: https://phabricator.services.mozilla.com/D9738

/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

"use strict";

ChromeUtils.import("resource://gre/modules/Services.jsm");
ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
ChromeUtils.import("resource://normandy/lib/LogManager.jsm");

XPCOMUtils.defineLazyServiceGetter(this, "timerManager",
                                   "@mozilla.org/updates/timer-manager;1",
                                   "nsIUpdateTimerManager");

XPCOMUtils.defineLazyModuleGetters(this, {
  Storage: "resource://normandy/lib/Storage.jsm",
  FilterExpressions: "resource://gre/modules/components-utils/FilterExpressions.jsm",
  NormandyApi: "resource://normandy/lib/NormandyApi.jsm",
  ClientEnvironment: "resource://normandy/lib/ClientEnvironment.jsm",
  CleanupManager: "resource://normandy/lib/CleanupManager.jsm",
  AddonStudies: "resource://normandy/lib/AddonStudies.jsm",
  Uptake: "resource://normandy/lib/Uptake.jsm",
  ActionsManager: "resource://normandy/lib/ActionsManager.jsm",
});

var EXPORTED_SYMBOLS = ["RecipeRunner"];

const log = LogManager.getLogger("recipe-runner");
const TIMER_NAME = "recipe-client-addon-run";
const PREF_CHANGED_TOPIC = "nsPref:changed";

const TELEMETRY_ENABLED_PREF = "datareporting.healthreport.uploadEnabled";

const PREF_PREFIX = "app.normandy";
const RUN_INTERVAL_PREF = `${PREF_PREFIX}.run_interval_seconds`;
const FIRST_RUN_PREF = `${PREF_PREFIX}.first_run`;
const SHIELD_ENABLED_PREF = `${PREF_PREFIX}.enabled`;
const DEV_MODE_PREF = `${PREF_PREFIX}.dev_mode`;
const API_URL_PREF = `${PREF_PREFIX}.api_url`;
const LAZY_CLASSIFY_PREF = `${PREF_PREFIX}.experiments.lazy_classify`;

const PREFS_TO_WATCH = [
  RUN_INTERVAL_PREF,
  TELEMETRY_ENABLED_PREF,
  SHIELD_ENABLED_PREF,
  API_URL_PREF,
];

/**
 * cacheProxy returns an object Proxy that will memoize properties of the target.
 */
function cacheProxy(target) {
  const cache = new Map();
  return new Proxy(target, {
    get(target, prop, receiver) {
      if (!cache.has(prop)) {
        cache.set(prop, target[prop]);
      }
      return cache.get(prop);
    },
  });
}

var RecipeRunner = {
  async init() {
    this.enabled = null;
    this.checkPrefs(); // sets this.enabled
    this.watchPrefs();

    // Run if enabled immediately on first run, or if dev mode is enabled.
    const firstRun = Services.prefs.getBoolPref(FIRST_RUN_PREF, true);
    const devMode = Services.prefs.getBoolPref(DEV_MODE_PREF, false);

    if (this.enabled && (devMode || firstRun)) {
      await this.run();
    }
    if (firstRun) {
      Services.prefs.setBoolPref(FIRST_RUN_PREF, false);
    }
  },

  enable() {
    if (this.enabled) {
      return;
    }
    this.registerTimer();
    this.enabled = true;
  },

  disable() {
    if (this.enabled) {
      this.unregisterTimer();
    }
    // this.enabled may be null, so always set it to false
    this.enabled = false;
  },

  /** Watch for prefs to change, and call this.observer when they do */
  watchPrefs() {
    for (const pref of PREFS_TO_WATCH) {
      Services.prefs.addObserver(pref, this);
    }

    CleanupManager.addCleanupHandler(this.unwatchPrefs.bind(this));
  },

  unwatchPrefs() {
    for (const pref of PREFS_TO_WATCH) {
      Services.prefs.removeObserver(pref, this);
    }
  },

  /** When prefs change, this is fired */
  observe(subject, topic, data) {
    switch (topic) {
      case PREF_CHANGED_TOPIC: {
        const prefName = data;

        switch (prefName) {
          case RUN_INTERVAL_PREF:
            this.updateRunInterval();
            break;

          // explicit fall-through
          case TELEMETRY_ENABLED_PREF:
          case SHIELD_ENABLED_PREF:
          case API_URL_PREF:
            this.checkPrefs();
            break;

          default:
            log.debug(`Observer fired with unexpected pref change: ${prefName}`);
        }

        break;
      }
    }
  },

  checkPrefs() {
    // Only run if Unified Telemetry is enabled.
    if (!Services.prefs.getBoolPref(TELEMETRY_ENABLED_PREF)) {
      log.debug("Disabling RecipeRunner because Unified Telemetry is disabled.");
      this.disable();
      return;
    }

    if (!Services.prefs.getBoolPref(SHIELD_ENABLED_PREF)) {
      log.debug(`Disabling Shield because ${SHIELD_ENABLED_PREF} is set to false`);
      this.disable();
      return;
    }

    if (!Services.policies.isAllowed("Shield")) {
      log.debug("Disabling Shield because it's blocked by policy.");
      this.disable();
      return;
    }

    const apiUrl = Services.prefs.getCharPref(API_URL_PREF);
    if (!apiUrl) {
      log.warn(`Disabling Shield because ${API_URL_PREF} is not set.`);
      this.disable();
      return;
    }
    if (!apiUrl.startsWith("https://")) {
      log.warn(`Disabling Shield because ${API_URL_PREF} is not an HTTPS url: ${apiUrl}.`);
      this.disable();
      return;
    }

    log.debug(`Enabling Shield`);
    this.enable();
  },

  registerTimer() {
    this.updateRunInterval();
    CleanupManager.addCleanupHandler(() => timerManager.unregisterTimer(TIMER_NAME));
  },

  unregisterTimer() {
    timerManager.unregisterTimer(TIMER_NAME);
  },

  updateRunInterval() {
    // Run once every `runInterval` wall-clock seconds. This is managed by setting a "last ran"
    // timestamp, and running if it is more than `runInterval` seconds ago. Even with very short
    // intervals, the timer will only fire at most once every few minutes.
    const runInterval = Services.prefs.getIntPref(RUN_INTERVAL_PREF);
    timerManager.registerTimer(TIMER_NAME, () => this.run(), runInterval);
  },

  async run() {
    this.clearCaches();
    // Unless lazy classification is enabled, prep the classify cache.
    if (!Services.prefs.getBoolPref(LAZY_CLASSIFY_PREF, false)) {
      try {
        await ClientEnvironment.getClientClassification();
      } catch (err) {
        // Try to go on without this data; the filter expressions will
        // gracefully fail without this info if they need it.
      }
    }

    // Fetch recipes before execution in case we fail and exit early.
    let recipes;
    try {
      recipes = await NormandyApi.fetchRecipes({enabled: true});
      log.debug(
        `Fetched ${recipes.length} recipes from the server: ` +
        recipes.map(r => r.name).join(", ")
      );

    } catch (e) {
      const apiUrl = Services.prefs.getCharPref(API_URL_PREF);
      log.error(`Could not fetch recipes from ${apiUrl}: "${e}"`);

      let status = Uptake.RUNNER_SERVER_ERROR;
      if (/NetworkError/.test(e)) {
        status = Uptake.RUNNER_NETWORK_ERROR;
      } else if (e instanceof NormandyApi.InvalidSignatureError) {
        status = Uptake.RUNNER_INVALID_SIGNATURE;
      }
      Uptake.reportRunner(status);
      return;
    }

    const actions = new ActionsManager();
    await actions.fetchRemoteActions();
    await actions.preExecution();

    // Evaluate recipe filters
    const recipesToRun = [];
    for (const recipe of recipes) {
      if (await this.checkFilter(recipe)) {
        recipesToRun.push(recipe);
      }
    }

    // Execute recipes, if we have any.
    if (recipesToRun.length === 0) {
      log.debug("No recipes to execute");
    } else {
      for (const recipe of recipesToRun) {
        await actions.runRecipe(recipe);
      }
    }

    await actions.finalize();

    // Close storage connections
    await AddonStudies.close();

    Uptake.reportRunner(Uptake.RUNNER_SUCCESS);
  },

  getFilterContext(recipe) {
    const environment = cacheProxy(ClientEnvironment);
    environment.recipe = {
      id: recipe.id,
      arguments: recipe.arguments,
    };
    return {
      normandy: environment,
    };
  },

  /**
   * Evaluate a recipe's filter expression against the environment.
   * @param {object} recipe
   * @param {string} recipe.filter The expression to evaluate against the environment.
   * @return {boolean} The result of evaluating the filter, cast to a bool, or false
   *                   if an error occurred during evaluation.
   */
  async checkFilter(recipe) {
    const context = this.getFilterContext(recipe);
    try {
      const result = await FilterExpressions.eval(recipe.filter_expression, context);
      return !!result;
    } catch (err) {
      log.error(`Error checking filter for "${recipe.name}". Filter: [${recipe.filter_expression}]. Error: "${err}"`);
      return false;
    }
  },

  /**
   * Clear all caches of systems used by RecipeRunner, in preparation
   * for a clean run.
   */
  clearCaches() {
    ClientEnvironment.clearClassifyCache();
    NormandyApi.clearIndexCache();
  },

  /**
   * Clear out cached state and fetch/execute recipes from the given
   * API url. This is used mainly by the mock-recipe-server JS that is
   * executed in the browser console.
   */
  async testRun(baseApiUrl) {
    const oldApiUrl = Services.prefs.getCharPref(API_URL_PREF);
    Services.prefs.setCharPref(API_URL_PREF, baseApiUrl);

    try {
      Storage.clearAllStorage();
      this.clearCaches();
      await this.run();
    } finally {
      Services.prefs.setCharPref(API_URL_PREF, oldApiUrl);
      this.clearCaches();
    }
  },
};