Bug 1436111 - Implement addon rollout/rollback actions for Normandy r=mythmon
authorrdalal <rdalal@mozilla.com>
Thu, 22 Aug 2019 23:52:53 +0000
changeset 553278 bd671490ec3cb9c14ab5b85a43f57cb9cca4355b
parent 553277 4ab23a7388e28233a96ef37e26bfe99cbba96522
child 553279 7fd9b797a83ea0e87994e0546b92b5c033385f7a
push id2165
push userffxbld-merge
push dateMon, 14 Oct 2019 16:30:58 +0000
treeherdermozilla-release@0eae18af659f [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersmythmon
bugs1436111
milestone70.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1436111 - Implement addon rollout/rollback actions for Normandy r=mythmon Differential Revision: https://phabricator.services.mozilla.com/D41479
toolkit/components/normandy/Normandy.jsm
toolkit/components/normandy/actions/AddonRollbackAction.jsm
toolkit/components/normandy/actions/AddonRolloutAction.jsm
toolkit/components/normandy/actions/schemas/index.js
toolkit/components/normandy/actions/schemas/package.json
toolkit/components/normandy/lib/ActionsManager.jsm
toolkit/components/normandy/lib/AddonRollouts.jsm
toolkit/components/normandy/lib/NormandyAddonManager.jsm
toolkit/components/normandy/test/browser/browser.ini
toolkit/components/normandy/test/browser/browser_AddonRollouts.js
toolkit/components/normandy/test/browser/browser_Normandy.js
toolkit/components/normandy/test/browser/browser_NormandyAddonManager.js
toolkit/components/normandy/test/browser/browser_actions_AddonRollbackAction.js
toolkit/components/normandy/test/browser/browser_actions_AddonRolloutAction.js
toolkit/components/telemetry/Events.yaml
--- a/toolkit/components/normandy/Normandy.jsm
+++ b/toolkit/components/normandy/Normandy.jsm
@@ -6,16 +6,17 @@
 const { Log } = ChromeUtils.import("resource://gre/modules/Log.jsm");
 const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
 const { XPCOMUtils } = ChromeUtils.import(
   "resource://gre/modules/XPCOMUtils.jsm"
 );
 
 XPCOMUtils.defineLazyModuleGetters(this, {
   AboutPages: "resource://normandy-content/AboutPages.jsm",
+  AddonRollouts: "resource://normandy/lib/AddonRollouts.jsm",
   AddonStudies: "resource://normandy/lib/AddonStudies.jsm",
   CleanupManager: "resource://normandy/lib/CleanupManager.jsm",
   LogManager: "resource://normandy/lib/LogManager.jsm",
   NormandyMigrations: "resource://normandy/NormandyMigrations.jsm",
   PreferenceExperiments: "resource://normandy/lib/PreferenceExperiments.jsm",
   PreferenceRollouts: "resource://normandy/lib/PreferenceRollouts.jsm",
   RecipeRunner: "resource://normandy/lib/RecipeRunner.jsm",
   ShieldPreferences: "resource://normandy/lib/ShieldPreferences.jsm",
@@ -96,16 +97,22 @@ var Normandy = {
 
     try {
       await PreferenceRollouts.init();
     } catch (err) {
       log.error("Failed to initialize preference rollouts:", err);
     }
 
     try {
+      await AddonRollouts.init();
+    } catch (err) {
+      log.error("Failed to initialize addon rollouts:", err);
+    }
+
+    try {
       await PreferenceExperiments.init();
     } catch (err) {
       log.error("Failed to initialize preference experiments:", err);
     }
 
     try {
       ShieldPreferences.init();
     } catch (err) {
new file mode 100644
--- /dev/null
+++ b/toolkit/components/normandy/actions/AddonRollbackAction.jsm
@@ -0,0 +1,84 @@
+/* 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.import(
+  "resource://gre/modules/XPCOMUtils.jsm"
+);
+const { BaseAction } = ChromeUtils.import(
+  "resource://normandy/actions/BaseAction.jsm"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+  ActionSchemas: "resource://normandy/actions/schemas/index.js",
+  AddonManager: "resource://gre/modules/AddonManager.jsm",
+  AddonRollouts: "resource://normandy/lib/AddonRollouts.jsm",
+  TelemetryEnvironment: "resource://gre/modules/TelemetryEnvironment.jsm",
+  TelemetryEvents: "resource://normandy/lib/TelemetryEvents.jsm",
+});
+
+var EXPORTED_SYMBOLS = ["AddonRollbackAction"];
+
+class AddonRollbackAction extends BaseAction {
+  get schema() {
+    return ActionSchemas["addon-rollback"];
+  }
+
+  async _run(recipe) {
+    const { rolloutSlug } = recipe.arguments;
+    const rollout = await AddonRollouts.get(rolloutSlug);
+
+    if (!rollout) {
+      this.log.debug(`Rollback ${rolloutSlug} not applicable, skipping`);
+      return;
+    }
+
+    switch (rollout.state) {
+      case AddonRollouts.STATE_ACTIVE: {
+        await AddonRollouts.update({
+          ...rollout,
+          state: AddonRollouts.STATE_ROLLED_BACK,
+        });
+
+        const addon = await AddonManager.getAddonByID(rollout.addonId);
+        if (addon) {
+          try {
+            await addon.uninstall();
+          } catch (err) {
+            TelemetryEvents.sendEvent(
+              "unenrollFailed",
+              "addon_rollback",
+              rolloutSlug,
+              { reason: "uninstall-failed" }
+            );
+            throw err;
+          }
+        } else {
+          this.log.warn(
+            `Could not uninstall addon ${
+              rollout.addonId
+            } for rollback ${rolloutSlug}: it is not installed.`
+          );
+        }
+
+        TelemetryEvents.sendEvent("unenroll", "addon_rollback", rolloutSlug, {
+          reason: "rollback",
+        });
+        TelemetryEnvironment.setExperimentInactive(rolloutSlug);
+        break;
+      }
+
+      case AddonRollouts.STATE_ROLLED_BACK: {
+        return; // Do nothing
+      }
+
+      default: {
+        throw new Error(
+          `Unexpected state when rolling back ${rolloutSlug}: ${rollout.state}`
+        );
+      }
+    }
+  }
+}
new file mode 100644
--- /dev/null
+++ b/toolkit/components/normandy/actions/AddonRolloutAction.jsm
@@ -0,0 +1,231 @@
+/* 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.import(
+  "resource://gre/modules/XPCOMUtils.jsm"
+);
+const { BaseAction } = ChromeUtils.import(
+  "resource://normandy/actions/BaseAction.jsm"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+  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",
+  Services: "resource://gre/modules/Services.jsm",
+  TelemetryEnvironment: "resource://gre/modules/TelemetryEnvironment.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 ActionSchemas["addon-rollout"];
+  }
+
+  async _run(recipe) {
+    const { extensionApiId, slug } = recipe.arguments;
+
+    const existingRollout = await AddonRollouts.get(slug);
+    const eventName = existingRollout ? "update" : "enroll";
+    const extensionDetails = await NormandyApi.fetchExtensionDetails(
+      extensionApiId
+    );
+
+    // 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 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,
+      });
+      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"));
+        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"));
+        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 AddonRollouts.update({
+          ...existingRollout,
+          ...details,
+        });
+      } else {
+        await AddonRollouts.add({
+          recipeId: recipe.id,
+          state: AddonRollouts.STATE_ACTIVE,
+          slug,
+          ...details,
+        });
+      }
+    };
+
+    const undoNormandyChanges = async () => {
+      if (existingRollout) {
+        await AddonRollouts.update(existingRollout);
+      } else {
+        await AddonRollouts.delete(recipe.id);
+      }
+    };
+
+    const [
+      installedId,
+      installedVersion,
+    ] = await 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}`);
+      TelemetryEnvironment.setExperimentActive(
+        slug,
+        AddonRollouts.STATE_ACTIVE,
+        {
+          type: "normandy-addonrollout",
+        }
+      );
+    }
+
+    // All done, report success to Telemetry
+    TelemetryEvents.sendEvent(eventName, "addon_rollout", slug, {
+      addonId: installedId,
+      addonVersion: installedVersion,
+    });
+  }
+
+  reportError(error, eventName) {
+    if (error instanceof AddonRolloutError) {
+      // One of our known errors. Report it nicely to telemetry
+      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}`;
+      TelemetryEvents.sendEvent(eventName, "addon_rollout", error.slug, {
+        reason: safeErrorMessage.slice(0, 80), // max length is 80 chars
+      });
+    }
+  }
+}
--- a/toolkit/components/normandy/actions/schemas/index.js
+++ b/toolkit/components/normandy/actions/schemas/index.js
@@ -97,16 +97,50 @@ const ActionSchemas = {
       isEnrollmentPaused: {
         description: "If true, new users will not be enrolled in the study.",
         type: "boolean",
         default: false,
       },
     },
   },
 
+  "addon-rollout": {
+    $schema: "http://json-schema.org/draft-04/schema#",
+    title: "Install add-on permanently",
+    type: "object",
+    required: ["extensionApiId", "slug"],
+    properties: {
+      extensionApiId: {
+        description:
+          "The record ID of the extension used for Normandy API calls.",
+        type: "integer",
+      },
+      slug: {
+        description:
+          "Unique identifer for the rollout, used in telemetry and rollbacks.",
+        type: "string",
+        pattern: "^[a-z0-9\\-_]+$",
+      },
+    },
+  },
+
+  "addon-rollback": {
+    $schema: "http://json-schema.org/draft-04/schema#",
+    title: "Undo an add-on rollout",
+    type: "object",
+    required: ["rolloutSlug"],
+    properties: {
+      rolloutSlug: {
+        description: "Unique identifer for the rollout to undo.",
+        type: "string",
+        pattern: "^[a-z0-9\\-_]+$",
+      },
+    },
+  },
+
   "branched-addon-study": {
     $schema: "http://json-schema.org/draft-04/schema#",
     title: "Enroll a user in an add-on experiment, with managed branches",
     type: "object",
     required: ["slug", "userFacingName", "userFacingDescription", "branches"],
     properties: {
       slug: {
         description: "Machine-readable identifier",
--- a/toolkit/components/normandy/actions/schemas/package.json
+++ b/toolkit/components/normandy/actions/schemas/package.json
@@ -1,11 +1,11 @@
 {
   "name": "@mozilla/normandy-action-argument-schemas",
-  "version": "0.8.1",
+  "version": "0.8.2",
   "description": "Schemas for Normandy action arguments",
   "main": "index.js",
   "author": "Michael Cooper <mcooper@mozilla.com>",
   "license": "MPL-2.0",
   "scripts": {
     "prepack": "node export_json.js"
   }
 }
--- a/toolkit/components/normandy/lib/ActionsManager.jsm
+++ b/toolkit/components/normandy/lib/ActionsManager.jsm
@@ -5,16 +5,18 @@
 const { XPCOMUtils } = ChromeUtils.import(
   "resource://gre/modules/XPCOMUtils.jsm"
 );
 const { LogManager } = ChromeUtils.import(
   "resource://normandy/lib/LogManager.jsm"
 );
 
 XPCOMUtils.defineLazyModuleGetters(this, {
+  AddonRollbackAction: "resource://normandy/actions/AddonRollbackAction.jsm",
+  AddonRolloutAction: "resource://normandy/actions/AddonRolloutAction.jsm",
   AddonStudyAction: "resource://normandy/actions/AddonStudyAction.jsm",
   BranchedAddonStudyAction:
     "resource://normandy/actions/BranchedAddonStudyAction.jsm",
   ConsoleLogAction: "resource://normandy/actions/ConsoleLogAction.jsm",
   PreferenceExperimentAction:
     "resource://normandy/actions/PreferenceExperimentAction.jsm",
   PreferenceRollbackAction:
     "resource://normandy/actions/PreferenceRollbackAction.jsm",
@@ -27,16 +29,18 @@ XPCOMUtils.defineLazyModuleGetters(this,
 });
 
 var EXPORTED_SYMBOLS = ["ActionsManager"];
 
 const log = LogManager.getLogger("recipe-runner");
 
 const actionConstructors = {
   "addon-study": AddonStudyAction,
+  "addon-rollback": AddonRollbackAction,
+  "addon-rollout": AddonRolloutAction,
   "branched-addon-study": BranchedAddonStudyAction,
   "console-log": ConsoleLogAction,
   "multi-preference-experiment": PreferenceExperimentAction,
   "preference-rollback": PreferenceRollbackAction,
   "preference-rollout": PreferenceRolloutAction,
   "show-heartbeat": ShowHeartbeatAction,
   "single-preference-experiment": SinglePreferenceExperimentAction,
 };
new file mode 100644
--- /dev/null
+++ b/toolkit/components/normandy/lib/AddonRollouts.jsm
@@ -0,0 +1,177 @@
+/* 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";
+
+ChromeUtils.defineModuleGetter(
+  this,
+  "IndexedDB",
+  "resource://gre/modules/IndexedDB.jsm"
+);
+ChromeUtils.defineModuleGetter(
+  this,
+  "TelemetryEnvironment",
+  "resource://gre/modules/TelemetryEnvironment.jsm"
+);
+
+/**
+ * AddonRollouts store info about an active or expired addon rollouts.
+ * @typedef {object} AddonRollout
+ * @property {int} recipeId
+ *   The ID of the recipe.
+ * @property {string} slug
+ *   Unique slug of the rollout.
+ * @property {string} state
+ *   The current state of the rollout: "active", or "rolled-back".
+ *   Active means that Normandy is actively managing therollout. Rolled-back
+ *   means that the rollout was previously active, but has been rolled back for
+ *   this user.
+ * @property {int} extensionApiId
+ *   The ID used to look up the extension in Normandy's API.
+ * @property {string} addonId
+ *   The add-on ID for this particular rollout.
+ * @property {string} addonVersion
+ *   The rollout add-on version number
+ * @property {string} xpiUrl
+ *   URL that the add-on was installed from.
+ * @property {string} xpiHash
+ *   The hash of the XPI file.
+ * @property {string} xpiHashAlgorithm
+ *   The algorithm used to hash the XPI file.
+ */
+
+var EXPORTED_SYMBOLS = ["AddonRollouts"];
+const DB_NAME = "normandy-addon-rollout";
+const STORE_NAME = "addon-rollouts";
+const DB_OPTIONS = { version: 1 };
+
+/**
+ * Create a new connection to the database.
+ */
+function openDatabase() {
+  return IndexedDB.open(DB_NAME, DB_OPTIONS, db => {
+    db.createObjectStore(STORE_NAME, {
+      keyPath: "slug",
+    });
+  });
+}
+
+/**
+ * Cache the database connection so that it is shared among multiple operations.
+ */
+let databasePromise;
+function getDatabase() {
+  if (!databasePromise) {
+    databasePromise = openDatabase();
+  }
+  return databasePromise;
+}
+
+/**
+ * Get a transaction for interacting with the rollout store.
+ *
+ * @param {IDBDatabase} db
+ * @param {String} mode Either "readonly" or "readwrite"
+ *
+ * NOTE: Methods on the store returned by this function MUST be called
+ * synchronously, otherwise the transaction with the store will expire.
+ * This is why the helper takes a database as an argument; if we fetched the
+ * database in the helper directly, the helper would be async and the
+ * transaction would expire before methods on the store were called.
+ */
+function getStore(db, mode) {
+  if (!mode) {
+    throw new Error("mode is required");
+  }
+  return db.objectStore(STORE_NAME, mode);
+}
+
+const AddonRollouts = {
+  STATE_ACTIVE: "active",
+  STATE_ROLLED_BACK: "rolled-back",
+
+  async init() {
+    for (const rollout of await this.getAllActive()) {
+      TelemetryEnvironment.setExperimentActive(rollout.slug, rollout.state, {
+        type: "normandy-addonrollout",
+      });
+    }
+  },
+
+  /**
+   * Add a new rollout
+   * @param {AddonRollout} rollout
+   */
+  async add(rollout) {
+    const db = await getDatabase();
+    return getStore(db, "readwrite").add(rollout);
+  },
+
+  /**
+   * Update an existing rollout
+   * @param {AddonRollout} rollout
+   * @throws If a matching rollout does not exist.
+   */
+  async update(rollout) {
+    if (!(await this.has(rollout.slug))) {
+      throw new Error(
+        `Tried to update ${rollout.slug}, but it doesn't already exist.`
+      );
+    }
+    const db = await getDatabase();
+    return getStore(db, "readwrite").put(rollout);
+  },
+
+  /**
+   * Test whether there is a rollout in storage with the given slug.
+   * @param {string} slug
+   * @returns {boolean}
+   */
+  async has(slug) {
+    const db = await getDatabase();
+    const rollout = await getStore(db, "readonly").get(slug);
+    return !!rollout;
+  },
+
+  /**
+   * Get a rollout by slug
+   * @param {string} slug
+   */
+  async get(slug) {
+    const db = await getDatabase();
+    return getStore(db, "readonly").get(slug);
+  },
+
+  /** Get all rollouts in the database. */
+  async getAll() {
+    const db = await getDatabase();
+    return getStore(db, "readonly").getAll();
+  },
+
+  /** Get all rollouts in the "active" state. */
+  async getAllActive() {
+    const rollouts = await this.getAll();
+    return rollouts.filter(rollout => rollout.state === this.STATE_ACTIVE);
+  },
+
+  /**
+   * Test wrapper that temporarily replaces the stored rollout data with fake
+   * data for testing.
+   */
+  withTestMock(testFunction) {
+    return async function inner(...args) {
+      let db = await getDatabase();
+      const oldData = await getStore(db, "readonly").getAll();
+      await getStore(db, "readwrite").clear();
+      try {
+        await testFunction(...args);
+      } finally {
+        db = await getDatabase();
+        await getStore(db, "readwrite").clear();
+        const store = getStore(db, "readwrite");
+        await Promise.all(oldData.map(d => store.add(d)));
+      }
+    };
+  },
+};
new file mode 100644
--- /dev/null
+++ b/toolkit/components/normandy/lib/NormandyAddonManager.jsm
@@ -0,0 +1,123 @@
+/* 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.import(
+  "resource://gre/modules/XPCOMUtils.jsm"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+  AddonManager: "resource://gre/modules/AddonManager.jsm",
+  PromiseUtils: "resource://gre/modules/PromiseUtils.jsm",
+});
+
+var EXPORTED_SYMBOLS = ["NormandyAddonManager"];
+
+const NormandyAddonManager = {
+  async downloadAndInstall({
+    createError,
+    extensionDetails,
+    applyNormandyChanges,
+    undoNormandyChanges,
+    onInstallStarted,
+    reportError,
+  }) {
+    const {
+      extension_id,
+      hash,
+      hash_algorithm,
+      version,
+      xpi,
+    } = extensionDetails;
+
+    const downloadDeferred = PromiseUtils.defer();
+    const installDeferred = PromiseUtils.defer();
+
+    const install = await AddonManager.getInstallForURL(xpi, {
+      hash: `${hash_algorithm}:${hash}`,
+      telemetryInfo: { source: "internal" },
+    });
+
+    const listener = {
+      onInstallStarted(cbInstall) {
+        const versionMatches = cbInstall.addon.version === version;
+        const idMatches = cbInstall.addon.id === extension_id;
+
+        if (!versionMatches || !idMatches) {
+          installDeferred.reject(createError("metadata-mismatch"));
+          return false; // cancel the installation, server metadata does not match downloaded add-on
+        }
+
+        if (onInstallStarted) {
+          return onInstallStarted(cbInstall, installDeferred);
+        }
+
+        return true;
+      },
+
+      onDownloadFailed() {
+        downloadDeferred.reject(
+          createError("download-failure", {
+            detail: AddonManager.errorToString(install.error),
+          })
+        );
+      },
+
+      onDownloadEnded() {
+        downloadDeferred.resolve();
+        return false; // temporarily pause installation for Normandy bookkeeping
+      },
+
+      onInstallFailed() {
+        installDeferred.reject(
+          createError("install-failure", {
+            detail: AddonManager.errorToString(install.error),
+          })
+        );
+      },
+
+      onInstallEnded() {
+        installDeferred.resolve();
+      },
+    };
+
+    install.addListener(listener);
+
+    // Download the add-on
+    try {
+      install.install();
+      await downloadDeferred.promise;
+    } catch (err) {
+      reportError(err);
+      install.removeListener(listener);
+      throw err;
+    }
+
+    // Complete any book-keeping
+    try {
+      await applyNormandyChanges(install);
+    } catch (err) {
+      reportError(err);
+      install.removeListener(listener);
+      install.cancel();
+      throw err;
+    }
+
+    // Finish paused installation
+    try {
+      install.install();
+      await installDeferred.promise;
+    } catch (err) {
+      reportError(err);
+      install.removeListener(listener);
+      await undoNormandyChanges();
+      throw err;
+    }
+
+    install.removeListener(listener);
+
+    return [install.addon.id, install.addon.version];
+  },
+};
--- a/toolkit/components/normandy/test/browser/browser.ini
+++ b/toolkit/components/normandy/test/browser/browser.ini
@@ -7,33 +7,37 @@ support-files =
   addons/normandydriver-a-2.0.xpi
 generated-files =
   addons/normandydriver-a-1.0.xpi
   addons/normandydriver-b-1.0.xpi
   addons/normandydriver-a-2.0.xpi
 head = head.js
 [browser_about_preferences.js]
 [browser_about_studies.js]
+[browser_actions_AddonRollbackAction.js]
+[browser_actions_AddonRolloutAction.js]
 [browser_actions_AddonStudyAction.js]
 [browser_actions_BranchedAddonStudyAction.js]
 [browser_actions_ConsoleLogAction.js]
 [browser_actions_PreferenceExperimentAction.js]
 [browser_actions_PreferenceRolloutAction.js]
 [browser_actions_PreferenceRollbackAction.js]
 [browser_actions_ShowHeartbeatAction.js]
 [browser_actions_SinglePreferenceExperimentAction.js]
 [browser_ActionsManager.js]
+[browser_AddonRollouts.js]
 [browser_AddonStudies.js]
 skip-if = (verify && (os == 'linux'))
 [browser_BaseAction.js]
 [browser_CleanupManager.js]
 [browser_ClientEnvironment.js]
 [browser_EventEmitter.js]
 [browser_Heartbeat.js]
 [browser_LogManager.js]
 [browser_Normandy.js]
+[browser_NormandyAddonManager.js]
 [browser_NormandyMigrations.js]
 [browser_PreferenceExperiments.js]
 [browser_PreferenceRollouts.js]
 [browser_RecipeRunner.js]
 [browser_ShieldPreferences.js]
 [browser_Storage.js]
 [browser_Uptake.js]
new file mode 100644
--- /dev/null
+++ b/toolkit/components/normandy/test/browser/browser_AddonRollouts.js
@@ -0,0 +1,136 @@
+"use strict";
+
+ChromeUtils.import("resource://gre/modules/IndexedDB.jsm", this);
+ChromeUtils.import("resource://gre/modules/TelemetryEnvironment.jsm", this);
+ChromeUtils.import("resource://normandy/lib/AddonRollouts.jsm", this);
+ChromeUtils.import("resource://normandy/lib/TelemetryEvents.jsm", this);
+
+decorate_task(AddonRollouts.withTestMock, async function testGetMissing() {
+  is(
+    await AddonRollouts.get("does-not-exist"),
+    null,
+    "get should return null when the requested rollout does not exist"
+  );
+});
+
+decorate_task(AddonRollouts.withTestMock, async function testAddUpdateAndGet() {
+  const rollout = {
+    slug: "test-rollout",
+    state: AddonRollouts.STATE_ACTIVE,
+    extension: {},
+  };
+  await AddonRollouts.add(rollout);
+  let storedRollout = await AddonRollouts.get(rollout.slug);
+  Assert.deepEqual(
+    rollout,
+    storedRollout,
+    "get should retrieve a rollout from storage."
+  );
+
+  rollout.state = AddonRollouts.STATE_ROLLED_BACK;
+  await AddonRollouts.update(rollout);
+  storedRollout = await AddonRollouts.get(rollout.slug);
+  Assert.deepEqual(
+    rollout,
+    storedRollout,
+    "get should retrieve a rollout from storage."
+  );
+});
+
+decorate_task(
+  AddonRollouts.withTestMock,
+  async function testCantUpdateNonexistent() {
+    const rollout = {
+      slug: "test-rollout",
+      state: AddonRollouts.STATE_ACTIVE,
+      extensions: {},
+    };
+    await Assert.rejects(
+      AddonRollouts.update(rollout),
+      /doesn't already exist/,
+      "Update should fail if the rollout doesn't exist"
+    );
+    ok(
+      !(await AddonRollouts.has("test-rollout")),
+      "rollout should not have been added"
+    );
+  }
+);
+
+decorate_task(AddonRollouts.withTestMock, async function testGetAll() {
+  const rollout1 = { slug: "test-rollout-1", extension: {} };
+  const rollout2 = { slug: "test-rollout-2", extension: {} };
+  await AddonRollouts.add(rollout1);
+  await AddonRollouts.add(rollout2);
+
+  const storedRollouts = await AddonRollouts.getAll();
+  Assert.deepEqual(
+    storedRollouts.sort((a, b) => a.id - b.id),
+    [rollout1, rollout2],
+    "getAll should return every stored rollout."
+  );
+});
+
+decorate_task(AddonRollouts.withTestMock, async function testGetAllActive() {
+  const rollout1 = {
+    slug: "test-rollout-1",
+    state: AddonRollouts.STATE_ACTIVE,
+  };
+  const rollout3 = {
+    slug: "test-rollout-2",
+    state: AddonRollouts.STATE_ROLLED_BACK,
+  };
+  await AddonRollouts.add(rollout1);
+  await AddonRollouts.add(rollout3);
+
+  const activeRollouts = await AddonRollouts.getAllActive();
+  Assert.deepEqual(
+    activeRollouts,
+    [rollout1],
+    "getAllActive should return only active rollouts"
+  );
+});
+
+decorate_task(AddonRollouts.withTestMock, async function testHas() {
+  const rollout = { slug: "test-rollout", extensions: {} };
+  await AddonRollouts.add(rollout);
+  ok(
+    await AddonRollouts.has(rollout.slug),
+    "has should return true for an existing rollout"
+  );
+  ok(
+    !(await AddonRollouts.has("does not exist")),
+    "has should return false for a missing rollout"
+  );
+});
+
+// init should mark active rollouts in telemetry
+decorate_task(
+  AddonRollouts.withTestMock,
+  withStub(TelemetryEnvironment, "setExperimentActive"),
+  async function testInitTelemetry(setExperimentActiveStub) {
+    await AddonRollouts.add({
+      slug: "test-rollout-active-1",
+      state: AddonRollouts.STATE_ACTIVE,
+    });
+    await AddonRollouts.add({
+      slug: "test-rollout-active-2",
+      state: AddonRollouts.STATE_ACTIVE,
+    });
+    await AddonRollouts.add({
+      slug: "test-rollout-rolled-back",
+      state: AddonRollouts.STATE_ROLLED_BACK,
+    });
+
+    await AddonRollouts.init();
+
+    Assert.deepEqual(
+      setExperimentActiveStub.args,
+      [
+        ["test-rollout-active-1", "active", { type: "normandy-addonrollout" }],
+        ["test-rollout-active-2", "active", { type: "normandy-addonrollout" }],
+      ],
+      "init should set activate a telemetry experiment for active addons"
+    );
+  }
+);
--- a/toolkit/components/normandy/test/browser/browser_Normandy.js
+++ b/toolkit/components/normandy/test/browser/browser_Normandy.js
@@ -1,26 +1,28 @@
 "use strict";
 
 ChromeUtils.import("resource://normandy/Normandy.jsm", this);
+ChromeUtils.import("resource://normandy/lib/AddonRollouts.jsm", this);
 ChromeUtils.import("resource://normandy/lib/AddonStudies.jsm", this);
 ChromeUtils.import("resource://normandy/lib/PreferenceExperiments.jsm", this);
 ChromeUtils.import("resource://normandy/lib/PreferenceRollouts.jsm", this);
 ChromeUtils.import("resource://normandy/lib/RecipeRunner.jsm", this);
 ChromeUtils.import("resource://normandy/lib/TelemetryEvents.jsm", this);
 ChromeUtils.import("resource://normandy-content/AboutPages.jsm", this);
 
 const experimentPref1 = "test.initExperimentPrefs1";
 const experimentPref2 = "test.initExperimentPrefs2";
 const experimentPref3 = "test.initExperimentPrefs3";
 const experimentPref4 = "test.initExperimentPrefs4";
 
 function withStubInits(testFunction) {
   return decorate(
     withStub(AboutPages, "init"),
+    withStub(AddonRollouts, "init"),
     withStub(AddonStudies, "init"),
     withStub(PreferenceRollouts, "init"),
     withStub(PreferenceExperiments, "init"),
     withStub(RecipeRunner, "init"),
     withStub(TelemetryEvents, "init"),
     () => testFunction()
   );
 }
@@ -207,46 +209,49 @@ decorate_task(withStubInits, async funct
 });
 
 decorate_task(withStubInits, async function testStartupPrefInitFail() {
   PreferenceExperiments.init.rejects();
 
   await Normandy.finishInit();
   ok(AboutPages.init.called, "startup calls AboutPages.init");
   ok(AddonStudies.init.called, "startup calls AddonStudies.init");
+  ok(AddonRollouts.init.called, "startup calls AddonRollouts.init");
   ok(
     PreferenceExperiments.init.called,
     "startup calls PreferenceExperiments.init"
   );
   ok(RecipeRunner.init.called, "startup calls RecipeRunner.init");
   ok(TelemetryEvents.init.called, "startup calls TelemetryEvents.init");
   ok(PreferenceRollouts.init.called, "startup calls PreferenceRollouts.init");
 });
 
 decorate_task(withStubInits, async function testStartupAboutPagesInitFail() {
   AboutPages.init.rejects();
 
   await Normandy.finishInit();
   ok(AboutPages.init.called, "startup calls AboutPages.init");
   ok(AddonStudies.init.called, "startup calls AddonStudies.init");
+  ok(AddonRollouts.init.called, "startup calls AddonRollouts.init");
   ok(
     PreferenceExperiments.init.called,
     "startup calls PreferenceExperiments.init"
   );
   ok(RecipeRunner.init.called, "startup calls RecipeRunner.init");
   ok(TelemetryEvents.init.called, "startup calls TelemetryEvents.init");
   ok(PreferenceRollouts.init.called, "startup calls PreferenceRollouts.init");
 });
 
 decorate_task(withStubInits, async function testStartupAddonStudiesInitFail() {
   AddonStudies.init.rejects();
 
   await Normandy.finishInit();
   ok(AboutPages.init.called, "startup calls AboutPages.init");
   ok(AddonStudies.init.called, "startup calls AddonStudies.init");
+  ok(AddonRollouts.init.called, "startup calls AddonRollouts.init");
   ok(
     PreferenceExperiments.init.called,
     "startup calls PreferenceExperiments.init"
   );
   ok(RecipeRunner.init.called, "startup calls RecipeRunner.init");
   ok(TelemetryEvents.init.called, "startup calls TelemetryEvents.init");
   ok(PreferenceRollouts.init.called, "startup calls PreferenceRollouts.init");
 });
@@ -254,16 +259,17 @@ decorate_task(withStubInits, async funct
 decorate_task(
   withStubInits,
   async function testStartupTelemetryEventsInitFail() {
     TelemetryEvents.init.throws();
 
     await Normandy.finishInit();
     ok(AboutPages.init.called, "startup calls AboutPages.init");
     ok(AddonStudies.init.called, "startup calls AddonStudies.init");
+    ok(AddonRollouts.init.called, "startup calls AddonRollouts.init");
     ok(
       PreferenceExperiments.init.called,
       "startup calls PreferenceExperiments.init"
     );
     ok(RecipeRunner.init.called, "startup calls RecipeRunner.init");
     ok(TelemetryEvents.init.called, "startup calls TelemetryEvents.init");
     ok(PreferenceRollouts.init.called, "startup calls PreferenceRollouts.init");
   }
@@ -272,16 +278,17 @@ decorate_task(
 decorate_task(
   withStubInits,
   async function testStartupPreferenceRolloutsInitFail() {
     PreferenceRollouts.init.throws();
 
     await Normandy.finishInit();
     ok(AboutPages.init.called, "startup calls AboutPages.init");
     ok(AddonStudies.init.called, "startup calls AddonStudies.init");
+    ok(AddonRollouts.init.called, "startup calls AddonRollouts.init");
     ok(
       PreferenceExperiments.init.called,
       "startup calls PreferenceExperiments.init"
     );
     ok(RecipeRunner.init.called, "startup calls RecipeRunner.init");
     ok(TelemetryEvents.init.called, "startup calls TelemetryEvents.init");
     ok(PreferenceRollouts.init.called, "startup calls PreferenceRollouts.init");
   }
new file mode 100644
--- /dev/null
+++ b/toolkit/components/normandy/test/browser/browser_NormandyAddonManager.js
@@ -0,0 +1,188 @@
+"use strict";
+
+ChromeUtils.import("resource://gre/modules/PromiseUtils.jsm", this);
+ChromeUtils.import("resource://normandy/lib/NormandyAddonManager.jsm", this);
+
+decorate_task(ensureAddonCleanup, async function download_and_install() {
+  const applyDeferred = PromiseUtils.defer();
+
+  const [addonId, addonVersion] = await NormandyAddonManager.downloadAndInstall(
+    {
+      extensionDetails: {
+        extension_id: FIXTURE_ADDON_ID,
+        hash: FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].hash,
+        hash_algorithm: "sha256",
+        version: "1.0",
+        xpi: FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].url,
+      },
+      applyNormandyChanges: () => {
+        applyDeferred.resolve();
+      },
+      createError: () => {},
+      reportError: () => {},
+      undoNormandyChanges: () => {},
+    }
+  );
+
+  // Ensure applyNormandyChanges was called
+  await applyDeferred;
+
+  const addon = await AddonManager.getAddonByID(FIXTURE_ADDON_ID);
+  is(addon.id, addonId, "add-on is installed");
+  is(addon.version, addonVersion, "add-on version is correct");
+
+  // Cleanup
+  await addon.uninstall();
+});
+
+decorate_task(ensureAddonCleanup, async function id_mismatch() {
+  const applyDeferred = PromiseUtils.defer();
+  const undoDeferred = PromiseUtils.defer();
+
+  let error;
+
+  try {
+    await NormandyAddonManager.downloadAndInstall({
+      extensionDetails: {
+        extension_id: FIXTURE_ADDON_ID,
+        hash: FIXTURE_ADDON_DETAILS["normandydriver-b-1.0"].hash,
+        hash_algorithm: "sha256",
+        version: "1.0",
+        xpi: FIXTURE_ADDON_DETAILS["normandydriver-b-1.0"].url,
+      },
+      applyNormandyChanges: () => {
+        applyDeferred.resolve();
+      },
+      createError: (reason, extra) => {
+        return [reason, extra];
+      },
+      reportError: err => {
+        return err;
+      },
+      undoNormandyChanges: () => {
+        undoDeferred.resolve();
+      },
+    });
+  } catch ([reason, extra]) {
+    error = true;
+    is(reason, "metadata-mismatch", "the expected reason is provided");
+    Assert.deepEqual(
+      extra,
+      undefined,
+      "the expected extra details are provided"
+    );
+  }
+
+  ok(error, "an error occured");
+
+  // Ensure applyNormandyChanges was called
+  await applyDeferred;
+
+  // Ensure undoNormandyChanges was called
+  await undoDeferred;
+
+  const addon = await AddonManager.getAddonByID(FIXTURE_ADDON_ID);
+  is(addon, undefined, "add-on is not installed");
+});
+
+decorate_task(ensureAddonCleanup, async function version_mismatch() {
+  const applyDeferred = PromiseUtils.defer();
+  const undoDeferred = PromiseUtils.defer();
+
+  let error;
+
+  try {
+    await NormandyAddonManager.downloadAndInstall({
+      extensionDetails: {
+        extension_id: FIXTURE_ADDON_ID,
+        hash: FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].hash,
+        hash_algorithm: "sha256",
+        version: "2.0",
+        xpi: FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].url,
+      },
+      applyNormandyChanges: () => {
+        applyDeferred.resolve();
+      },
+      createError: (reason, extra) => {
+        return [reason, extra];
+      },
+      reportError: err => {
+        return err;
+      },
+      undoNormandyChanges: () => {
+        undoDeferred.resolve();
+      },
+    });
+  } catch ([reason, extra]) {
+    error = true;
+    is(reason, "metadata-mismatch", "the expected reason is provided");
+    Assert.deepEqual(
+      extra,
+      undefined,
+      "the expected extra details are provided"
+    );
+  }
+
+  ok(error, "should throw an error");
+
+  // Ensure applyNormandyChanges was called
+  await applyDeferred;
+
+  // Ensure undoNormandyChanges was called
+  await undoDeferred;
+
+  const addon = await AddonManager.getAddonByID(FIXTURE_ADDON_ID);
+  is(addon, undefined, "add-on is not installed");
+});
+
+decorate_task(ensureAddonCleanup, async function download_failure() {
+  const applyDeferred = PromiseUtils.defer();
+  const undoDeferred = PromiseUtils.defer();
+
+  let error;
+
+  try {
+    await NormandyAddonManager.downloadAndInstall({
+      extensionDetails: {
+        extension_id: FIXTURE_ADDON_ID,
+        hash: FIXTURE_ADDON_DETAILS["normandydriver-b-1.0"].hash,
+        hash_algorithm: "sha256",
+        version: "1.0",
+        xpi: FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].url,
+      },
+      applyNormandyChanges: () => {
+        applyDeferred.resolve();
+      },
+      createError: (reason, extra) => {
+        return [reason, extra];
+      },
+      reportError: err => {
+        return err;
+      },
+      undoNormandyChanges: () => {
+        undoDeferred.resolve();
+      },
+    });
+  } catch ([reason, extra]) {
+    error = true;
+    is(reason, "download-failure", "the expected reason is provided");
+    Assert.deepEqual(
+      extra,
+      {
+        detail: "ERROR_INCORRECT_HASH",
+      },
+      "the expected extra details are provided"
+    );
+  }
+
+  ok(error, "an error occured");
+
+  // Ensure applyNormandyChanges was called
+  await applyDeferred;
+
+  // Ensure undoNormandyChanges was called
+  await undoDeferred;
+
+  const addon = await AddonManager.getAddonByID(FIXTURE_ADDON_ID);
+  is(addon, undefined, "add-on is not installed");
+});
new file mode 100644
--- /dev/null
+++ b/toolkit/components/normandy/test/browser/browser_actions_AddonRollbackAction.js
@@ -0,0 +1,208 @@
+"use strict";
+
+ChromeUtils.import("resource://gre/modules/Services.jsm", this);
+ChromeUtils.import("resource://gre/modules/TelemetryEnvironment.jsm", this);
+ChromeUtils.import("resource://normandy/actions/AddonRollbackAction.jsm", this);
+ChromeUtils.import("resource://normandy/actions/AddonRolloutAction.jsm", this);
+ChromeUtils.import("resource://normandy/lib/AddonRollouts.jsm", this);
+ChromeUtils.import("resource://normandy/lib/TelemetryEvents.jsm", this);
+
+// Test that a simple recipe unenrolls as expected
+decorate_task(
+  AddonRollouts.withTestMock,
+  ensureAddonCleanup,
+  withMockNormandyApi,
+  withStub(TelemetryEnvironment, "setExperimentInactive"),
+  withSendEventStub,
+  async function simple_recipe_unenrollment(
+    mockApi,
+    setExperimentInactiveStub,
+    sendEventStub
+  ) {
+    const rolloutRecipe = {
+      id: 1,
+      arguments: {
+        slug: "test-rollout",
+        extensionApiId: 1,
+      },
+    };
+    mockApi.extensionDetails = {
+      [rolloutRecipe.arguments.extensionApiId]: extensionDetailsFactory({
+        id: rolloutRecipe.arguments.extensionApiId,
+      }),
+    };
+
+    const webExtStartupPromise = AddonTestUtils.promiseWebExtensionStartup(
+      FIXTURE_ADDON_ID
+    );
+
+    const rolloutAction = new AddonRolloutAction();
+    await rolloutAction.runRecipe(rolloutRecipe);
+    is(rolloutAction.lastError, null, "lastError should be null");
+
+    await webExtStartupPromise;
+
+    const rollbackRecipe = {
+      id: 2,
+      arguments: {
+        rolloutSlug: "test-rollout",
+      },
+    };
+
+    const rollbackAction = new AddonRollbackAction();
+    await rollbackAction.runRecipe(rollbackRecipe);
+
+    const addon = await AddonManager.getAddonByID(FIXTURE_ADDON_ID);
+    is(addon, undefined, "add-on is uninstalled");
+
+    Assert.deepEqual(
+      await AddonRollouts.getAll(),
+      [
+        {
+          recipeId: rolloutRecipe.id,
+          slug: "test-rollout",
+          state: AddonRollouts.STATE_ROLLED_BACK,
+          extensionApiId: 1,
+          addonId: FIXTURE_ADDON_ID,
+          addonVersion: "1.0",
+          xpiUrl: FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].url,
+          xpiHash: FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].hash,
+          xpiHashAlgorithm: "sha256",
+        },
+      ],
+      "Rollback should be stored in db"
+    );
+
+    sendEventStub.assertEvents([
+      ["enroll", "addon_rollout", rollbackRecipe.arguments.rolloutSlug],
+      ["unenroll", "addon_rollback", rollbackRecipe.arguments.rolloutSlug],
+    ]);
+
+    Assert.deepEqual(
+      setExperimentInactiveStub.args,
+      [["test-rollout"]],
+      "the telemetry experiment should deactivated"
+    );
+  }
+);
+
+// Add-on already uninstalled
+decorate_task(
+  AddonRollouts.withTestMock,
+  ensureAddonCleanup,
+  withMockNormandyApi,
+  withSendEventStub,
+  async function addon_already_uninstalled(mockApi, sendEventStub) {
+    const rolloutRecipe = {
+      id: 1,
+      arguments: {
+        slug: "test-rollout",
+        extensionApiId: 1,
+      },
+    };
+    mockApi.extensionDetails = {
+      [rolloutRecipe.arguments.extensionApiId]: extensionDetailsFactory({
+        id: rolloutRecipe.arguments.extensionApiId,
+      }),
+    };
+
+    const webExtStartupPromise = AddonTestUtils.promiseWebExtensionStartup(
+      FIXTURE_ADDON_ID
+    );
+
+    const rolloutAction = new AddonRolloutAction();
+    await rolloutAction.runRecipe(rolloutRecipe);
+    is(rolloutAction.lastError, null, "lastError should be null");
+
+    await webExtStartupPromise;
+
+    const rollbackRecipe = {
+      id: 2,
+      arguments: {
+        rolloutSlug: "test-rollout",
+      },
+    };
+
+    let addon = await AddonManager.getAddonByID(FIXTURE_ADDON_ID);
+    await addon.uninstall();
+
+    const rollbackAction = new AddonRollbackAction();
+    await rollbackAction.runRecipe(rollbackRecipe);
+
+    addon = await AddonManager.getAddonByID(FIXTURE_ADDON_ID);
+    is(addon, undefined, "add-on is uninstalled");
+
+    Assert.deepEqual(
+      await AddonRollouts.getAll(),
+      [
+        {
+          recipeId: rolloutRecipe.id,
+          slug: "test-rollout",
+          state: AddonRollouts.STATE_ROLLED_BACK,
+          extensionApiId: 1,
+          addonId: FIXTURE_ADDON_ID,
+          addonVersion: "1.0",
+          xpiUrl: FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].url,
+          xpiHash: FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].hash,
+          xpiHashAlgorithm: "sha256",
+        },
+      ],
+      "Rollback should be stored in db"
+    );
+
+    sendEventStub.assertEvents([
+      ["enroll", "addon_rollout", rollbackRecipe.arguments.rolloutSlug],
+      ["unenroll", "addon_rollback", rollbackRecipe.arguments.rolloutSlug],
+    ]);
+  }
+);
+
+// Already rolled back, do nothing
+decorate_task(
+  AddonRollouts.withTestMock,
+  ensureAddonCleanup,
+  withMockNormandyApi,
+  withSendEventStub,
+  async function already_rolled_back(mockApi, sendEventStub) {
+    const rollout = {
+      recipeId: 1,
+      slug: "test-rollout",
+      state: AddonRollouts.STATE_ROLLED_BACK,
+      extensionApiId: 1,
+      addonId: FIXTURE_ADDON_ID,
+      addonVersion: "1.0",
+      xpiUrl: FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].url,
+      xpiHash: FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].hash,
+      xpiHashAlgorithm: "sha256",
+    };
+    AddonRollouts.add(rollout);
+
+    const action = new AddonRollbackAction();
+    await action.runRecipe({
+      id: 2,
+      arguments: {
+        rolloutSlug: "test-rollout",
+      },
+    });
+
+    Assert.deepEqual(
+      await AddonRollouts.getAll(),
+      [
+        {
+          recipeId: 1,
+          slug: "test-rollout",
+          state: AddonRollouts.STATE_ROLLED_BACK,
+          extensionApiId: 1,
+          addonId: FIXTURE_ADDON_ID,
+          addonVersion: "1.0",
+          xpiUrl: FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].url,
+          xpiHash: FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].hash,
+          xpiHashAlgorithm: "sha256",
+        },
+      ],
+      "Rollback should be stored in db"
+    );
+
+    sendEventStub.assertEvents([]);
+  }
+);
new file mode 100644
--- /dev/null
+++ b/toolkit/components/normandy/test/browser/browser_actions_AddonRolloutAction.js
@@ -0,0 +1,486 @@
+"use strict";
+
+ChromeUtils.import("resource://gre/modules/Services.jsm", this);
+ChromeUtils.import("resource://gre/modules/TelemetryEnvironment.jsm", this);
+ChromeUtils.import("resource://normandy/actions/AddonRolloutAction.jsm", this);
+ChromeUtils.import("resource://normandy/lib/AddonRollouts.jsm", this);
+ChromeUtils.import("resource://normandy/lib/TelemetryEvents.jsm", this);
+
+// Test that a simple recipe enrolls as expected
+decorate_task(
+  AddonRollouts.withTestMock,
+  ensureAddonCleanup,
+  withMockNormandyApi,
+  withStub(TelemetryEnvironment, "setExperimentActive"),
+  withSendEventStub,
+  async function simple_recipe_enrollment(
+    mockApi,
+    setExperimentActiveStub,
+    sendEventStub
+  ) {
+    const recipe = {
+      id: 1,
+      arguments: {
+        slug: "test-rollout",
+        extensionApiId: 1,
+      },
+    };
+    mockApi.extensionDetails = {
+      [recipe.arguments.extensionApiId]: extensionDetailsFactory({
+        id: recipe.arguments.extensionApiId,
+      }),
+    };
+
+    const webExtStartupPromise = AddonTestUtils.promiseWebExtensionStartup(
+      FIXTURE_ADDON_ID
+    );
+
+    const action = new AddonRolloutAction();
+    await action.runRecipe(recipe);
+    is(action.lastError, null, "lastError should be null");
+
+    await webExtStartupPromise;
+
+    // addon was installed
+    const addon = await AddonManager.getAddonByID(FIXTURE_ADDON_ID);
+    is(addon.id, FIXTURE_ADDON_ID, "addon should be installed");
+
+    // rollout was stored
+    Assert.deepEqual(
+      await AddonRollouts.getAll(),
+      [
+        {
+          recipeId: recipe.id,
+          slug: "test-rollout",
+          state: AddonRollouts.STATE_ACTIVE,
+          extensionApiId: 1,
+          addonId: FIXTURE_ADDON_ID,
+          addonVersion: "1.0",
+          xpiUrl: FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].url,
+          xpiHash: FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].hash,
+          xpiHashAlgorithm: "sha256",
+        },
+      ],
+      "Rollout should be stored in db"
+    );
+
+    sendEventStub.assertEvents([
+      ["enroll", "addon_rollout", recipe.arguments.slug],
+    ]);
+    Assert.deepEqual(
+      setExperimentActiveStub.args,
+      [["test-rollout", "active", { type: "normandy-addonrollout" }]],
+      "a telemetry experiment should be activated"
+    );
+
+    // cleanup installed addon
+    await addon.uninstall();
+  }
+);
+
+// Test that a rollout can update the addon
+decorate_task(
+  AddonRollouts.withTestMock,
+  ensureAddonCleanup,
+  withMockNormandyApi,
+  withSendEventStub,
+  async function update_rollout(mockApi, sendEventStub) {
+    // first enrollment
+    const recipe = {
+      id: 1,
+      arguments: {
+        slug: "test-rollout",
+        extensionApiId: 1,
+      },
+    };
+    mockApi.extensionDetails = {
+      [recipe.arguments.extensionApiId]: extensionDetailsFactory({
+        id: recipe.arguments.extensionApiId,
+      }),
+      2: extensionDetailsFactory({
+        id: 2,
+        xpi: FIXTURE_ADDON_DETAILS["normandydriver-a-2.0"].url,
+        version: "2.0",
+        hash: FIXTURE_ADDON_DETAILS["normandydriver-a-2.0"].hash,
+      }),
+    };
+
+    let webExtStartupPromise = AddonTestUtils.promiseWebExtensionStartup(
+      FIXTURE_ADDON_ID
+    );
+
+    let action = new AddonRolloutAction();
+    await action.runRecipe(recipe);
+    is(action.lastError, null, "lastError should be null");
+
+    await webExtStartupPromise;
+
+    // addon was installed
+    let addon = await AddonManager.getAddonByID(FIXTURE_ADDON_ID);
+    is(addon.id, FIXTURE_ADDON_ID, "addon should be installed");
+    is(addon.version, "1.0", "addon should be the correct version");
+
+    // update existing enrollment
+    recipe.arguments.extensionApiId = 2;
+    webExtStartupPromise = AddonTestUtils.promiseWebExtensionStartup(
+      FIXTURE_ADDON_ID
+    );
+    action = new AddonRolloutAction();
+    await action.runRecipe(recipe);
+    is(action.lastError, null, "lastError should be null");
+
+    await webExtStartupPromise;
+
+    addon = await AddonManager.getAddonByID(FIXTURE_ADDON_ID);
+    is(addon.id, FIXTURE_ADDON_ID, "addon should still be installed");
+    is(addon.version, "2.0", "addon should be the correct version");
+
+    // rollout in the DB has been updated
+    Assert.deepEqual(
+      await AddonRollouts.getAll(),
+      [
+        {
+          recipeId: recipe.id,
+          slug: "test-rollout",
+          state: AddonRollouts.STATE_ACTIVE,
+          extensionApiId: 2,
+          addonId: FIXTURE_ADDON_ID,
+          addonVersion: "2.0",
+          xpiUrl: FIXTURE_ADDON_DETAILS["normandydriver-a-2.0"].url,
+          xpiHash: FIXTURE_ADDON_DETAILS["normandydriver-a-2.0"].hash,
+          xpiHashAlgorithm: "sha256",
+        },
+      ],
+      "Rollout should be stored in db"
+    );
+
+    sendEventStub.assertEvents([
+      ["enroll", "addon_rollout", "test-rollout"],
+      ["update", "addon_rollout", "test-rollout"],
+    ]);
+
+    // Cleanup
+    await addon.uninstall();
+  }
+);
+
+// Re-running a recipe does nothing
+decorate_task(
+  AddonRollouts.withTestMock,
+  ensureAddonCleanup,
+  withMockNormandyApi,
+  withSendEventStub,
+  async function rerun_recipe(mockApi, sendEventStub) {
+    const recipe = {
+      id: 1,
+      arguments: {
+        slug: "test-rollout",
+        extensionApiId: 1,
+      },
+    };
+    mockApi.extensionDetails = {
+      [recipe.arguments.extensionApiId]: extensionDetailsFactory({
+        id: recipe.arguments.extensionApiId,
+      }),
+    };
+
+    const webExtStartupPromise = AddonTestUtils.promiseWebExtensionStartup(
+      FIXTURE_ADDON_ID
+    );
+
+    let action = new AddonRolloutAction();
+    await action.runRecipe(recipe);
+    is(action.lastError, null, "lastError should be null");
+
+    await webExtStartupPromise;
+
+    // addon was installed
+    let addon = await AddonManager.getAddonByID(FIXTURE_ADDON_ID);
+    is(addon.id, FIXTURE_ADDON_ID, "addon should be installed");
+    is(addon.version, "1.0", "addon should be the correct version");
+
+    // re-run the same recipe
+    action = new AddonRolloutAction();
+    await action.runRecipe(recipe);
+    is(action.lastError, null, "lastError should be null");
+
+    addon = await AddonManager.getAddonByID(FIXTURE_ADDON_ID);
+    is(addon.id, FIXTURE_ADDON_ID, "addon should still be installed");
+    is(addon.version, "1.0", "addon should be the correct version");
+
+    // rollout in the DB has not been updated
+    Assert.deepEqual(
+      await AddonRollouts.getAll(),
+      [
+        {
+          recipeId: recipe.id,
+          slug: "test-rollout",
+          state: AddonRollouts.STATE_ACTIVE,
+          extensionApiId: 1,
+          addonId: FIXTURE_ADDON_ID,
+          addonVersion: "1.0",
+          xpiUrl: FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].url,
+          xpiHash: FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].hash,
+          xpiHashAlgorithm: "sha256",
+        },
+      ],
+      "Rollout should be stored in db"
+    );
+
+    sendEventStub.assertEvents([["enroll", "addon_rollout", "test-rollout"]]);
+
+    // Cleanup
+    await addon.uninstall();
+  }
+);
+
+// Conflicting rollouts
+decorate_task(
+  AddonRollouts.withTestMock,
+  ensureAddonCleanup,
+  withMockNormandyApi,
+  withSendEventStub,
+  async function conflicting_rollout(mockApi, sendEventStub) {
+    const recipe = {
+      id: 1,
+      arguments: {
+        slug: "test-rollout",
+        extensionApiId: 1,
+      },
+    };
+    mockApi.extensionDetails = {
+      [recipe.arguments.extensionApiId]: extensionDetailsFactory({
+        id: recipe.arguments.extensionApiId,
+      }),
+    };
+
+    const webExtStartupPromise = AddonTestUtils.promiseWebExtensionStartup(
+      FIXTURE_ADDON_ID
+    );
+
+    let action = new AddonRolloutAction();
+    await action.runRecipe(recipe);
+    is(action.lastError, null, "lastError should be null");
+
+    await webExtStartupPromise;
+
+    // addon was installed
+    let addon = await AddonManager.getAddonByID(FIXTURE_ADDON_ID);
+    is(addon.id, FIXTURE_ADDON_ID, "addon should be installed");
+    is(addon.version, "1.0", "addon should be the correct version");
+
+    // update existing enrollment
+    action = new AddonRolloutAction();
+    await action.runRecipe({
+      ...recipe,
+      id: 2,
+      arguments: {
+        ...recipe.arguments,
+        slug: "test-conflict",
+      },
+    });
+    is(action.lastError, null, "lastError should be null");
+
+    addon = await AddonManager.getAddonByID(FIXTURE_ADDON_ID);
+    is(addon.id, FIXTURE_ADDON_ID, "addon should still be installed");
+    is(addon.version, "1.0", "addon should be the correct version");
+
+    // rollout in the DB has not been updated
+    Assert.deepEqual(
+      await AddonRollouts.getAll(),
+      [
+        {
+          recipeId: recipe.id,
+          slug: "test-rollout",
+          state: AddonRollouts.STATE_ACTIVE,
+          extensionApiId: 1,
+          addonId: FIXTURE_ADDON_ID,
+          addonVersion: "1.0",
+          xpiUrl: FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].url,
+          xpiHash: FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].hash,
+          xpiHashAlgorithm: "sha256",
+        },
+      ],
+      "Rollout should be stored in db"
+    );
+
+    sendEventStub.assertEvents([
+      ["enroll", "addon_rollout", "test-rollout"],
+      ["enrollFailed", "addon_rollout", "test-conflict"],
+    ]);
+
+    // Cleanup
+    await addon.uninstall();
+  }
+);
+
+// Add-on ID changed
+decorate_task(
+  AddonRollouts.withTestMock,
+  ensureAddonCleanup,
+  withMockNormandyApi,
+  withSendEventStub,
+  async function enroll_failed_addon_id_changed(mockApi, sendEventStub) {
+    const recipe = {
+      id: 1,
+      arguments: {
+        slug: "test-rollout",
+        extensionApiId: 1,
+      },
+    };
+    mockApi.extensionDetails = {
+      [recipe.arguments.extensionApiId]: extensionDetailsFactory({
+        id: recipe.arguments.extensionApiId,
+      }),
+      2: extensionDetailsFactory({
+        id: 2,
+        extension_id: "normandydriver-b@example.com",
+        xpi: FIXTURE_ADDON_DETAILS["normandydriver-b-1.0"].url,
+        version: "1.0",
+        hash: FIXTURE_ADDON_DETAILS["normandydriver-b-1.0"].hash,
+      }),
+    };
+
+    const webExtStartupPromise = AddonTestUtils.promiseWebExtensionStartup(
+      FIXTURE_ADDON_ID
+    );
+
+    let action = new AddonRolloutAction();
+    await action.runRecipe(recipe);
+    is(action.lastError, null, "lastError should be null");
+
+    await webExtStartupPromise;
+
+    // addon was installed
+    let addon = await AddonManager.getAddonByID(FIXTURE_ADDON_ID);
+    is(addon.id, FIXTURE_ADDON_ID, "addon should be installed");
+    is(addon.version, "1.0", "addon should be the correct version");
+
+    // update existing enrollment
+    recipe.arguments.extensionApiId = 2;
+    action = new AddonRolloutAction();
+    await action.runRecipe(recipe);
+    is(action.lastError, null, "lastError should be null");
+
+    addon = await AddonManager.getAddonByID(FIXTURE_ADDON_ID);
+    is(addon.id, FIXTURE_ADDON_ID, "addon should still be installed");
+    is(addon.version, "1.0", "addon should be the correct version");
+
+    // rollout in the DB has not been updated
+    Assert.deepEqual(
+      await AddonRollouts.getAll(),
+      [
+        {
+          recipeId: recipe.id,
+          slug: "test-rollout",
+          state: AddonRollouts.STATE_ACTIVE,
+          extensionApiId: 1,
+          addonId: FIXTURE_ADDON_ID,
+          addonVersion: "1.0",
+          xpiUrl: FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].url,
+          xpiHash: FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].hash,
+          xpiHashAlgorithm: "sha256",
+        },
+      ],
+      "Rollout should be stored in db"
+    );
+
+    sendEventStub.assertEvents([
+      ["enroll", "addon_rollout", "test-rollout"],
+      [
+        "updateFailed",
+        "addon_rollout",
+        "test-rollout",
+        { reason: "addon-id-changed" },
+      ],
+    ]);
+
+    // Cleanup
+    await addon.uninstall();
+  }
+);
+
+// Add-on upgrade required
+decorate_task(
+  AddonRollouts.withTestMock,
+  ensureAddonCleanup,
+  withMockNormandyApi,
+  withSendEventStub,
+  async function enroll_failed_upgrade_required(mockApi, sendEventStub) {
+    const recipe = {
+      id: 1,
+      arguments: {
+        slug: "test-rollout",
+        extensionApiId: 1,
+      },
+    };
+    mockApi.extensionDetails = {
+      [recipe.arguments.extensionApiId]: extensionDetailsFactory({
+        id: recipe.arguments.extensionApiId,
+        xpi: FIXTURE_ADDON_DETAILS["normandydriver-a-2.0"].url,
+        version: "2.0",
+        hash: FIXTURE_ADDON_DETAILS["normandydriver-a-2.0"].hash,
+      }),
+      2: extensionDetailsFactory({
+        id: 2,
+      }),
+    };
+
+    const webExtStartupPromise = AddonTestUtils.promiseWebExtensionStartup(
+      FIXTURE_ADDON_ID
+    );
+
+    let action = new AddonRolloutAction();
+    await action.runRecipe(recipe);
+    is(action.lastError, null, "lastError should be null");
+
+    await webExtStartupPromise;
+
+    // addon was installed
+    let addon = await AddonManager.getAddonByID(FIXTURE_ADDON_ID);
+    is(addon.id, FIXTURE_ADDON_ID, "addon should be installed");
+    is(addon.version, "2.0", "addon should be the correct version");
+
+    // update existing enrollment
+    recipe.arguments.extensionApiId = 2;
+    action = new AddonRolloutAction();
+    await action.runRecipe(recipe);
+    is(action.lastError, null, "lastError should be null");
+
+    addon = await AddonManager.getAddonByID(FIXTURE_ADDON_ID);
+    is(addon.id, FIXTURE_ADDON_ID, "addon should still be installed");
+    is(addon.version, "2.0", "addon should be the correct version");
+
+    // rollout in the DB has not been updated
+    Assert.deepEqual(
+      await AddonRollouts.getAll(),
+      [
+        {
+          recipeId: recipe.id,
+          slug: "test-rollout",
+          state: AddonRollouts.STATE_ACTIVE,
+          extensionApiId: 1,
+          addonId: FIXTURE_ADDON_ID,
+          addonVersion: "2.0",
+          xpiUrl: FIXTURE_ADDON_DETAILS["normandydriver-a-2.0"].url,
+          xpiHash: FIXTURE_ADDON_DETAILS["normandydriver-a-2.0"].hash,
+          xpiHashAlgorithm: "sha256",
+        },
+      ],
+      "Rollout should be stored in db"
+    );
+
+    sendEventStub.assertEvents([
+      ["enroll", "addon_rollout", "test-rollout"],
+      [
+        "updateFailed",
+        "addon_rollout",
+        "test-rollout",
+        { reason: "upgrade-required" },
+      ],
+    ]);
+
+    // Cleanup
+    await addon.uninstall();
+  }
+);
--- a/toolkit/components/telemetry/Events.yaml
+++ b/toolkit/components/telemetry/Events.yaml
@@ -404,17 +404,17 @@ urlbar:
       - "fx-search@mozilla.com"
     expiry_version: never
     extra_keys:
       elapsed: abandonement time in milliseconds.
       numChars: number of input characters.
 
 normandy:
   enroll:
-    objects: ["preference_study", "addon_study", "preference_rollout"]
+    objects: ["preference_study", "addon_study", "preference_rollout", "addon_rollout"]
     description: >
       Sent when applying a Normandy recipe of the above types has succeeded.
     extra_keys:
       experimentType: >
         For preference_study recipes, the type of experiment this is ("exp" or "exp-highpop").
       branch: >
         The slug of the branch that was chosen for this client.
       addonId: For addon_study recipes, the ID of the addon that was installed.
@@ -426,40 +426,42 @@ normandy:
       - "fennec"
       - "geckoview"
     record_in_processes: [main]
     release_channel_collection: opt-out
     expiry_version: never
 
   enroll_failed:
     methods: ["enrollFailed"]
-    objects: ["addon_study", "preference_rollout", "preference_study"]
+    objects: ["addon_study", "preference_rollout", "preference_study", "addon_rollout"]
     description: >
       Sent when applying a Normandy recipe of the above types has failed.
     extra_keys:
       reason: An error code describing the failure.
       preference: >
         For preference_rollout when reason=conflict, the name of the preference
         that was going to be modified.
       detail: >
         For addon_study and branched_addon study, extra text describing the failure.
       branch: >
         The branch that failed to enroll.
+      addonId: The ID of the addon for the rollout when reason=conflict.
+      conflictingSlug: The slug for the conlicting rollout.
     bug_numbers: [1443560]
     notification_emails: ["normandy-notifications@mozilla.com"]
     products:
       - "firefox"
       - "fennec"
       - "geckoview"
     record_in_processes: [main]
     release_channel_collection: opt-out
     expiry_version: never
 
   update:
-    objects: ["addon_study", "preference_rollout"]
+    objects: ["addon_study", "preference_rollout", "addon_rollout"]
     description: >
       This event is fired when a client detects that a recipe of the
       ahove types has changed on the server, and the new version of the
       recipe is being applied over an existing, older version previously
       fetched from the server.
     extra_keys:
       previousState: >
         For preference_rollout recipes, the state of the rollout that had been applied
@@ -474,17 +476,17 @@ normandy:
       - "fennec"
       - "geckoview"
     record_in_processes: [main]
     release_channel_collection: opt-out
     expiry_version: never
 
   update_failed:
     methods: ["updateFailed"]
-    objects: ["addon_study"]
+    objects: ["addon_study", "addon_rollout"]
     description: >
       Sent when applying a new version of a Normandy recipe of the above types (over an
       existing, older version previously fetched from the server) has failed.
     extra_keys:
       reason: An error code describing the failure.
       detail: >
         Extra text describing the failure. Currently only provided for addon_study.
       branch: The branch that failed to update.
@@ -494,17 +496,17 @@ normandy:
       - "firefox"
       - "fennec"
       - "geckoview"
     record_in_processes: [main]
     release_channel_collection: opt-out
     expiry_version: never
 
   unenroll:
-    objects: ["preference_study", "addon_study", "preference_rollback"]
+    objects: ["preference_study", "addon_study", "preference_rollback", "addon_rollback"]
     description: >
       Sent when a Normandy recipe of certain types "ends".  N.B. For
       preference_rollback, this is fired when the recipe is fired (the
       recipe that "ends" is a corresponding preference_rollout).
     extra_keys:
       reason: A code describing the reason why the recipe ended.
       didResetValue: >
         For preference_study, "true" or "false" according to whether we put the preference back the way it was.
@@ -520,17 +522,17 @@ normandy:
     record_in_processes: [main]
     release_channel_collection: opt-out
     expiry_version: never
 
   unenroll_failed:
     methods: ["unenrollFailed"]
     description: >
       Sent when unenrolling a user fails (see the unenroll event).
-    objects: ["preference_rollback", "preference_study"]
+    objects: ["preference_rollback", "preference_study", "addon_rollback"]
     extra_keys:
       reason: A code describing the reason the unenroll failed.
     bug_numbers: [1443560]
     notification_emails: ["normandy-notifications@mozilla.com"]
     products:
       - "firefox"
       - "fennec"
       - "geckoview"