/* 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/. */ /* * This action handles the life cycle of add-on based studies. Currently that * means installing the add-on the first time the recipe applies to this * client, updating the add-on to new versions if the recipe changes, and * uninstalling them when the recipe no longer applies. */ import { BaseStudyAction } from "resource://normandy/actions/BaseStudyAction.sys.mjs"; const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { ActionSchemas: "resource://normandy/actions/schemas/index.sys.mjs", AddonManager: "resource://gre/modules/AddonManager.sys.mjs", AddonStudies: "resource://normandy/lib/AddonStudies.sys.mjs", BaseAction: "resource://normandy/actions/BaseAction.sys.mjs", ClientEnvironment: "resource://normandy/lib/ClientEnvironment.sys.mjs", NormandyApi: "resource://normandy/lib/NormandyApi.sys.mjs", Sampling: "resource://gre/modules/components-utils/Sampling.sys.mjs", TelemetryEnvironment: "resource://gre/modules/TelemetryEnvironment.sys.mjs", TelemetryEvents: "resource://normandy/lib/TelemetryEvents.sys.mjs", }); class AddonStudyEnrollError extends Error { /** * @param {string} studyName * @param {object} extra Extra details to include when reporting the error to telemetry. * @param {string} extra.reason The specific reason for the failure. */ constructor(studyName, extra) { let message; let { reason } = extra; switch (reason) { case "conflicting-addon-id": { message = "an add-on with this ID is already installed"; break; } case "download-failure": { message = "the add-on failed to download"; break; } case "metadata-mismatch": { message = "the server metadata does not match the downloaded add-on"; break; } case "install-failure": { message = "the add-on failed to install"; break; } default: { throw new Error(`Unexpected AddonStudyEnrollError reason: ${reason}`); } } super(`Cannot install study add-on for ${studyName}: ${message}.`); this.studyName = studyName; this.extra = extra; } } class AddonStudyUpdateError extends Error { /** * @param {string} studyName * @param {object} extra Extra details to include when reporting the error to telemetry. * @param {string} extra.reason The specific reason for the failure. */ constructor(studyName, extra) { let message; let { reason } = extra; switch (reason) { case "addon-id-mismatch": { message = "new add-on ID does not match old add-on ID"; break; } case "addon-does-not-exist": { message = "an add-on with this ID does not exist"; break; } case "no-downgrade": { message = "the add-on was an older version than is installed"; break; } case "metadata-mismatch": { message = "the server metadata does not match the downloaded add-on"; break; } case "download-failure": { message = "the add-on failed to download"; break; } case "install-failure": { message = "the add-on failed to install"; break; } default: { throw new Error(`Unexpected AddonStudyUpdateError reason: ${reason}`); } } super(`Cannot update study add-on for ${studyName}: ${message}.`); this.studyName = studyName; this.extra = extra; } } export class BranchedAddonStudyAction extends BaseStudyAction { get schema() { return lazy.ActionSchemas["branched-addon-study"]; } constructor() { super(); this.seenRecipeIds = new Set(); } async _run(recipe) { throw new Error("_run should not be called anymore"); } /** * This hook is executed once for every recipe currently enabled on the * server. It is responsible for: * * - Enrolling studies the first time they have a FILTER_MATCH suitability. * - Updating studies that have changed and still have a FILTER_MATCH suitability. * - Marking studies as having been seen in this session. * - Unenrolling studies when they have permanent errors. * - Unenrolling studies when temporary errors persist for too long. * * If the action fails to perform any of these tasks, it should throw to * properly report its status. */ async _processRecipe(recipe, suitability) { this.seenRecipeIds.add(recipe.id); const study = await lazy.AddonStudies.get(recipe.id); switch (suitability) { case lazy.BaseAction.suitability.FILTER_MATCH: { if (!study) { await this.enroll(recipe); } else if (study.active) { await this.update(recipe, study); } break; } case lazy.BaseAction.suitability.SIGNATURE_ERROR: { await this._considerTemporaryError({ study, reason: "signature-error", }); break; } case lazy.BaseAction.suitability.FILTER_ERROR: { await this._considerTemporaryError({ study, reason: "filter-error", }); break; } case lazy.BaseAction.suitability.CAPABILITIES_MISMATCH: { if (study?.active) { await this.unenroll(recipe.id, "capability-mismatch"); } break; } case lazy.BaseAction.suitability.FILTER_MISMATCH: { if (study?.active) { await this.unenroll(recipe.id, "filter-mismatch"); } break; } case lazy.BaseAction.suitability.ARGUMENTS_INVALID: { if (study?.active) { await this.unenroll(recipe.id, "arguments-invalid"); } break; } default: { throw new Error(`Unknown recipe suitability "${suitability}".`); } } } /** * This hook is executed once after all recipes that apply to this client * have been processed. It is responsible for unenrolling the client from any * studies that no longer apply, based on this.seenRecipeIds. */ async _finalize({ noRecipes } = {}) { const activeStudies = await lazy.AddonStudies.getAllActive({ branched: lazy.AddonStudies.FILTER_BRANCHED_ONLY, }); if (noRecipes) { if (this.seenRecipeIds.size) { throw new BranchedAddonStudyAction.BadNoRecipesArg(); } for (const study of activeStudies) { await this._considerTemporaryError({ study, reason: "no-recipes" }); } } else { for (const study of activeStudies) { if (!this.seenRecipeIds.has(study.recipeId)) { this.log.debug( `Stopping branched add-on study for recipe ${study.recipeId}` ); try { await this.unenroll(study.recipeId, "recipe-not-seen"); } catch (err) { console.error(err); } } } } } /** * Download and install the addon for a given recipe * * @param recipe Object describing the study to enroll in. * @param extensionDetails Object describing the addon to be installed. * @param onInstallStarted A function that returns a callback for the install listener. * @param onComplete A callback function that is run on completion of the download. * @param onFailedInstall A callback function that is run if the installation fails. * @param errorClass The class of error to be thrown when exceptions occur. * @param reportError A function that reports errors to Telemetry. * @param [errorExtra] Optional, an object that will be merged into the * `extra` field of the error generated, if any. */ async downloadAndInstall({ recipe, extensionDetails, branchSlug, onInstallStarted, onComplete, onFailedInstall, errorClass, reportError, errorExtra = {}, }) { const { slug } = recipe.arguments; const { hash, hash_algorithm } = extensionDetails; const downloadDeferred = Promise.withResolvers(); const installDeferred = Promise.withResolvers(); const install = await lazy.AddonManager.getInstallForURL( extensionDetails.xpi, { hash: `${hash_algorithm}:${hash}`, telemetryInfo: { source: "internal" }, } ); const listener = { onDownloadFailed() { downloadDeferred.reject( new errorClass(slug, { reason: "download-failure", branch: branchSlug, detail: lazy.AddonManager.errorToString(install.error), ...errorExtra, }) ); }, onDownloadEnded() { downloadDeferred.resolve(); return false; // temporarily pause installation for Normandy bookkeeping }, onInstallFailed() { installDeferred.reject( new errorClass(slug, { reason: "install-failure", branch: branchSlug, detail: lazy.AddonManager.errorToString(install.error), }) ); }, onInstallEnded() { installDeferred.resolve(); }, }; listener.onInstallStarted = onInstallStarted(installDeferred); install.addListener(listener); // Download the add-on try { install.install(); await downloadDeferred.promise; } catch (err) { reportError(err); install.removeListener(listener); throw err; } await onComplete(install, listener); // Finish paused installation try { install.install(); await installDeferred.promise; } catch (err) { reportError(err); install.removeListener(listener); await onFailedInstall(); throw err; } install.removeListener(listener); return [install.addon.id, install.addon.version]; } async chooseBranch({ slug, branches }) { const ratios = branches.map(branch => branch.ratio); const userId = lazy.ClientEnvironment.userId; // It's important that the input be: // - Unique per-user (no one is bucketed alike) // - Unique per-experiment (bucketing differs across multiple experiments) // - Differs from the input used for sampling the recipe (otherwise only // branches that contain the same buckets as the recipe sampling will // receive users) const input = `${userId}-${slug}-addon-branch`; const index = await lazy.Sampling.ratioSample(input, ratios); return branches[index]; } /** * Enroll in the study represented by the given recipe. * @param recipe Object describing the study to enroll in. * @param extensionDetails Object describing the addon to be installed. */ async enroll(recipe) { // This function first downloads the add-on to get its metadata. Then it // uses that metadata to record a study in `AddonStudies`. Then, it finishes // installing the add-on, and finally sends telemetry. If any of these steps // fails, the previous ones are undone, as needed. // // This ordering is important because the only intermediate states we can be // in are: // 1. The add-on is only downloaded, in which case AddonManager will clean it up. // 2. The study has been recorded, in which case we will unenroll on next // start up. The start up code will assume that the add-on was uninstalled // while the browser was shutdown. // 3. After installation is complete, but before telemetry, in which case we // lose an enroll event. This is acceptable. // // This way a shutdown, crash or unexpected error can't leave Normandy in a // long term inconsistent state. The main thing avoided is having a study // add-on installed but no record of it, which would leave it permanently // installed. if (recipe.arguments.isEnrollmentPaused) { // Recipe does not need anything done return; } const { slug, userFacingName, userFacingDescription } = recipe.arguments; const branch = await this.chooseBranch({ slug: recipe.arguments.slug, branches: recipe.arguments.branches, }); this.log.debug(`Enrolling in branch ${branch.slug}`); if (branch.extensionApiId === null) { const study = { recipeId: recipe.id, slug, userFacingName, userFacingDescription, branch: branch.slug, addonId: null, addonVersion: null, addonUrl: null, extensionApiId: null, extensionHash: null, extensionHashAlgorithm: null, active: true, studyStartDate: new Date(), studyEndDate: null, temporaryErrorDeadline: null, }; try { await lazy.AddonStudies.add(study); } catch (err) { this.reportEnrollError(err); throw err; } // All done, report success to Telemetry lazy.TelemetryEvents.sendEvent("enroll", "addon_study", slug, { addonId: lazy.AddonStudies.NO_ADDON_MARKER, addonVersion: lazy.AddonStudies.NO_ADDON_MARKER, branch: branch.slug, }); } else { const extensionDetails = await lazy.NormandyApi.fetchExtensionDetails( branch.extensionApiId ); const onInstallStarted = installDeferred => cbInstall => { const versionMatches = cbInstall.addon.version === extensionDetails.version; const idMatches = cbInstall.addon.id === extensionDetails.extension_id; if (cbInstall.existingAddon) { installDeferred.reject( new AddonStudyEnrollError(slug, { reason: "conflicting-addon-id", branch: branch.slug, }) ); return false; // cancel the installation, no upgrades allowed } else if (!versionMatches || !idMatches) { installDeferred.reject( new AddonStudyEnrollError(slug, { branch: branch.slug, reason: "metadata-mismatch", }) ); return false; // cancel the installation, server metadata does not match downloaded add-on } return true; }; let study; const onComplete = async (install, listener) => { study = { recipeId: recipe.id, slug, userFacingName, userFacingDescription, branch: branch.slug, addonId: install.addon.id, addonVersion: install.addon.version, addonUrl: extensionDetails.xpi, extensionApiId: branch.extensionApiId, extensionHash: extensionDetails.hash, extensionHashAlgorithm: extensionDetails.hash_algorithm, active: true, studyStartDate: new Date(), studyEndDate: null, temporaryErrorDeadline: null, }; try { await lazy.AddonStudies.add(study); } catch (err) { this.reportEnrollError(err); install.removeListener(listener); install.cancel(); throw err; } }; const onFailedInstall = async () => { await lazy.AddonStudies.delete(recipe.id); }; const [installedId, installedVersion] = await this.downloadAndInstall({ recipe, branchSlug: branch.slug, extensionDetails, onInstallStarted, onComplete, onFailedInstall, errorClass: AddonStudyEnrollError, reportError: this.reportEnrollError, }); // All done, report success to Telemetry lazy.TelemetryEvents.sendEvent("enroll", "addon_study", slug, { addonId: installedId, addonVersion: installedVersion, branch: branch.slug, }); } lazy.TelemetryEnvironment.setExperimentActive(slug, branch.slug, { type: "normandy-addonstudy", }); } /** * Update the study represented by the given recipe. * @param recipe Object describing the study to be updated. * @param extensionDetails Object describing the addon to be installed. */ async update(recipe, study) { const { slug } = recipe.arguments; // Stay in the same branch, don't re-sample every time. const branch = recipe.arguments.branches.find( branch => branch.slug === study.branch ); if (!branch) { // Our branch has been removed. Unenroll. await this.unenroll(recipe.id, "branch-removed"); return; } // Since we saw a non-error suitability, clear the temporary error deadline. study.temporaryErrorDeadline = null; await lazy.AddonStudies.update(study); const extensionDetails = await lazy.NormandyApi.fetchExtensionDetails( branch.extensionApiId ); let error; if (study.addonId && study.addonId !== extensionDetails.extension_id) { error = new AddonStudyUpdateError(slug, { branch: branch.slug, reason: "addon-id-mismatch", }); } const versionCompare = Services.vc.compare( study.addonVersion, extensionDetails.version ); if (versionCompare > 0) { error = new AddonStudyUpdateError(slug, { branch: branch.slug, reason: "no-downgrade", }); } else if (versionCompare === 0) { return; // Unchanged, do nothing } if (error) { this.reportUpdateError(error); throw error; } const onInstallStarted = installDeferred => cbInstall => { const versionMatches = cbInstall.addon.version === extensionDetails.version; const idMatches = cbInstall.addon.id === extensionDetails.extension_id; if (!cbInstall.existingAddon) { installDeferred.reject( new AddonStudyUpdateError(slug, { branch: branch.slug, reason: "addon-does-not-exist", }) ); return false; // cancel the installation, must upgrade an existing add-on } else if (!versionMatches || !idMatches) { installDeferred.reject( new AddonStudyUpdateError(slug, { branch: branch.slug, reason: "metadata-mismatch", }) ); return false; // cancel the installation, server metadata do not match downloaded add-on } return true; }; const onComplete = async (install, listener) => { try { await lazy.AddonStudies.update({ ...study, addonVersion: install.addon.version, addonUrl: extensionDetails.xpi, extensionHash: extensionDetails.hash, extensionHashAlgorithm: extensionDetails.hash_algorithm, extensionApiId: branch.extensionApiId, }); } catch (err) { this.reportUpdateError(err); install.removeListener(listener); install.cancel(); throw err; } }; const onFailedInstall = () => { lazy.AddonStudies.update(study); }; const [installedId, installedVersion] = await this.downloadAndInstall({ recipe, extensionDetails, branchSlug: branch.slug, onInstallStarted, onComplete, onFailedInstall, errorClass: AddonStudyUpdateError, reportError: this.reportUpdateError, errorExtra: {}, }); // All done, report success to Telemetry lazy.TelemetryEvents.sendEvent("update", "addon_study", slug, { addonId: installedId, addonVersion: installedVersion, branch: branch.slug, }); } reportEnrollError(error) { if (error instanceof AddonStudyEnrollError) { // One of our known errors. Report it nicely to telemetry lazy.TelemetryEvents.sendEvent( "enrollFailed", "addon_study", error.studyName, error.extra ); } else { /* * Some unknown error. Add some helpful details, and report it to * telemetry. The actual stack trace and error message could possibly * contain PII, so we don't include them here. Instead include some * information that should still be helpful, and is less likely to be * unsafe. */ const safeErrorMessage = `${error.fileName}:${error.lineNumber}:${error.columnNumber} ${error.name}`; lazy.TelemetryEvents.sendEvent( "enrollFailed", "addon_study", error.studyName, { reason: safeErrorMessage.slice(0, 80), // max length is 80 chars } ); } } reportUpdateError(error) { if (error instanceof AddonStudyUpdateError) { // One of our known errors. Report it nicely to telemetry lazy.TelemetryEvents.sendEvent( "updateFailed", "addon_study", error.studyName, error.extra ); } else { /* * Some unknown error. Add some helpful details, and report it to * telemetry. The actual stack trace and error message could possibly * contain PII, so we don't include them here. Instead include some * information that should still be helpful, and is less likely to be * unsafe. */ const safeErrorMessage = `${error.fileName}:${error.lineNumber}:${error.columnNumber} ${error.name}`; lazy.TelemetryEvents.sendEvent( "updateFailed", "addon_study", error.studyName, { reason: safeErrorMessage.slice(0, 80), // max length is 80 chars } ); } } /** * Unenrolls the client from the study with a given recipe ID. * @param recipeId The recipe ID of an enrolled study * @param reason The reason for this unenrollment, to be used in Telemetry * @throws If the specified study does not exist, or if it is already inactive. */ async unenroll(recipeId, reason = "unknown") { const study = await lazy.AddonStudies.get(recipeId); if (!study) { throw new Error(`No study found for recipe ${recipeId}.`); } if (!study.active) { throw new Error( `Cannot stop study for recipe ${recipeId}; it is already inactive.` ); } await lazy.AddonStudies.markAsEnded(study, reason); // Study branches may indicate that no add-on should be installed, as a // form of control branch. In that case, `study.addonId` will be null (as // will the other add-on related fields). Only try to uninstall the add-on // if we expect one should be installed. if (study.addonId) { const addon = await lazy.AddonManager.getAddonByID(study.addonId); if (addon) { await addon.uninstall(); } else { this.log.warn( `Could not uninstall addon ${study.addonId} for recipe ${study.recipeId}: it is not installed.` ); } } } /** * Given that a temporary error has occured for a study, check if it * should be temporarily ignored, or if the deadline has passed. If the * deadline is passed, the study will be ended. If this is the first * temporary error, a deadline will be generated. Otherwise, nothing will * happen. * * If a temporary deadline exists but cannot be parsed, a new one will be * made. * * The deadline is 7 days from the first time that recipe failed, as * reckoned by the client's clock. * * @param {Object} args * @param {Study} args.study The enrolled study to potentially unenroll. * @param {String} args.reason If the study should end, the reason it is ending. */ async _considerTemporaryError({ study, reason }) { if (!study?.active) { return; } let now = Date.now(); // milliseconds-since-epoch let day = 24 * 60 * 60 * 1000; let newDeadline = new Date(now + 7 * day); if (study.temporaryErrorDeadline) { // if deadline is an invalid date, set it to one week from now. if (isNaN(study.temporaryErrorDeadline)) { study.temporaryErrorDeadline = newDeadline; await lazy.AddonStudies.update(study); return; } if (now > study.temporaryErrorDeadline) { await this.unenroll(study.recipeId, reason); } } else { // there is no deadline, so set one study.temporaryErrorDeadline = newDeadline; await lazy.AddonStudies.update(study); } } } BranchedAddonStudyAction.BadNoRecipesArg = class extends Error { message = "noRecipes is true, but some recipes observed"; };