summaryrefslogtreecommitdiffstats
path: root/toolkit/components/normandy/actions/AddonRolloutAction.jsm
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--toolkit/components/normandy/actions/AddonRolloutAction.jsm255
1 files changed, 255 insertions, 0 deletions
diff --git a/toolkit/components/normandy/actions/AddonRolloutAction.jsm b/toolkit/components/normandy/actions/AddonRolloutAction.jsm
new file mode 100644
index 0000000000..53e61e1b24
--- /dev/null
+++ b/toolkit/components/normandy/actions/AddonRolloutAction.jsm
@@ -0,0 +1,255 @@
+/* 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/. */
+
+"use strict";
+
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+const { BaseAction } = ChromeUtils.import(
+ "resource://normandy/actions/BaseAction.jsm"
+);
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ TelemetryEnvironment: "resource://gre/modules/TelemetryEnvironment.sys.mjs",
+});
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ ActionSchemas: "resource://normandy/actions/schemas/index.js",
+ AddonRollouts: "resource://normandy/lib/AddonRollouts.jsm",
+ NormandyAddonManager: "resource://normandy/lib/NormandyAddonManager.jsm",
+ NormandyApi: "resource://normandy/lib/NormandyApi.jsm",
+ NormandyUtils: "resource://normandy/lib/NormandyUtils.jsm",
+ TelemetryEvents: "resource://normandy/lib/TelemetryEvents.jsm",
+});
+
+var EXPORTED_SYMBOLS = ["AddonRolloutAction"];
+
+class AddonRolloutError extends Error {
+ /**
+ * @param {string} slug
+ * @param {object} extra Extra details to include when reporting the error to telemetry.
+ * @param {string} extra.reason The specific reason for the failure.
+ */
+ constructor(slug, extra) {
+ let message;
+ let { reason } = extra;
+ switch (reason) {
+ case "conflict": {
+ message = "an existing rollout already exists for this add-on";
+ break;
+ }
+ case "addon-id-changed": {
+ message = "upgrade add-on ID does not match installed add-on ID";
+ break;
+ }
+ case "upgrade-required": {
+ message = "a newer version of the add-on 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 AddonRolloutError reason: ${reason}`);
+ }
+ }
+ super(`Cannot install add-on for rollout (${slug}): ${message}.`);
+ this.slug = slug;
+ this.extra = extra;
+ }
+}
+
+class AddonRolloutAction extends BaseAction {
+ get schema() {
+ return lazy.ActionSchemas["addon-rollout"];
+ }
+
+ async _run(recipe) {
+ const { extensionApiId, slug } = recipe.arguments;
+
+ const existingRollout = await lazy.AddonRollouts.get(slug);
+ const eventName = existingRollout ? "update" : "enroll";
+ const extensionDetails = await lazy.NormandyApi.fetchExtensionDetails(
+ extensionApiId
+ );
+ let enrollmentId = existingRollout
+ ? existingRollout.enrollmentId
+ : undefined;
+
+ // Check if the existing rollout matches the current rollout
+ if (
+ existingRollout &&
+ existingRollout.addonId === extensionDetails.extension_id
+ ) {
+ const versionCompare = Services.vc.compare(
+ existingRollout.addonVersion,
+ extensionDetails.version
+ );
+
+ if (versionCompare === 0) {
+ return; // Do nothing
+ }
+ }
+
+ const createError = (reason, extra) => {
+ return new AddonRolloutError(slug, {
+ ...extra,
+ reason,
+ });
+ };
+
+ // Check for a conflict (addon already installed by another rollout)
+ const activeRollouts = await lazy.AddonRollouts.getAllActive();
+ const conflictingRollout = activeRollouts.find(
+ rollout =>
+ rollout.slug !== slug &&
+ rollout.addonId === extensionDetails.extension_id
+ );
+ if (conflictingRollout) {
+ const conflictError = createError("conflict", {
+ addonId: conflictingRollout.addonId,
+ conflictingSlug: conflictingRollout.slug,
+ enrollmentId:
+ conflictingRollout.enrollmentId ||
+ lazy.TelemetryEvents.NO_ENROLLMENT_ID_MARKER,
+ });
+ this.reportError(conflictError, "enrollFailed");
+ throw conflictError;
+ }
+
+ const onInstallStarted = (install, installDeferred) => {
+ const existingAddon = install.existingAddon;
+
+ if (existingRollout && existingRollout.addonId !== install.addon.id) {
+ installDeferred.reject(
+ createError("addon-id-changed", {
+ enrollmentId:
+ enrollmentId || lazy.TelemetryEvents.NO_ENROLLMENT_ID_MARKER,
+ })
+ );
+ return false; // cancel the upgrade, the add-on ID has changed
+ }
+
+ if (
+ existingAddon &&
+ Services.vc.compare(existingAddon.version, install.addon.version) > 0
+ ) {
+ installDeferred.reject(
+ createError("upgrade-required", {
+ enrollmentId:
+ enrollmentId || lazy.TelemetryEvents.NO_ENROLLMENT_ID_MARKER,
+ })
+ );
+ return false; // cancel the installation, must be an upgrade
+ }
+
+ return true;
+ };
+
+ const applyNormandyChanges = async install => {
+ const details = {
+ addonId: install.addon.id,
+ addonVersion: install.addon.version,
+ extensionApiId,
+ xpiUrl: extensionDetails.xpi,
+ xpiHash: extensionDetails.hash,
+ xpiHashAlgorithm: extensionDetails.hash_algorithm,
+ };
+
+ if (existingRollout) {
+ await lazy.AddonRollouts.update({
+ ...existingRollout,
+ ...details,
+ });
+ } else {
+ enrollmentId = lazy.NormandyUtils.generateUuid();
+ await lazy.AddonRollouts.add({
+ recipeId: recipe.id,
+ state: lazy.AddonRollouts.STATE_ACTIVE,
+ slug,
+ enrollmentId:
+ enrollmentId || lazy.TelemetryEvents.NO_ENROLLMENT_ID_MARKER,
+ ...details,
+ });
+ }
+ };
+
+ const undoNormandyChanges = async () => {
+ if (existingRollout) {
+ await lazy.AddonRollouts.update(existingRollout);
+ } else {
+ await lazy.AddonRollouts.delete(recipe.id);
+ }
+ };
+
+ const [
+ installedId,
+ installedVersion,
+ ] = await lazy.NormandyAddonManager.downloadAndInstall({
+ createError,
+ extensionDetails,
+ applyNormandyChanges,
+ undoNormandyChanges,
+ onInstallStarted,
+ reportError: error => this.reportError(error, `${eventName}Failed`),
+ });
+
+ if (existingRollout) {
+ this.log.debug(`Updated addon rollout ${slug}`);
+ } else {
+ this.log.debug(`Enrolled in addon rollout ${slug}`);
+ lazy.TelemetryEnvironment.setExperimentActive(
+ slug,
+ lazy.AddonRollouts.STATE_ACTIVE,
+ {
+ type: "normandy-addonrollout",
+ }
+ );
+ }
+
+ // All done, report success to Telemetry
+ lazy.TelemetryEvents.sendEvent(eventName, "addon_rollout", slug, {
+ addonId: installedId,
+ addonVersion: installedVersion,
+ enrollmentId:
+ enrollmentId || lazy.TelemetryEvents.NO_ENROLLMENT_ID_MARKER,
+ });
+ }
+
+ reportError(error, eventName) {
+ if (error instanceof AddonRolloutError) {
+ // One of our known errors. Report it nicely to telemetry
+ lazy.TelemetryEvents.sendEvent(
+ eventName,
+ "addon_rollout",
+ error.slug,
+ 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(eventName, "addon_rollout", error.slug, {
+ reason: safeErrorMessage.slice(0, 80), // max length is 80 chars
+ });
+ }
+ }
+}