Bug 1943650 - Command-line --help output misformatted after --dbus-service. r=emilio
[gecko.git] / toolkit / components / normandy / actions / BranchedAddonStudyAction.sys.mjs
blobb3b111cdf81be3491a36099aaa68e189e7104cfd
1 /* This Source Code Form is subject to the terms of the Mozilla Public
2  * License, v. 2.0. If a copy of the MPL was not distributed with this
3  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
5 /*
6  * This action handles the life cycle of add-on based studies. Currently that
7  * means installing the add-on the first time the recipe applies to this
8  * client, updating the add-on to new versions if the recipe changes, and
9  * uninstalling them when the recipe no longer applies.
10  */
12 import { BaseStudyAction } from "resource://normandy/actions/BaseStudyAction.sys.mjs";
14 const lazy = {};
16 ChromeUtils.defineESModuleGetters(lazy, {
17   ActionSchemas: "resource://normandy/actions/schemas/index.sys.mjs",
18   AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
19   AddonStudies: "resource://normandy/lib/AddonStudies.sys.mjs",
20   BaseAction: "resource://normandy/actions/BaseAction.sys.mjs",
21   ClientEnvironment: "resource://normandy/lib/ClientEnvironment.sys.mjs",
22   NormandyApi: "resource://normandy/lib/NormandyApi.sys.mjs",
23   Sampling: "resource://gre/modules/components-utils/Sampling.sys.mjs",
24   TelemetryEnvironment: "resource://gre/modules/TelemetryEnvironment.sys.mjs",
25   TelemetryEvents: "resource://normandy/lib/TelemetryEvents.sys.mjs",
26 });
28 class AddonStudyEnrollError extends Error {
29   /**
30    * @param {string} studyName
31    * @param {object} extra Extra details to include when reporting the error to telemetry.
32    * @param {string} extra.reason The specific reason for the failure.
33    */
34   constructor(studyName, extra) {
35     let message;
36     let { reason } = extra;
37     switch (reason) {
38       case "conflicting-addon-id": {
39         message = "an add-on with this ID is already installed";
40         break;
41       }
42       case "download-failure": {
43         message = "the add-on failed to download";
44         break;
45       }
46       case "metadata-mismatch": {
47         message = "the server metadata does not match the downloaded add-on";
48         break;
49       }
50       case "install-failure": {
51         message = "the add-on failed to install";
52         break;
53       }
54       default: {
55         throw new Error(`Unexpected AddonStudyEnrollError reason: ${reason}`);
56       }
57     }
58     super(`Cannot install study add-on for ${studyName}: ${message}.`);
59     this.studyName = studyName;
60     this.extra = extra;
61   }
64 class AddonStudyUpdateError extends Error {
65   /**
66    * @param {string} studyName
67    * @param {object} extra Extra details to include when reporting the error to telemetry.
68    * @param {string} extra.reason The specific reason for the failure.
69    */
70   constructor(studyName, extra) {
71     let message;
72     let { reason } = extra;
73     switch (reason) {
74       case "addon-id-mismatch": {
75         message = "new add-on ID does not match old add-on ID";
76         break;
77       }
78       case "addon-does-not-exist": {
79         message = "an add-on with this ID does not exist";
80         break;
81       }
82       case "no-downgrade": {
83         message = "the add-on was an older version than is installed";
84         break;
85       }
86       case "metadata-mismatch": {
87         message = "the server metadata does not match the downloaded add-on";
88         break;
89       }
90       case "download-failure": {
91         message = "the add-on failed to download";
92         break;
93       }
94       case "install-failure": {
95         message = "the add-on failed to install";
96         break;
97       }
98       default: {
99         throw new Error(`Unexpected AddonStudyUpdateError reason: ${reason}`);
100       }
101     }
102     super(`Cannot update study add-on for ${studyName}: ${message}.`);
103     this.studyName = studyName;
104     this.extra = extra;
105   }
108 export class BranchedAddonStudyAction extends BaseStudyAction {
109   get schema() {
110     return lazy.ActionSchemas["branched-addon-study"];
111   }
113   constructor() {
114     super();
115     this.seenRecipeIds = new Set();
116   }
118   async _run() {
119     throw new Error("_run should not be called anymore");
120   }
122   /**
123    * This hook is executed once for every recipe currently enabled on the
124    * server. It is responsible for:
125    *
126    *   - Enrolling studies the first time they have a FILTER_MATCH suitability.
127    *   - Updating studies that have changed and still have a FILTER_MATCH suitability.
128    *   - Marking studies as having been seen in this session.
129    *   - Unenrolling studies when they have permanent errors.
130    *   - Unenrolling studies when temporary errors persist for too long.
131    *
132    * If the action fails to perform any of these tasks, it should throw to
133    * properly report its status.
134    */
135   async _processRecipe(recipe, suitability) {
136     this.seenRecipeIds.add(recipe.id);
137     const study = await lazy.AddonStudies.get(recipe.id);
139     switch (suitability) {
140       case lazy.BaseAction.suitability.FILTER_MATCH: {
141         if (!study) {
142           await this.enroll(recipe);
143         } else if (study.active) {
144           await this.update(recipe, study);
145         }
146         break;
147       }
149       case lazy.BaseAction.suitability.SIGNATURE_ERROR: {
150         await this._considerTemporaryError({
151           study,
152           reason: "signature-error",
153         });
154         break;
155       }
157       case lazy.BaseAction.suitability.FILTER_ERROR: {
158         await this._considerTemporaryError({
159           study,
160           reason: "filter-error",
161         });
162         break;
163       }
165       case lazy.BaseAction.suitability.CAPABILITIES_MISMATCH: {
166         if (study?.active) {
167           await this.unenroll(recipe.id, "capability-mismatch");
168         }
169         break;
170       }
172       case lazy.BaseAction.suitability.FILTER_MISMATCH: {
173         if (study?.active) {
174           await this.unenroll(recipe.id, "filter-mismatch");
175         }
176         break;
177       }
179       case lazy.BaseAction.suitability.ARGUMENTS_INVALID: {
180         if (study?.active) {
181           await this.unenroll(recipe.id, "arguments-invalid");
182         }
183         break;
184       }
186       default: {
187         throw new Error(`Unknown recipe suitability "${suitability}".`);
188       }
189     }
190   }
192   /**
193    * This hook is executed once after all recipes that apply to this client
194    * have been processed. It is responsible for unenrolling the client from any
195    * studies that no longer apply, based on this.seenRecipeIds.
196    */
197   async _finalize({ noRecipes } = {}) {
198     const activeStudies = await lazy.AddonStudies.getAllActive({
199       branched: lazy.AddonStudies.FILTER_BRANCHED_ONLY,
200     });
202     if (noRecipes) {
203       if (this.seenRecipeIds.size) {
204         throw new BranchedAddonStudyAction.BadNoRecipesArg();
205       }
206       for (const study of activeStudies) {
207         await this._considerTemporaryError({ study, reason: "no-recipes" });
208       }
209     } else {
210       for (const study of activeStudies) {
211         if (!this.seenRecipeIds.has(study.recipeId)) {
212           this.log.debug(
213             `Stopping branched add-on study for recipe ${study.recipeId}`
214           );
215           try {
216             await this.unenroll(study.recipeId, "recipe-not-seen");
217           } catch (err) {
218             console.error(err);
219           }
220         }
221       }
222     }
223   }
225   /**
226    * Download and install the addon for a given recipe
227    *
228    * @param recipe Object describing the study to enroll in.
229    * @param extensionDetails Object describing the addon to be installed.
230    * @param onInstallStarted A function that returns a callback for the install listener.
231    * @param onComplete A callback function that is run on completion of the download.
232    * @param onFailedInstall A callback function that is run if the installation fails.
233    * @param errorClass The class of error to be thrown when exceptions occur.
234    * @param reportError A function that reports errors to Telemetry.
235    * @param [errorExtra] Optional, an object that will be merged into the
236    *                     `extra` field of the error generated, if any.
237    */
238   async downloadAndInstall({
239     recipe,
240     extensionDetails,
241     branchSlug,
242     onInstallStarted,
243     onComplete,
244     onFailedInstall,
245     errorClass,
246     reportError,
247     errorExtra = {},
248   }) {
249     const { slug } = recipe.arguments;
250     const { hash, hash_algorithm } = extensionDetails;
252     const downloadDeferred = Promise.withResolvers();
253     const installDeferred = Promise.withResolvers();
255     const install = await lazy.AddonManager.getInstallForURL(
256       extensionDetails.xpi,
257       {
258         hash: `${hash_algorithm}:${hash}`,
259         telemetryInfo: { source: "internal" },
260       }
261     );
263     const listener = {
264       onDownloadFailed() {
265         downloadDeferred.reject(
266           new errorClass(slug, {
267             reason: "download-failure",
268             branch: branchSlug,
269             detail: lazy.AddonManager.errorToString(install.error),
270             ...errorExtra,
271           })
272         );
273       },
275       onDownloadEnded() {
276         downloadDeferred.resolve();
277         return false; // temporarily pause installation for Normandy bookkeeping
278       },
280       onInstallFailed() {
281         installDeferred.reject(
282           new errorClass(slug, {
283             reason: "install-failure",
284             branch: branchSlug,
285             detail: lazy.AddonManager.errorToString(install.error),
286           })
287         );
288       },
290       onInstallEnded() {
291         installDeferred.resolve();
292       },
293     };
295     listener.onInstallStarted = onInstallStarted(installDeferred);
297     install.addListener(listener);
299     // Download the add-on
300     try {
301       install.install();
302       await downloadDeferred.promise;
303     } catch (err) {
304       reportError(err);
305       install.removeListener(listener);
306       throw err;
307     }
309     await onComplete(install, listener);
311     // Finish paused installation
312     try {
313       install.install();
314       await installDeferred.promise;
315     } catch (err) {
316       reportError(err);
317       install.removeListener(listener);
318       await onFailedInstall();
319       throw err;
320     }
322     install.removeListener(listener);
324     return [install.addon.id, install.addon.version];
325   }
327   async chooseBranch({ slug, branches }) {
328     const ratios = branches.map(branch => branch.ratio);
329     const userId = lazy.ClientEnvironment.userId;
331     // It's important that the input be:
332     // - Unique per-user (no one is bucketed alike)
333     // - Unique per-experiment (bucketing differs across multiple experiments)
334     // - Differs from the input used for sampling the recipe (otherwise only
335     //   branches that contain the same buckets as the recipe sampling will
336     //   receive users)
337     const input = `${userId}-${slug}-addon-branch`;
339     const index = await lazy.Sampling.ratioSample(input, ratios);
340     return branches[index];
341   }
343   /**
344    * Enroll in the study represented by the given recipe.
345    * @param recipe Object describing the study to enroll in.
346    * @param extensionDetails Object describing the addon to be installed.
347    */
348   async enroll(recipe) {
349     // This function first downloads the add-on to get its metadata. Then it
350     // uses that metadata to record a study in `AddonStudies`. Then, it finishes
351     // installing the add-on, and finally sends telemetry. If any of these steps
352     // fails, the previous ones are undone, as needed.
353     //
354     // This ordering is important because the only intermediate states we can be
355     // in are:
356     //   1. The add-on is only downloaded, in which case AddonManager will clean it up.
357     //   2. The study has been recorded, in which case we will unenroll on next
358     //      start up. The start up code will assume that the add-on was uninstalled
359     //      while the browser was shutdown.
360     //   3. After installation is complete, but before telemetry, in which case we
361     //      lose an enroll event. This is acceptable.
362     //
363     // This way a shutdown, crash or unexpected error can't leave Normandy in a
364     // long term inconsistent state. The main thing avoided is having a study
365     // add-on installed but no record of it, which would leave it permanently
366     // installed.
368     if (recipe.arguments.isEnrollmentPaused) {
369       // Recipe does not need anything done
370       return;
371     }
373     const { slug, userFacingName, userFacingDescription } = recipe.arguments;
374     const branch = await this.chooseBranch({
375       slug: recipe.arguments.slug,
376       branches: recipe.arguments.branches,
377     });
378     this.log.debug(`Enrolling in branch ${branch.slug}`);
380     if (branch.extensionApiId === null) {
381       const study = {
382         recipeId: recipe.id,
383         slug,
384         userFacingName,
385         userFacingDescription,
386         branch: branch.slug,
387         addonId: null,
388         addonVersion: null,
389         addonUrl: null,
390         extensionApiId: null,
391         extensionHash: null,
392         extensionHashAlgorithm: null,
393         active: true,
394         studyStartDate: new Date(),
395         studyEndDate: null,
396         temporaryErrorDeadline: null,
397       };
399       try {
400         await lazy.AddonStudies.add(study);
401       } catch (err) {
402         this.reportEnrollError(err);
403         throw err;
404       }
406       // All done, report success to Telemetry
407       lazy.TelemetryEvents.sendEvent("enroll", "addon_study", slug, {
408         addonId: lazy.AddonStudies.NO_ADDON_MARKER,
409         addonVersion: lazy.AddonStudies.NO_ADDON_MARKER,
410         branch: branch.slug,
411       });
412     } else {
413       const extensionDetails = await lazy.NormandyApi.fetchExtensionDetails(
414         branch.extensionApiId
415       );
417       const onInstallStarted = installDeferred => cbInstall => {
418         const versionMatches =
419           cbInstall.addon.version === extensionDetails.version;
420         const idMatches = cbInstall.addon.id === extensionDetails.extension_id;
422         if (cbInstall.existingAddon) {
423           installDeferred.reject(
424             new AddonStudyEnrollError(slug, {
425               reason: "conflicting-addon-id",
426               branch: branch.slug,
427             })
428           );
429           return false; // cancel the installation, no upgrades allowed
430         } else if (!versionMatches || !idMatches) {
431           installDeferred.reject(
432             new AddonStudyEnrollError(slug, {
433               branch: branch.slug,
434               reason: "metadata-mismatch",
435             })
436           );
437           return false; // cancel the installation, server metadata does not match downloaded add-on
438         }
439         return true;
440       };
442       let study;
443       const onComplete = async (install, listener) => {
444         study = {
445           recipeId: recipe.id,
446           slug,
447           userFacingName,
448           userFacingDescription,
449           branch: branch.slug,
450           addonId: install.addon.id,
451           addonVersion: install.addon.version,
452           addonUrl: extensionDetails.xpi,
453           extensionApiId: branch.extensionApiId,
454           extensionHash: extensionDetails.hash,
455           extensionHashAlgorithm: extensionDetails.hash_algorithm,
456           active: true,
457           studyStartDate: new Date(),
458           studyEndDate: null,
459           temporaryErrorDeadline: null,
460         };
462         try {
463           await lazy.AddonStudies.add(study);
464         } catch (err) {
465           this.reportEnrollError(err);
466           install.removeListener(listener);
467           install.cancel();
468           throw err;
469         }
470       };
472       const onFailedInstall = async () => {
473         await lazy.AddonStudies.delete(recipe.id);
474       };
476       const [installedId, installedVersion] = await this.downloadAndInstall({
477         recipe,
478         branchSlug: branch.slug,
479         extensionDetails,
480         onInstallStarted,
481         onComplete,
482         onFailedInstall,
483         errorClass: AddonStudyEnrollError,
484         reportError: this.reportEnrollError,
485       });
487       // All done, report success to Telemetry
488       lazy.TelemetryEvents.sendEvent("enroll", "addon_study", slug, {
489         addonId: installedId,
490         addonVersion: installedVersion,
491         branch: branch.slug,
492       });
493     }
495     lazy.TelemetryEnvironment.setExperimentActive(slug, branch.slug, {
496       type: "normandy-addonstudy",
497     });
498   }
500   /**
501    * Update the study represented by the given recipe.
502    * @param recipe Object describing the study to be updated.
503    * @param extensionDetails Object describing the addon to be installed.
504    */
505   async update(recipe, study) {
506     const { slug } = recipe.arguments;
508     // Stay in the same branch, don't re-sample every time.
509     const branch = recipe.arguments.branches.find(
510       branch => branch.slug === study.branch
511     );
513     if (!branch) {
514       // Our branch has been removed. Unenroll.
515       await this.unenroll(recipe.id, "branch-removed");
516       return;
517     }
519     // Since we saw a non-error suitability, clear the temporary error deadline.
520     study.temporaryErrorDeadline = null;
521     await lazy.AddonStudies.update(study);
523     const extensionDetails = await lazy.NormandyApi.fetchExtensionDetails(
524       branch.extensionApiId
525     );
527     let error;
529     if (study.addonId && study.addonId !== extensionDetails.extension_id) {
530       error = new AddonStudyUpdateError(slug, {
531         branch: branch.slug,
532         reason: "addon-id-mismatch",
533       });
534     }
536     const versionCompare = Services.vc.compare(
537       study.addonVersion,
538       extensionDetails.version
539     );
540     if (versionCompare > 0) {
541       error = new AddonStudyUpdateError(slug, {
542         branch: branch.slug,
543         reason: "no-downgrade",
544       });
545     } else if (versionCompare === 0) {
546       return; // Unchanged, do nothing
547     }
549     if (error) {
550       this.reportUpdateError(error);
551       throw error;
552     }
554     const onInstallStarted = installDeferred => cbInstall => {
555       const versionMatches =
556         cbInstall.addon.version === extensionDetails.version;
557       const idMatches = cbInstall.addon.id === extensionDetails.extension_id;
559       if (!cbInstall.existingAddon) {
560         installDeferred.reject(
561           new AddonStudyUpdateError(slug, {
562             branch: branch.slug,
563             reason: "addon-does-not-exist",
564           })
565         );
566         return false; // cancel the installation, must upgrade an existing add-on
567       } else if (!versionMatches || !idMatches) {
568         installDeferred.reject(
569           new AddonStudyUpdateError(slug, {
570             branch: branch.slug,
571             reason: "metadata-mismatch",
572           })
573         );
574         return false; // cancel the installation, server metadata do not match downloaded add-on
575       }
577       return true;
578     };
580     const onComplete = async (install, listener) => {
581       try {
582         await lazy.AddonStudies.update({
583           ...study,
584           addonVersion: install.addon.version,
585           addonUrl: extensionDetails.xpi,
586           extensionHash: extensionDetails.hash,
587           extensionHashAlgorithm: extensionDetails.hash_algorithm,
588           extensionApiId: branch.extensionApiId,
589         });
590       } catch (err) {
591         this.reportUpdateError(err);
592         install.removeListener(listener);
593         install.cancel();
594         throw err;
595       }
596     };
598     const onFailedInstall = () => {
599       lazy.AddonStudies.update(study);
600     };
602     const [installedId, installedVersion] = await this.downloadAndInstall({
603       recipe,
604       extensionDetails,
605       branchSlug: branch.slug,
606       onInstallStarted,
607       onComplete,
608       onFailedInstall,
609       errorClass: AddonStudyUpdateError,
610       reportError: this.reportUpdateError,
611       errorExtra: {},
612     });
614     // All done, report success to Telemetry
615     lazy.TelemetryEvents.sendEvent("update", "addon_study", slug, {
616       addonId: installedId,
617       addonVersion: installedVersion,
618       branch: branch.slug,
619     });
620   }
622   reportEnrollError(error) {
623     if (error instanceof AddonStudyEnrollError) {
624       // One of our known errors. Report it nicely to telemetry
625       lazy.TelemetryEvents.sendEvent(
626         "enrollFailed",
627         "addon_study",
628         error.studyName,
629         error.extra
630       );
631     } else {
632       /*
633        * Some unknown error. Add some helpful details, and report it to
634        * telemetry. The actual stack trace and error message could possibly
635        * contain PII, so we don't include them here. Instead include some
636        * information that should still be helpful, and is less likely to be
637        * unsafe.
638        */
639       const safeErrorMessage = `${error.fileName}:${error.lineNumber}:${error.columnNumber} ${error.name}`;
640       lazy.TelemetryEvents.sendEvent(
641         "enrollFailed",
642         "addon_study",
643         error.studyName,
644         {
645           reason: safeErrorMessage.slice(0, 80), // max length is 80 chars
646         }
647       );
648     }
649   }
651   reportUpdateError(error) {
652     if (error instanceof AddonStudyUpdateError) {
653       // One of our known errors. Report it nicely to telemetry
654       lazy.TelemetryEvents.sendEvent(
655         "updateFailed",
656         "addon_study",
657         error.studyName,
658         error.extra
659       );
660     } else {
661       /*
662        * Some unknown error. Add some helpful details, and report it to
663        * telemetry. The actual stack trace and error message could possibly
664        * contain PII, so we don't include them here. Instead include some
665        * information that should still be helpful, and is less likely to be
666        * unsafe.
667        */
668       const safeErrorMessage = `${error.fileName}:${error.lineNumber}:${error.columnNumber} ${error.name}`;
669       lazy.TelemetryEvents.sendEvent(
670         "updateFailed",
671         "addon_study",
672         error.studyName,
673         {
674           reason: safeErrorMessage.slice(0, 80), // max length is 80 chars
675         }
676       );
677     }
678   }
680   /**
681    * Unenrolls the client from the study with a given recipe ID.
682    * @param recipeId The recipe ID of an enrolled study
683    * @param reason The reason for this unenrollment, to be used in Telemetry
684    * @throws If the specified study does not exist, or if it is already inactive.
685    */
686   async unenroll(recipeId, reason = "unknown") {
687     const study = await lazy.AddonStudies.get(recipeId);
688     if (!study) {
689       throw new Error(`No study found for recipe ${recipeId}.`);
690     }
691     if (!study.active) {
692       throw new Error(
693         `Cannot stop study for recipe ${recipeId}; it is already inactive.`
694       );
695     }
697     await lazy.AddonStudies.markAsEnded(study, reason);
699     // Study branches may indicate that no add-on should be installed, as a
700     // form of control branch. In that case, `study.addonId` will be null (as
701     // will the other add-on related fields). Only try to uninstall the add-on
702     // if we expect one should be installed.
703     if (study.addonId) {
704       const addon = await lazy.AddonManager.getAddonByID(study.addonId);
705       if (addon) {
706         await addon.uninstall();
707       } else {
708         this.log.warn(
709           `Could not uninstall addon ${study.addonId} for recipe ${study.recipeId}: it is not installed.`
710         );
711       }
712     }
713   }
715   /**
716    * Given that a temporary error has occured for a study, check if it
717    * should be temporarily ignored, or if the deadline has passed. If the
718    * deadline is passed, the study will be ended. If this is the first
719    * temporary error, a deadline will be generated. Otherwise, nothing will
720    * happen.
721    *
722    * If a temporary deadline exists but cannot be parsed, a new one will be
723    * made.
724    *
725    * The deadline is 7 days from the first time that recipe failed, as
726    * reckoned by the client's clock.
727    *
728    * @param {Object} args
729    * @param {Study} args.study The enrolled study to potentially unenroll.
730    * @param {String} args.reason If the study should end, the reason it is ending.
731    */
732   async _considerTemporaryError({ study, reason }) {
733     if (!study?.active) {
734       return;
735     }
737     let now = Date.now(); // milliseconds-since-epoch
738     let day = 24 * 60 * 60 * 1000;
739     let newDeadline = new Date(now + 7 * day);
741     if (study.temporaryErrorDeadline) {
742       // if deadline is an invalid date, set it to one week from now.
743       if (isNaN(study.temporaryErrorDeadline)) {
744         study.temporaryErrorDeadline = newDeadline;
745         await lazy.AddonStudies.update(study);
746         return;
747       }
749       if (now > study.temporaryErrorDeadline) {
750         await this.unenroll(study.recipeId, reason);
751       }
752     } else {
753       // there is no deadline, so set one
754       study.temporaryErrorDeadline = newDeadline;
755       await lazy.AddonStudies.update(study);
756     }
757   }
760 BranchedAddonStudyAction.BadNoRecipesArg = class extends Error {
761   message = "noRecipes is true, but some recipes observed";