Files
tubestation/browser/extensions/shield-recipe-client/lib/RecipeRunner.jsm
2017-04-27 12:30:07 -07:00

259 lines
9.1 KiB
JavaScript

/* 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";
const {utils: Cu} = Components;
Cu.import("resource://gre/modules/Services.jsm");
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
Cu.import("resource://shield-recipe-client/lib/LogManager.jsm");
XPCOMUtils.defineLazyServiceGetter(this, "timerManager",
"@mozilla.org/updates/timer-manager;1",
"nsIUpdateTimerManager");
XPCOMUtils.defineLazyModuleGetter(this, "Preferences", "resource://gre/modules/Preferences.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "Storage",
"resource://shield-recipe-client/lib/Storage.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "NormandyDriver",
"resource://shield-recipe-client/lib/NormandyDriver.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "FilterExpressions",
"resource://shield-recipe-client/lib/FilterExpressions.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "NormandyApi",
"resource://shield-recipe-client/lib/NormandyApi.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "SandboxManager",
"resource://shield-recipe-client/lib/SandboxManager.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "ClientEnvironment",
"resource://shield-recipe-client/lib/ClientEnvironment.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "CleanupManager",
"resource://shield-recipe-client/lib/CleanupManager.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "ActionSandboxManager",
"resource://shield-recipe-client/lib/ActionSandboxManager.jsm");
Cu.importGlobalProperties(["fetch"]); /* globals fetch */
this.EXPORTED_SYMBOLS = ["RecipeRunner"];
const log = LogManager.getLogger("recipe-runner");
const prefs = Services.prefs.getBranch("extensions.shield-recipe-client.");
const TIMER_NAME = "recipe-client-addon-run";
const RUN_INTERVAL_PREF = "run_interval_seconds";
this.RecipeRunner = {
init() {
if (!this.checkPrefs()) {
return;
}
if (prefs.getBoolPref("dev_mode")) {
// Run right now in dev mode
this.run();
}
this.updateRunInterval();
CleanupManager.addCleanupHandler(() => timerManager.unregisterTimer(TIMER_NAME));
// Watch for the run interval to change, and re-register the timer with the new value
prefs.addObserver(RUN_INTERVAL_PREF, this);
CleanupManager.addCleanupHandler(() => prefs.removeObserver(RUN_INTERVAL_PREF, this));
},
checkPrefs() {
// Only run if Unified Telemetry is enabled.
if (!Services.prefs.getBoolPref("toolkit.telemetry.unified")) {
log.info("Disabling RecipeRunner because Unified Telemetry is disabled.");
return false;
}
if (!prefs.getBoolPref("enabled")) {
log.info("Recipe Client is disabled.");
return false;
}
const apiUrl = prefs.getCharPref("api_url");
if (!apiUrl || !apiUrl.startsWith("https://")) {
log.error(`Non HTTPS URL provided for extensions.shield-recipe-client.api_url: ${apiUrl}`);
return false;
}
return true;
},
/**
* Watch for preference changes from Services.pref.addObserver.
*/
observe(changedPrefBranch, action, changedPref) {
if (action === "nsPref:changed" && changedPref === RUN_INTERVAL_PREF) {
this.updateRunInterval();
} else {
log.debug(`Observer fired with unexpected pref change: ${action} ${changedPref}`);
}
},
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 = 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 (!Preferences.get("extensions.shield-recipe-client.experiments.lazy_classify", false)) {
await ClientEnvironment.getClientClassification();
}
const actionSandboxManagers = await this.loadActionSandboxManagers();
Object.values(actionSandboxManagers).forEach(manager => manager.addHold("recipeRunner"));
// Run pre-execution hooks. If a hook fails, we don't run recipes with that
// action to avoid inconsistencies.
for (const [actionName, manager] of Object.entries(actionSandboxManagers)) {
try {
await manager.runAsyncCallback("preExecution");
manager.disabled = false;
} catch (err) {
log.error(`Could not run pre-execution hook for ${actionName}:`, err.message);
manager.disabled = true;
}
}
// Fetch recipes from the API
let recipes;
try {
recipes = await NormandyApi.fetchRecipes({enabled: true});
} catch (e) {
const apiUrl = prefs.getCharPref("api_url");
log.error(`Could not fetch recipes from ${apiUrl}: "${e}"`);
return;
}
// 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) {
const manager = actionSandboxManagers[recipe.action];
if (!manager) {
log.error(
`Could not execute recipe ${recipe.name}:`,
`Action ${recipe.action} is either missing or invalid.`
);
} else if (manager.disabled) {
log.warn(
`Skipping recipe ${recipe.name} because ${recipe.action} failed during pre-execution.`
);
} else {
try {
log.info(`Executing recipe "${recipe.name}" (action=${recipe.action})`);
await manager.runAsyncCallback("action", recipe);
} catch (e) {
log.error(`Could not execute recipe ${recipe.name}:`, e);
}
}
}
}
// Run post-execution hooks
for (const [actionName, manager] of Object.entries(actionSandboxManagers)) {
// Skip if pre-execution failed.
if (manager.disabled) {
log.info(`Skipping post-execution hook for ${actionName} due to earlier failure.`);
continue;
}
try {
await manager.runAsyncCallback("postExecution");
} catch (err) {
log.info(`Could not run post-execution hook for ${actionName}:`, err.message);
}
}
// Nuke sandboxes
Object.values(actionSandboxManagers).forEach(manager => manager.removeHold("recipeRunner"));
},
async loadActionSandboxManagers() {
const actions = await NormandyApi.fetchActions();
const actionSandboxManagers = {};
for (const action of actions) {
try {
const implementation = await NormandyApi.fetchImplementation(action);
actionSandboxManagers[action.name] = new ActionSandboxManager(implementation);
} catch (err) {
log.warn(`Could not fetch implementation for ${action.name}:`, err);
}
}
return actionSandboxManagers;
},
getFilterContext(recipe) {
return {
normandy: Object.assign(ClientEnvironment.getEnvironment(), {
recipe: {
id: recipe.id,
arguments: recipe.arguments,
},
}),
};
},
/**
* 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}"`);
log.error(`Filter: "${recipe.filter_expression}"`);
log.error(`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 = prefs.getCharPref("api_url");
prefs.setCharPref("api_url", baseApiUrl);
try {
Storage.clearAllStorage();
this.clearCaches();
await this.run();
} finally {
prefs.setCharPref("api_url", oldApiUrl);
this.clearCaches();
}
},
};