summaryrefslogtreecommitdiffstats
path: root/toolkit/components/normandy/actions/BaseAction.sys.mjs
blob: f71d5c71dd01cd0f752e3808c563afd943bbe190 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
/* 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,
      {
        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) {
        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(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;
          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,
};