diff options
Diffstat (limited to 'toolkit/components/normandy/actions/BaseAction.jsm')
-rw-r--r-- | toolkit/components/normandy/actions/BaseAction.jsm | 347 |
1 files changed, 347 insertions, 0 deletions
diff --git a/toolkit/components/normandy/actions/BaseAction.jsm b/toolkit/components/normandy/actions/BaseAction.jsm new file mode 100644 index 0000000000..b8c17a3e2e --- /dev/null +++ b/toolkit/components/normandy/actions/BaseAction.jsm @@ -0,0 +1,347 @@ +/* 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/. */ + +const { Uptake } = ChromeUtils.import("resource://normandy/lib/Uptake.jsm"); + +const lazy = {}; + +ChromeUtils.defineModuleGetter( + lazy, + "LogManager", + "resource://normandy/lib/LogManager.jsm" +); +ChromeUtils.defineModuleGetter( + lazy, + "JsonSchemaValidator", + "resource://gre/modules/components-utils/JsonSchemaValidator.jsm" +); + +var EXPORTED_SYMBOLS = ["BaseAction"]; + +/** + * Base class for local actions. + * + * This should be subclassed. Subclasses must implement _run() for + * per-recipe behavior, and may implement _preExecution and _finalize + * for actions to be taken once before and after recipes are run. + * + * Other methods should be overridden with care, to maintain the life + * cycle events and error reporting implemented by this class. + */ +class BaseAction { + constructor() { + this.state = BaseAction.STATE_PREPARING; + this.log = lazy.LogManager.getLogger(`action.${this.name}`); + this.lastError = null; + } + + /** + * Be sure to run the _preExecution() hook once during its + * lifecycle. + * + * This is not intended for overriding by subclasses. + */ + _ensurePreExecution() { + if (this.state !== BaseAction.STATE_PREPARING) { + return; + } + + try { + this._preExecution(); + // if _preExecution changed the state, don't overwrite it + if (this.state === BaseAction.STATE_PREPARING) { + this.state = BaseAction.STATE_READY; + } + } catch (err) { + // Sometimes err.message is editable. If it is, add helpful details. + // Otherwise log the helpful details and move on. + try { + err.message = `Could not initialize action ${this.name}: ${err.message}`; + } catch (_e) { + this.log.error( + `Could not initialize action ${this.name}, error follows.` + ); + } + this.fail(err); + } + } + + get schema() { + return { + type: "object", + properties: {}, + }; + } + + /** + * Disable the action for a non-error reason, such as the user opting out of + * this type of action. + */ + disable() { + this.state = BaseAction.STATE_DISABLED; + } + + fail(err) { + switch (this.state) { + case BaseAction.STATE_PREPARING: { + Uptake.reportAction(this.name, Uptake.ACTION_PRE_EXECUTION_ERROR); + break; + } + default: { + Cu.reportError( + new Error("BaseAction.fail() called at unexpected time") + ); + } + } + this.state = BaseAction.STATE_FAILED; + this.lastError = err; + Cu.reportError(err); + } + + // Gets the name of the action. Does not necessarily match the + // server slug for the action. + get name() { + return this.constructor.name; + } + + /** + * Action specific pre-execution behavior should be implemented + * here. It will be called once per execution session. + */ + _preExecution() { + // Does nothing, may be overridden + } + + validateArguments(args, schema = this.schema) { + let { valid, parsedValue: validated } = lazy.JsonSchemaValidator.validate( + args, + schema, + { + allowExtraProperties: true, + } + ); + if (!valid) { + throw new Error( + `Arguments do not match schema. arguments:\n${JSON.stringify(args)}\n` + + `schema:\n${JSON.stringify(schema)}` + ); + } + return validated; + } + + /** + * Execute the per-recipe behavior of this action for a given + * recipe. Reports Uptake telemetry for the execution of the recipe. + * + * @param {Recipe} recipe + * @param {BaseAction.suitability} suitability + * @throws If this action has already been finalized. + */ + async processRecipe(recipe, suitability) { + if (!BaseAction.suitabilitySet.has(suitability)) { + throw new Error(`Unknown recipe status ${suitability}`); + } + + this._ensurePreExecution(); + + if (this.state === BaseAction.STATE_FINALIZED) { + throw new Error("Action has already been finalized"); + } + + if (this.state !== BaseAction.STATE_READY) { + Uptake.reportRecipe(recipe, Uptake.RECIPE_ACTION_DISABLED); + this.log.warn( + `Skipping recipe ${recipe.name} because ${this.name} was disabled during preExecution.` + ); + return; + } + + let uptakeResult = BaseAction.suitabilityToUptakeStatus[suitability]; + if (!uptakeResult) { + throw new Error( + `Coding error, no uptake status for suitability ${suitability}` + ); + } + + // If capabilties don't match, we can't even be sure that the arguments + // should be valid. In that case don't try to validate them. + if (suitability !== BaseAction.suitability.CAPABILITIES_MISMATCH) { + try { + recipe.arguments = this.validateArguments(recipe.arguments); + } catch (error) { + Cu.reportError(error); + uptakeResult = Uptake.RECIPE_EXECUTION_ERROR; + suitability = BaseAction.suitability.ARGUMENTS_INVALID; + } + } + + try { + await this._processRecipe(recipe, suitability); + } catch (err) { + Cu.reportError(err); + uptakeResult = Uptake.RECIPE_EXECUTION_ERROR; + } + Uptake.reportRecipe(recipe, uptakeResult); + } + + /** + * Action specific recipe behavior may be implemented here. It will be + * executed once for each recipe that applies to this client. + * The recipe will be passed as a parameter. + * + * @param {Recipe} recipe + */ + async _run(recipe) { + throw new Error("Not implemented"); + } + + /** + * Action specific recipe behavior should be implemented here. It will be + * executed once for every recipe currently published. The suitability of the + * recipe will be passed, it will be one of the constants from + * `BaseAction.suitability`. + * + * By default, this calls `_run()` for recipes with `status == FILTER_MATCH`, + * and does nothing for all other recipes. It is invalid for an action to + * override both `_run` and `_processRecipe`. + * + * @param {Recipe} recipe + * @param {RecipeSuitability} suitability + */ + async _processRecipe(recipe, suitability) { + if (!suitability) { + throw new Error("Suitability is undefined:", suitability); + } + if (suitability == BaseAction.suitability.FILTER_MATCH) { + await this._run(recipe); + } + } + + /** + * Finish an execution session. After this method is called, no + * other methods may be called on this method, and all relevant + * recipes will be assumed to have been seen. + */ + async finalize(options) { + // It's possible that no recipes used this action, so processRecipe() + // was never called. In that case, we should ensure that we call + // _preExecute() here. + this._ensurePreExecution(); + + let status; + switch (this.state) { + case BaseAction.STATE_FINALIZED: { + throw new Error("Action has already been finalized"); + } + case BaseAction.STATE_READY: { + try { + await this._finalize(options); + status = Uptake.ACTION_SUCCESS; + } catch (err) { + status = Uptake.ACTION_POST_EXECUTION_ERROR; + // Sometimes Error.message can be updated in place. This gives better messages when debugging errors. + try { + err.message = `Could not run postExecution hook for ${this.name}: ${err.message}`; + } catch (err) { + // Sometimes Error.message cannot be updated. Log a warning, and move on. + this.log.debug(`Could not run postExecution hook for ${this.name}`); + } + + this.lastError = err; + Cu.reportError(err); + } + break; + } + case BaseAction.STATE_DISABLED: { + this.log.debug( + `Skipping post-execution hook for ${this.name} because it is disabled.` + ); + status = Uptake.ACTION_SUCCESS; + break; + } + case BaseAction.STATE_FAILED: { + this.log.debug( + `Skipping post-execution hook for ${this.name} because it failed during pre-execution.` + ); + // Don't report a status. A status should have already been reported by this.fail(). + break; + } + default: { + throw new Error(`Unexpected state during finalize: ${this.state}`); + } + } + + this.state = BaseAction.STATE_FINALIZED; + if (status) { + Uptake.reportAction(this.name, status); + } + } + + /** + * Action specific post-execution behavior should be implemented + * here. It will be executed once after all recipes have been + * processed. + */ + async _finalize(_options = {}) { + // Does nothing, may be overridden + } +} + +BaseAction.STATE_PREPARING = "ACTION_PREPARING"; +BaseAction.STATE_READY = "ACTION_READY"; +BaseAction.STATE_DISABLED = "ACTION_DISABLED"; +BaseAction.STATE_FAILED = "ACTION_FAILED"; +BaseAction.STATE_FINALIZED = "ACTION_FINALIZED"; + +// Make sure to update the docs in ../docs/suitabilities.rst when changing this. +BaseAction.suitability = { + /** + * The recipe's signature is not valid. If any action is taken this recipe + * should be treated with extreme suspicion. + */ + SIGNATURE_ERROR: "RECIPE_SUITABILITY_SIGNATURE_ERROR", + + /** + * The recipe requires capabilities that this recipe runner does not have. + * Use caution when interacting with this recipe, as it may not match the + * expected schema. + */ + CAPABILITIES_MISMATCH: "RECIPE_SUITABILITY_CAPABILITIES_MISMATCH", + + /** + * The recipe is suitable to execute in this client. + */ + FILTER_MATCH: "RECIPE_SUITABILITY_FILTER_MATCH", + + /** + * This client does not match the recipe's filter, but it is otherwise a + * suitable recipe. + */ + FILTER_MISMATCH: "RECIPE_SUITABILITY_FILTER_MISMATCH", + + /** + * There was an error while evaluating the filter. It is unknown if this + * client matches this filter. This may be temporary, due to network errors, + * or permanent due to syntax errors. + */ + FILTER_ERROR: "RECIPE_SUITABILITY_FILTER_ERROR", + + /** + * The arguments of the recipe do not match the expected schema for the named + * action. + */ + ARGUMENTS_INVALID: "RECIPE_SUITABILITY_ARGUMENTS_INVALID", +}; + +BaseAction.suitabilitySet = new Set(Object.values(BaseAction.suitability)); + +BaseAction.suitabilityToUptakeStatus = { + [BaseAction.suitability.SIGNATURE_ERROR]: Uptake.RECIPE_INVALID_SIGNATURE, + [BaseAction.suitability.CAPABILITIES_MISMATCH]: + Uptake.RECIPE_INCOMPATIBLE_CAPABILITIES, + [BaseAction.suitability.FILTER_MATCH]: Uptake.RECIPE_SUCCESS, + [BaseAction.suitability.FILTER_MISMATCH]: Uptake.RECIPE_DIDNT_MATCH_FILTER, + [BaseAction.suitability.FILTER_ERROR]: Uptake.RECIPE_FILTER_BROKEN, + [BaseAction.suitability.ARGUMENTS_INVALID]: Uptake.RECIPE_ARGUMENTS_INVALID, +}; |