Anforderungen  |   Konzepte  |   Entwurf  |   Entwicklung  |   Qualitätssicherung  |   Lebenszyklus  |   Steuerung
 
 
 
 


Quelle  RecipeRunner.sys.mjs   Sprache: unbekannt

 
/* 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/. */

import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
import { LogManager } from "resource://normandy/lib/LogManager.sys.mjs";

const lazy = {};

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

ChromeUtils.defineESModuleGetters(lazy, {
  ActionsManager: "resource://normandy/lib/ActionsManager.sys.mjs",
  BaseAction: "resource://normandy/actions/BaseAction.sys.mjs",
  CleanupManager: "resource://normandy/lib/CleanupManager.sys.mjs",
  ClientEnvironment: "resource://normandy/lib/ClientEnvironment.sys.mjs",
  FilterExpressions:
    "resource://gre/modules/components-utils/FilterExpressions.sys.mjs",
  LegacyHeartbeat: "resource://normandy/lib/LegacyHeartbeat.sys.mjs",
  Normandy: "resource://normandy/Normandy.sys.mjs",
  NormandyApi: "resource://normandy/lib/NormandyApi.sys.mjs",
  RemoteSettings: "resource://services-settings/remote-settings.sys.mjs",
  RemoteSettingsClient:
    "resource://services-settings/RemoteSettingsClient.sys.mjs",
  Storage: "resource://normandy/lib/Storage.sys.mjs",
  TargetingContext: "resource://messaging-system/targeting/Targeting.sys.mjs",
  Uptake: "resource://normandy/lib/Uptake.sys.mjs",
  clearTimeout: "resource://gre/modules/Timer.sys.mjs",
  setTimeout: "resource://gre/modules/Timer.sys.mjs",
});

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

const RUN_INTERVAL_PREF = "app.normandy.run_interval_seconds";
const FIRST_RUN_PREF = "app.normandy.first_run";
const SHIELD_ENABLED_PREF = "app.normandy.enabled";
const DEV_MODE_PREF = "app.normandy.dev_mode";
const API_URL_PREF = "app.normandy.api_url";
const LAZY_CLASSIFY_PREF = "app.normandy.experiments.lazy_classify";
const ONSYNC_SKEW_SEC_PREF = "app.normandy.onsync_skew_sec";

// Timer last update preference.
// see https://searchfox.org/mozilla-central/rev/11cfa0462/toolkit/components/timermanager/UpdateTimerManager.jsm#8
const TIMER_LAST_UPDATE_PREF = `app.update.lastUpdateTime.${TIMER_NAME}`;

const PREFS_TO_WATCH = [RUN_INTERVAL_PREF, SHIELD_ENABLED_PREF, API_URL_PREF];

ChromeUtils.defineLazyGetter(lazy, "gRemoteSettingsClient", () => {
  return lazy.RemoteSettings(REMOTE_SETTINGS_COLLECTION);
});

/**
 * 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) {
      if (!cache.has(prop)) {
        cache.set(prop, target[prop]);
      }
      return cache.get(prop);
    },
    set(target, prop, value) {
      cache.set(prop, value);
      return true;
    },
    has(target, prop) {
      return cache.has(prop) || prop in target;
    },
  });
}

export var RecipeRunner = {
  initializedPromise: Promise.withResolvers(),

  async init() {
    this.running = false;
    this.enabled = null;
    this.loadFromRemoteSettings = false;
    this._syncSkewTimeout = null;

    this.checkPrefs(); // sets this.enabled
    this.watchPrefs();
    this.setUpRemoteSettings();

    // Here "first run" means the first run this profile has ever done. This
    // preference is set to true at the end of this function, and never reset to
    // false.
    const firstRun = Services.prefs.getBoolPref(FIRST_RUN_PREF, true);

    // If we've seen a build ID from a previous run that doesn't match the
    // current build ID, run immediately. This is probably an upgrade or
    // downgrade, which may cause recipe eligibility to change.
    let hasNewBuildID =
      Services.appinfo.lastAppBuildID != null &&
      Services.appinfo.lastAppBuildID != Services.appinfo.appBuildID;

    // Dev mode is a mode used for development and QA that bypasses the normal
    // timer function of Normandy, to make testing more convenient.
    const devMode = Services.prefs.getBoolPref(DEV_MODE_PREF, false);

    if (this.enabled && (devMode || firstRun || hasNewBuildID)) {
      // In dev mode, if remote settings is enabled, force an immediate sync
      // before running. This ensures that the latest data is used for testing.
      // This is not needed for the first run case, because remote settings
      // already handles empty collections well.
      if (devMode) {
        await lazy.gRemoteSettingsClient.sync();
      }
      let trigger;
      if (devMode) {
        trigger = "devMode";
      } else if (firstRun) {
        trigger = "firstRun";
      } else if (hasNewBuildID) {
        trigger = "newBuildID";
      }

      await this.run({ trigger });
    }

    // Update the firstRun pref, to indicate that Normandy has run at least once
    // on this profile.
    if (firstRun) {
      Services.prefs.setBoolPref(FIRST_RUN_PREF, false);
    }

    this.initializedPromise.resolve();
  },

  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);
    }

    lazy.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 SHIELD_ENABLED_PREF:
          case API_URL_PREF:
            this.checkPrefs();
            break;

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

        break;
      }
    }
  },

  checkPrefs() {
    if (!Services.prefs.getBoolPref(SHIELD_ENABLED_PREF, false)) {
      log.debug(
        `Disabling Shield because ${SHIELD_ENABLED_PREF} is set to false`
      );
      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();
    lazy.CleanupManager.addCleanupHandler(() =>
      lazy.timerManager.unregisterTimer(TIMER_NAME)
    );
  },

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

  setUpRemoteSettings() {
    if (this._alreadySetUpRemoteSettings) {
      return;
    }
    this._alreadySetUpRemoteSettings = true;

    if (!this._onSync) {
      this._onSync = this.onSync.bind(this);
    }
    lazy.gRemoteSettingsClient.on("sync", this._onSync);

    lazy.CleanupManager.addCleanupHandler(() => {
      lazy.gRemoteSettingsClient.off("sync", this._onSync);
      this._alreadySetUpRemoteSettings = false;
    });
  },

  /** Called when our Remote Settings collection is updated */
  async onSync() {
    if (!this.enabled) {
      return;
    }

    // Delay the Normandy run by a random amount, determined by preference.
    // This helps alleviate server load, since we don't have a thundering
    // herd of users trying to update all at once.
    if (this._syncSkewTimeout) {
      lazy.clearTimeout(this._syncSkewTimeout);
    }
    let minSkewSec = 1; // this is primarily is to avoid race conditions in tests
    let maxSkewSec = Services.prefs.getIntPref(ONSYNC_SKEW_SEC_PREF, 0);
    if (maxSkewSec >= minSkewSec) {
      let skewMillis =
        (minSkewSec + Math.random() * (maxSkewSec - minSkewSec)) * 1000;
      log.debug(
        `Delaying on-sync Normandy run for ${Math.floor(
          skewMillis / 1000
        )} seconds`
      );
      this._syncSkewTimeout = lazy.setTimeout(
        () => this.run({ trigger: "sync" }),
        skewMillis
      );
    } else {
      log.debug(`Not skewing on-sync Normandy run`);
      await this.run({ trigger: "sync" });
    }
  },

  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, 21600); // 6h
    lazy.timerManager.registerTimer(TIMER_NAME, () => this.run(), runInterval);
  },

  async run({ trigger = "timer" } = {}) {
    if (this.running) {
      // Do nothing if already running.
      return;
    }
    this.running = true;

    await lazy.Normandy.defaultPrefsHaveBeenApplied.promise;

    try {
      this.running = true;
      Services.obs.notifyObservers(null, "recipe-runner:start");

      if (this._syncSkewTimeout) {
        lazy.clearTimeout(this._syncSkewTimeout);
        this._syncSkewTimeout = null;
      }

      this.clearCaches();
      // Unless lazy classification is enabled, prep the classify cache.
      if (!Services.prefs.getBoolPref(LAZY_CLASSIFY_PREF, false)) {
        try {
          await lazy.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 recipesAndSignatures;
      try {
        recipesAndSignatures = await lazy.gRemoteSettingsClient.get({
          // Do not return an empty list if an error occurs.
          emptyListFallback: false,
        });
      } catch (e) {
        await lazy.Uptake.reportRunner(lazy.Uptake.RUNNER_SERVER_ERROR);
        return;
      }

      const actionsManager = new lazy.ActionsManager();

      const legacyHeartbeat = lazy.LegacyHeartbeat.getHeartbeatRecipe();
      const noRecipes =
        !recipesAndSignatures.length && legacyHeartbeat === null;

      // Execute recipes, if we have any.
      if (noRecipes) {
        log.debug("No recipes to execute");
      } else {
        for (const { recipe, signature } of recipesAndSignatures) {
          let suitability = await this.getRecipeSuitability(recipe, signature);
          await actionsManager.processRecipe(recipe, suitability);
        }

        if (legacyHeartbeat !== null) {
          await actionsManager.processRecipe(
            legacyHeartbeat,
            lazy.BaseAction.suitability.FILTER_MATCH
          );
        }
      }

      await actionsManager.finalize({ noRecipes });

      await lazy.Uptake.reportRunner(lazy.Uptake.RUNNER_SUCCESS);
      Services.obs.notifyObservers(null, "recipe-runner:end");
    } finally {
      this.running = false;
      if (trigger != "timer") {
        // `run()` was executed outside the scheduled timer.
        // Update the last time it ran to make sure it is rescheduled later.
        const lastUpdateTime = Math.round(Date.now() / 1000);
        Services.prefs.setIntPref(TIMER_LAST_UPDATE_PREF, lastUpdateTime);
      }
    }
  },

  getFilterContext(recipe) {
    const environment = cacheProxy(lazy.ClientEnvironment);
    environment.recipe = {
      id: recipe.id,
      arguments: recipe.arguments,
    };
    return {
      env: environment,
      // Backwards compatibility -- see bug 1477255.
      normandy: environment,
    };
  },

  /**
   * Return the set of capabilities this runner has.
   *
   * This is used to pre-filter recipes that aren't compatible with this client.
   *
   * @returns {Set<String>} The capabilities supported by this client.
   */
  getCapabilities() {
    let capabilities = new Set([
      "capabilities-v1", // The initial version of the capabilities system.
    ]);

    // Get capabilities from ActionsManager.
    for (const actionCapability of lazy.ActionsManager.getCapabilities()) {
      capabilities.add(actionCapability);
    }

    // Add a capability for each transform available to JEXL.
    for (const transform of lazy.FilterExpressions.getAvailableTransforms()) {
      capabilities.add(`jexl.transform.${transform}`);
    }

    // Add two capabilities for each top level key available in the context: one
    // for the `normandy.` namespace, and another for the `env.` namespace.
    capabilities.add("jexl.context.env");
    capabilities.add("jexl.context.normandy");
    let env = lazy.ClientEnvironment;
    while (env && env.name) {
      // Walk up the class chain for ClientEnvironment, collecting applicable
      // properties as we go. Stop when we get to an unnamed object, which is
      // usually just a plain function is the super class of a class that doesn't
      // extend anything. Also stop if we get to an undefined object, just in
      // case.
      for (const [name, descriptor] of Object.entries(
        Object.getOwnPropertyDescriptors(env)
      )) {
        // All of the properties we are looking for are are static getters (so
        // will have a truthy `get` property) and are defined on the class, so
        // will be configurable
        if (descriptor.configurable && descriptor.get) {
          capabilities.add(`jexl.context.env.${name}`);
          capabilities.add(`jexl.context.normandy.${name}`);
        }
      }
      // Check for the next parent
      env = Object.getPrototypeOf(env);
    }

    return capabilities;
  },

  /**
   * Decide if a recipe is suitable to run, and returns a value from
   * `BaseAction.suitability`.
   *
   * This checks several things in order:
   *  - recipe signature
   *  - capabilities
   *  - filter expression
   *
   * If the provided signature does not match the provided recipe, then
   * `SIGNATURE_ERROR` is returned. Recipes with this suitability should not be
   * trusted. These recipes are included so that temporary signature errors on
   * the server can be handled intelligently by actions.
   *
   * Capabilities are a simple set of strings in the recipe. If the Normandy
   * client has all of the capabilities listed, then execution continues. If
   * not, then `CAPABILITY_MISMATCH` is returned. Recipes with this suitability
   * should be considered incompatible and treated with caution.
   *
   * If the capabilities check passes, then the filter expression is evaluated
   * against the current environment. The result of the expression is cast to a
   * boolean. If it is true, then `FILTER_MATCH` is returned. If not, then
   * `FILTER_MISMATCH` is returned.
   *
   * If there is an error while evaluating the recipe's filter, `FILTER_ERROR`
   * is returned instead.
   *
   * @param {object} recipe
   * @param {object} signature
   * @param {string} recipe.filter_expression The expression to evaluate against the environment.
   * @param {Set<String>} runnerCapabilities The capabilities provided by this runner.
   * @return {Promise<BaseAction.suitability>} The recipe's suitability
   */
  async getRecipeSuitability(recipe, signature) {
    let generator = this.getAllSuitabilities(recipe, signature);
    // For our purposes, only the first suitability matters, so pull the first
    // value out of the async generator. This additionally guarantees if we fail
    // a security or compatibility check, we won't continue to run other checks,
    // which is good for the general case of running recipes.
    let { value: suitability } = await generator.next();
    switch (suitability) {
      case lazy.BaseAction.suitability.SIGNATURE_ERROR: {
        await lazy.Uptake.reportRecipe(
          recipe,
          lazy.Uptake.RECIPE_INVALID_SIGNATURE
        );
        break;
      }

      case lazy.BaseAction.suitability.CAPABILITIES_MISMATCH: {
        await lazy.Uptake.reportRecipe(
          recipe,
          lazy.Uptake.RECIPE_INCOMPATIBLE_CAPABILITIES
        );
        break;
      }

      case lazy.BaseAction.suitability.FILTER_MATCH: {
        // No telemetry needs to be sent for this right now.
        break;
      }

      case lazy.BaseAction.suitability.FILTER_MISMATCH: {
        // This represents a terminal state for the given recipe, so
        // report its outcome. Others are reported when executed in
        // ActionsManager.
        await lazy.Uptake.reportRecipe(
          recipe,
          lazy.Uptake.RECIPE_DIDNT_MATCH_FILTER
        );
        break;
      }

      case lazy.BaseAction.suitability.FILTER_ERROR: {
        await lazy.Uptake.reportRecipe(
          recipe,
          lazy.Uptake.RECIPE_FILTER_BROKEN
        );
        break;
      }

      case lazy.BaseAction.suitability.ARGUMENTS_INVALID: {
        // This shouldn't ever occur, since the arguments schema is checked by
        // BaseAction itself.
        throw new Error(`Shouldn't get ${suitability} in RecipeRunner`);
      }

      default: {
        throw new Error(`Unexpected recipe suitability ${suitability}`);
      }
    }

    return suitability;
  },

  /**
   * Some uses cases, such as Normandy Devtools, want the status of all
   * suitabilities, not only the most important one. This checks the cases of
   * suitabilities in order from most blocking to least blocking. The first
   * yielded is the "primary" suitability to pass on to actions.
   *
   * If this function yields only [FILTER_MATCH], then the recipe fully matches
   * and should be executed. If any other statuses are yielded, then the recipe
   * should not be executed as normal.
   *
   * This is a generator so that the execution can be halted as needed. For
   * example, after receiving a signature error, a caller can stop advancing
   * the iterator to avoid exposing the browser to unneeded risk.
   */
  async *getAllSuitabilities(recipe, signature) {
    try {
      await lazy.NormandyApi.verifyObjectSignature(recipe, signature, "recipe");
    } catch (e) {
      yield lazy.BaseAction.suitability.SIGNATURE_ERROR;
    }

    const runnerCapabilities = this.getCapabilities();
    if (Array.isArray(recipe.capabilities)) {
      for (const recipeCapability of recipe.capabilities) {
        if (!runnerCapabilities.has(recipeCapability)) {
          log.debug(
            `Recipe "${recipe.name}" requires unknown capabilities. ` +
              `Recipe capabilities: ${JSON.stringify(recipe.capabilities)}. ` +
              `Local runner capabilities: ${JSON.stringify(
                Array.from(runnerCapabilities)
              )}`
          );
          yield lazy.BaseAction.suitability.CAPABILITIES_MISMATCH;
        }
      }
    }

    const context = this.getFilterContext(recipe);
    const targetingContext = new lazy.TargetingContext();
    try {
      if (await targetingContext.eval(recipe.filter_expression, context)) {
        yield lazy.BaseAction.suitability.FILTER_MATCH;
      } else {
        yield lazy.BaseAction.suitability.FILTER_MISMATCH;
      }
    } catch (err) {
      log.error(
        `Error checking filter for "${recipe.name}". Filter: [${recipe.filter_expression}]. Error: "${err}"`
      );
      yield lazy.BaseAction.suitability.FILTER_ERROR;
    }
  },

  /**
   * Clear all caches of systems used by RecipeRunner, in preparation
   * for a clean run.
   */
  clearCaches() {
    lazy.ClientEnvironment.clearClassifyCache();
    lazy.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 {
      lazy.Storage.clearAllStorage();
      this.clearCaches();
      await this.run();
    } finally {
      Services.prefs.setCharPref(API_URL_PREF, oldApiUrl);
      this.clearCaches();
    }
  },

  /**
   * Offer a mechanism to get access to the lazily-instantiated
   * gRemoteSettingsClient, because if someone instantiates it
   * themselves, it won't have the options we provided in this module,
   * and it will prevent instantiation by this module later.
   *
   * This is only meant to be used in testing, where it is a
   * convenient hook to store data in the underlying remote-settings
   * collection.
   */
  get _remoteSettingsClientForTesting() {
    return lazy.gRemoteSettingsClient;
  },

  migrations: {
    /**
     * Delete the now-unused collection of recipes, since we are using the
     * "normandy-recipes-capabilities" collection now.
     */
    async migration01RemoveOldRecipesCollection() {
      // Don't bother to open IDB and clear on clean profiles.
      const lastCheckPref =
        "services.settings.main.normandy-recipes.last_check";
      if (Services.prefs.prefHasUserValue(lastCheckPref)) {
        // We instantiate a client, but it won't take part of sync.
        const client = new lazy.RemoteSettingsClient("normandy-recipes");
        await client.db.clear();
        Services.prefs.clearUserPref(lastCheckPref);
      }
    },
  },
};

[ Dauer der Verarbeitung: 0.4 Sekunden  (vorverarbeitet)  ]

                                                                                                                                                                                                                                                                                                                                                                                                     


Neuigkeiten

     Aktuelles
     Motto des Tages

Software

     Produkte
     Quellcodebibliothek

Aktivitäten

     Artikel über Sicherheit
     Anleitung zur Aktivierung von SSL

Muße

     Gedichte
     Musik
     Bilder

Jenseits des Üblichen ....
    

Besucherstatistik

Besucherstatistik

Monitoring

Montastic status badge