259 lines
9.1 KiB
JavaScript
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();
|
|
}
|
|
},
|
|
};
|