summaryrefslogtreecommitdiffstats
path: root/toolkit/components/normandy/lib/RecipeRunner.sys.mjs
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/components/normandy/lib/RecipeRunner.sys.mjs')
-rw-r--r--toolkit/components/normandy/lib/RecipeRunner.sys.mjs645
1 files changed, 645 insertions, 0 deletions
diff --git a/toolkit/components/normandy/lib/RecipeRunner.sys.mjs b/toolkit/components/normandy/lib/RecipeRunner.sys.mjs
new file mode 100644
index 0000000000..ac55328a01
--- /dev/null
+++ b/toolkit/components/normandy/lib/RecipeRunner.sys.mjs
@@ -0,0 +1,645 @@
+/* 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";
+import { PromiseUtils } from "resource://gre/modules/PromiseUtils.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];
+
+XPCOMUtils.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, receiver) {
+ if (!cache.has(prop)) {
+ cache.set(prop, target[prop]);
+ }
+ return cache.get(prop);
+ },
+ set(target, prop, value, receiver) {
+ cache.set(prop, value);
+ return true;
+ },
+ has(target, prop) {
+ return cache.has(prop) || prop in target;
+ },
+ });
+}
+
+export var RecipeRunner = {
+ initializedPromise: PromiseUtils.defer(),
+
+ 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)) {
+ 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);
+ 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);
+ }
+ },
+ },
+};