338 lines
10 KiB
JavaScript
338 lines
10 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/. */
|
|
|
|
import { Uptake } from "resource://normandy/lib/Uptake.sys.mjs";
|
|
|
|
const lazy = {};
|
|
|
|
ChromeUtils.defineESModuleGetters(lazy, {
|
|
JsonSchemaValidator:
|
|
"resource://gre/modules/components-utils/JsonSchemaValidator.sys.mjs",
|
|
LogManager: "resource://normandy/lib/LogManager.sys.mjs",
|
|
});
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
export 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: {
|
|
console.error(new Error("BaseAction.fail() called at unexpected time"));
|
|
}
|
|
}
|
|
this.state = BaseAction.STATE_FAILED;
|
|
this.lastError = err;
|
|
console.error(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,
|
|
{
|
|
allowAdditionalProperties: 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) {
|
|
console.error(error);
|
|
uptakeResult = Uptake.RECIPE_EXECUTION_ERROR;
|
|
suitability = BaseAction.suitability.ARGUMENTS_INVALID;
|
|
}
|
|
}
|
|
|
|
try {
|
|
await this._processRecipe(recipe, suitability);
|
|
} catch (err) {
|
|
console.error(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() {
|
|
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;
|
|
console.error(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,
|
|
};
|