Bug 1440782 Part 3 - Add preference-rollback action to Normandy r?Gijs draft
authorMike Cooper <mcooper@mozilla.com>
Thu, 19 Apr 2018 13:18:15 -0700
changeset 785813 fec17e51def88d811665bf8bc3c1129b7ae56a4c
parent 785330 4850c0ace4797ec4c5ae9a63ad8f02b032b4ae17
push id107314
push userbmo:mcooper@mozilla.com
push dateFri, 20 Apr 2018 17:11:12 +0000
reviewersGijs
bugs1440782
milestone61.0a1
Bug 1440782 Part 3 - Add preference-rollback action to Normandy r?Gijs MozReview-Commit-ID: rmXvlBTUmH
toolkit/components/normandy/actions/PreferenceRollbackAction.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/TelemetryEvents.jsm
toolkit/components/normandy/test/browser/browser.ini
toolkit/components/normandy/test/browser/browser_actions_PreferenceRollbackAction.js
new file mode 100644
--- /dev/null
+++ b/toolkit/components/normandy/actions/PreferenceRollbackAction.jsm
@@ -0,0 +1,63 @@
+/* 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.import("resource://normandy/actions/BaseAction.jsm");
+ChromeUtils.import("resource://gre/modules/Services.jsm");
+ChromeUtils.defineModuleGetter(this, "IndexedDB", "resource://gre/modules/IndexedDB.jsm");
+ChromeUtils.defineModuleGetter(this, "TelemetryEnvironment", "resource://gre/modules/TelemetryEnvironment.jsm");
+ChromeUtils.defineModuleGetter(this, "PreferenceRollouts", "resource://normandy/lib/PreferenceRollouts.jsm");
+ChromeUtils.defineModuleGetter(this, "PrefUtils", "resource://normandy/lib/PrefUtils.jsm");
+ChromeUtils.defineModuleGetter(this, "ActionSchemas", "resource://normandy/actions/schemas/index.js");
+ChromeUtils.defineModuleGetter(this, "TelemetryEvents", "resource://normandy/lib/TelemetryEvents.jsm");
+
+var EXPORTED_SYMBOLS = ["PreferenceRollbackAction"];
+
+class PreferenceRollbackAction extends BaseAction {
+  get schema() {
+    return ActionSchemas["preference-rollback"];
+  }
+
+  async _run(recipe) {
+    const {rolloutSlug} = recipe.arguments;
+    const rollout = await PreferenceRollouts.get(rolloutSlug);
+
+    if (!rollout) {
+      TelemetryEvents.sendEvent("unenrollFailure", "preference_rollout", rolloutSlug, {"reason": "rollout missing"});
+      this.log.info(`Cannot rollback ${rolloutSlug}: no rollout found with that slug`);
+      return;
+    }
+
+    switch (rollout.state) {
+      case PreferenceRollouts.STATE_ACTIVE: {
+        this.log.info(`Rolling back ${rolloutSlug}`);
+        rollout.state = PreferenceRollouts.STATE_ROLLED_BACK;
+        for (const {preferenceName, previousValue} of rollout.preferences) {
+          PrefUtils.setPref("default", preferenceName, previousValue);
+        }
+        await PreferenceRollouts.update(rollout);
+        TelemetryEvents.sendEvent("unenroll", "preference_rollout", rolloutSlug, {"reason": "rollback"});
+        TelemetryEnvironment.setExperimentInactive(rolloutSlug);
+      }
+      case PreferenceRollouts.STATE_ROLLED_BACK: {
+        // The rollout has already been rolled back, so nothing to do here.
+        break;
+      }
+      case PreferenceRollouts.STATE_GRADUATED: {
+        // graduated rollouts can't be rolled back
+        TelemetryEvents.sendEvent("unenrollFailure", "preference_rollout", rolloutSlug, {"reason": "graduated"});
+        throw new Error(`Cannot rollback already graduated rollout ${rolloutSlug}`);
+      }
+      default: {
+        throw new Error(`Unexpected state when rolling back ${rolloutSlug}: ${rollout.state}`);
+      }
+    }
+  }
+
+  async _finalize() {
+    await PreferenceRollouts.saveStartupPrefs();
+    await PreferenceRollouts.closeDB();
+  }
+}
--- a/toolkit/components/normandy/actions/schemas/index.js
+++ b/toolkit/components/normandy/actions/schemas/index.js
@@ -42,15 +42,29 @@ const ActionSchemas = {
               description: "Value to set the preference to",
               type: ["string", "number", "boolean"],
             },
           },
         },
       },
     },
   },
+
+  "preference-rollback": {
+    $schema: "http://json-schema.org/draft-04/schema#",
+    title: "Undo a preference rollout",
+    type: "object",
+    required: ["rolloutSlug"],
+    properties: {
+      rolloutSlug: {
+        description: "Unique identifer for the rollout to undo",
+        type: "string",
+        pattern: "^[a-z0-9\\-_]+$",
+      },
+    },
+  },
 };
 
 // If running in Node.js, export the schemas.
 if (typeof module !== "undefined") {
   /* globals module */
   module.exports = ActionSchemas;
 }
--- 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.2.0",
+  "version": "0.3.0",
   "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
@@ -2,16 +2,17 @@ ChromeUtils.import("resource://gre/modul
 ChromeUtils.import("resource://normandy/lib/LogManager.jsm");
 
 XPCOMUtils.defineLazyModuleGetters(this, {
   ActionSandboxManager: "resource://normandy/lib/ActionSandboxManager.jsm",
   NormandyApi: "resource://normandy/lib/NormandyApi.jsm",
   Uptake: "resource://normandy/lib/Uptake.jsm",
   ConsoleLogAction: "resource://normandy/actions/ConsoleLogAction.jsm",
   PreferenceRolloutAction: "resource://normandy/actions/PreferenceRolloutAction.jsm",
+  PreferenceRollbackAction: "resource://normandy/actions/PreferenceRollbackAction.jsm",
 });
 
 var EXPORTED_SYMBOLS = ["ActionsManager"];
 
 const log = LogManager.getLogger("recipe-runner");
 
 /**
  * A class to manage the actions that recipes can use in Normandy.
@@ -25,16 +26,17 @@ const log = LogManager.getLogger("recipe
 class ActionsManager {
   constructor() {
     this.finalized = false;
     this.remoteActionSandboxes = {};
 
     this.localActions = {
       "console-log": new ConsoleLogAction(),
       "preference-rollout": new PreferenceRolloutAction(),
+      "preference-rollback": new PreferenceRollbackAction(),
     };
   }
 
   async fetchRemoteActions() {
     const actions = await NormandyApi.fetchActions();
 
     for (const action of actions) {
       // Skip actions with local implementations
--- a/toolkit/components/normandy/lib/TelemetryEvents.jsm
+++ b/toolkit/components/normandy/lib/TelemetryEvents.jsm
@@ -36,16 +36,23 @@ const TelemetryEvents = {
 
       unenroll: {
         methods: ["unenroll"],
         objects: ["preference_study", "addon_study"],
         extra_keys: ["reason", "didResetValue", "addonId", "addonVersion"],
         record_on_release: true,
       },
 
+      unenroll_failure: {
+        methods: ["unenrollFailed"],
+        objects: ["preference_rollout"],
+        extra_keys: ["reason"],
+        record_on_release: true,
+      },
+
       graduated: {
         methods: ["graduated"],
         objects: ["preference_rollout"],
         extra_keys: [],
         record_on_release: true,
       },
     });
   },
--- a/toolkit/components/normandy/test/browser/browser.ini
+++ b/toolkit/components/normandy/test/browser/browser.ini
@@ -5,16 +5,17 @@ support-files =
 head = head.js
 [browser_about_preferences.js]
 # Skip this test when FHR/Telemetry aren't available.
 skip-if = !healthreport || !telemetry
 [browser_about_studies.js]
 skip-if = true # bug 1442712
 [browser_actions_ConsoleLogAction.js]
 [browser_actions_PreferenceRolloutAction.js]
+[browser_actions_PreferenceRollbackAction.js]
 [browser_ActionSandboxManager.js]
 [browser_ActionsManager.js]
 [browser_Addons.js]
 [browser_AddonStudies.js]
 [browser_BaseAction.js]
 [browser_CleanupManager.js]
 [browser_ClientEnvironment.js]
 [browser_EventEmitter.js]
new file mode 100644
--- /dev/null
+++ b/toolkit/components/normandy/test/browser/browser_actions_PreferenceRollbackAction.js
@@ -0,0 +1,209 @@
+"use strict";
+
+ChromeUtils.import("resource://gre/modules/Services.jsm", this);
+ChromeUtils.import("resource://gre/modules/Preferences.jsm", this);
+ChromeUtils.import("resource://gre/modules/TelemetryEnvironment.jsm", this);
+ChromeUtils.import("resource://normandy/actions/PreferenceRollbackAction.jsm", this);
+ChromeUtils.import("resource://normandy/lib/Uptake.jsm", this);
+ChromeUtils.import("resource://normandy/lib/PreferenceRollouts.jsm", this);
+ChromeUtils.import("resource://normandy/lib/TelemetryEvents.jsm", this);
+
+// Test that a simple recipe rollsback as expected
+decorate_task(
+  PreferenceRollouts.withTestMock,
+  withStub(TelemetryEnvironment, "setExperimentInactive"),
+  withStub(TelemetryEvents, "sendEvent"),
+  async function simple_rollback(setExperimentInactiveStub, sendEventStub) {
+    Services.prefs.getDefaultBranch("").setIntPref("test.pref1", 2);
+    Services.prefs.getDefaultBranch("").setCharPref("test.pref2", "rollout value");
+    Services.prefs.getDefaultBranch("").setBoolPref("test.pref3", true);
+
+    PreferenceRollouts.add({
+      slug: "test-rollout",
+      state: PreferenceRollouts.STATE_ACTIVE,
+      preferences: [
+        {preferenceName: "test.pref1", value: 2, previousValue: 1},
+        {preferenceName: "test.pref2", value: "rollout value", previousValue: "builtin value"},
+        {preferenceName: "test.pref3", value: true, previousValue: false},
+      ],
+    });
+
+    const recipe = {id: 1, arguments: {rolloutSlug: "test-rollout"}};
+
+    const action = new PreferenceRollbackAction();
+    await action.runRecipe(recipe);
+    await action.finalize();
+
+    // rollout prefs are reset
+    is(Services.prefs.getIntPref("test.pref1"), 1, "integer pref should be rolled back");
+    is(Services.prefs.getCharPref("test.pref2"), "builtin value", "string pref should be rolled back");
+    is(Services.prefs.getBoolPref("test.pref3"), false, "boolean pref should be rolled back");
+
+    // start up prefs are unset
+    is(Services.prefs.getPrefType("app.normandy.startupRolloutPrefs.test.pref1"), Services.prefs.PREF_INVALID, "integer startup pref should be unset");
+    is(Services.prefs.getPrefType("app.normandy.startupRolloutPrefs.test.pref2"), Services.prefs.PREF_INVALID, "string startup pref should be unset");
+    is(Services.prefs.getPrefType("app.normandy.startupRolloutPrefs.test.pref3"), Services.prefs.PREF_INVALID, "boolean startup pref should be unset");
+
+    // rollout in db was updated
+    Assert.deepEqual(
+      await PreferenceRollouts.getAll(),
+      [{
+        slug: "test-rollout",
+        state: PreferenceRollouts.STATE_ROLLED_BACK,
+        preferences: [
+          {preferenceName: "test.pref1", value: 2, previousValue: 1},
+          {preferenceName: "test.pref2", value: "rollout value", previousValue: "builtin value"},
+          {preferenceName: "test.pref3", value: true, previousValue: false},
+        ],
+      }],
+      "Rollout should be updated in db"
+    );
+
+    // Telemetry is updated
+    Assert.deepEqual(
+      sendEventStub.args,
+      [["unenroll", "preference_rollout", recipe.arguments.rolloutSlug, {reason: "rollback"}]],
+      "an unenrollment event should be sent"
+    );
+    Assert.deepEqual(setExperimentInactiveStub.args, [["test-rollout"]], "the telemetry experiment should deactivated");
+
+    // Cleanup
+    Services.prefs.getDefaultBranch("").deleteBranch("test.pref1");
+    Services.prefs.getDefaultBranch("").deleteBranch("test.pref2");
+    Services.prefs.getDefaultBranch("").deleteBranch("test.pref3");
+  },
+);
+
+// Test that a graduated rollout can't be rolled back
+decorate_task(
+  PreferenceRollouts.withTestMock,
+  withStub(TelemetryEvents, "sendEvent"),
+  async function cant_rollback_graduated(sendEventStub) {
+    Services.prefs.getDefaultBranch("").setIntPref("test.pref", 1);
+    await PreferenceRollouts.add({
+      slug: "graduated-rollout",
+      state: PreferenceRollouts.STATE_GRADUATED,
+      preferences: [{preferenceName: "test.pref", value: 1, previousValue: 1}],
+    });
+
+    let recipe = {id: 1, arguments: {rolloutSlug: "graduated-rollout"}};
+
+    const action = new PreferenceRollbackAction();
+    await action.runRecipe(recipe);
+    await action.finalize();
+
+    is(Services.prefs.getIntPref("test.pref"), 1, "pref should not change");
+    is(Services.prefs.getPrefType("app.normandy.startupRolloutPrefs.test.pref"), Services.prefs.PREF_INVALID, "no startup pref should be added");
+
+    // rollout in the DB hasn't changed
+    Assert.deepEqual(
+      await PreferenceRollouts.getAll(),
+      [{
+        slug: "graduated-rollout",
+        state: PreferenceRollouts.STATE_GRADUATED,
+        preferences: [{preferenceName: "test.pref", value: 1, previousValue: 1}],
+      }],
+      "Rollout should not change in db"
+    );
+
+    Assert.deepEqual(
+      sendEventStub.args,
+      [["unenrollFailure", "preference_rollout", "graduated-rollout", {reason: "graduated"}]],
+      "correct event was sent"
+    );
+
+    // Cleanup
+    Services.prefs.getDefaultBranch("").deleteBranch("test.pref");
+  },
+);
+
+// Test that a rollback without a matching rollout
+decorate_task(
+  PreferenceRollouts.withTestMock,
+  withStub(TelemetryEvents, "sendEvent"),
+  withStub(Uptake, "reportRecipe"),
+  async function rollback_without_rollout(sendEventStub, reportRecipeStub) {
+    let recipe = {id: 1, arguments: {rolloutSlug: "missing-rollout"}};
+
+    const action = new PreferenceRollbackAction();
+    await action.runRecipe(recipe);
+    await action.finalize();
+
+    Assert.deepEqual(
+      sendEventStub.args,
+      [["unenrollFailure", "preference_rollout", "missing-rollout", {reason: "rollout missing"}]],
+      "an unenrollFailure event should be sent",
+    );
+    // This is too common a case for an error, so it should be reported as success
+    Assert.deepEqual(
+      reportRecipeStub.args,
+      [[recipe.id, Uptake.RECIPE_SUCCESS]],
+      "recipe should be reported as succesful",
+    );
+  },
+);
+
+// Test that rolling back an already rolled back recipe doesn't do anything
+decorate_task(
+  PreferenceRollouts.withTestMock,
+  withStub(TelemetryEnvironment, "setExperimentInactive"),
+  withStub(TelemetryEvents, "sendEvent"),
+  async function rollback_already_rolled_back(setExperimentInactiveStub, sendEventStub) {
+    Services.prefs.getDefaultBranch("").setIntPref("test.pref", 1);
+
+    const recipe = {id: 1, arguments: {rolloutSlug: "test-rollout"}};
+    const rollout = {
+      slug: "test-rollout",
+      state: PreferenceRollouts.STATE_ROLLED_BACK,
+      preferences: [{preferenceName: "test.pref", value: 2, previousValue: 1}],
+    };
+    PreferenceRollouts.add(rollout);
+
+    const action = new PreferenceRollbackAction();
+    await action.runRecipe(recipe);
+    await action.finalize();
+
+    is(Services.prefs.getIntPref("test.pref"), 1, "pref shouldn't change");
+    is(Services.prefs.getPrefType("app.normandy.startupRolloutPrefs.test.pref"), Services.prefs.PREF_INVALID, "startup pref should not be set");
+
+    // rollout in db was updated
+    Assert.deepEqual(await PreferenceRollouts.getAll(), [rollout], "Rollout shouldn't change in db");
+
+    // Telemetry is updated
+    Assert.deepEqual(sendEventStub.args, [], "no telemetry event should be sent");
+    Assert.deepEqual(setExperimentInactiveStub.args, [], "telemetry experiments should not be updated");
+
+    // Cleanup
+    Services.prefs.getDefaultBranch("").deleteBranch("test.pref");
+  },
+);
+
+// Test that a rollback doesn't affect user prefs
+decorate_task(
+  PreferenceRollouts.withTestMock,
+  async function simple_rollback(setExperimentInactiveStub, sendEventStub) {
+    Services.prefs.getDefaultBranch("").setCharPref("test.pref", "rollout value");
+    Services.prefs.setCharPref("test.pref", "user value");
+
+    PreferenceRollouts.add({
+      slug: "test-rollout",
+      state: PreferenceRollouts.STATE_ACTIVE,
+      preferences: [
+        {preferenceName: "test.pref", value: "rollout value", previousValue: "builtin value"},
+      ],
+    });
+
+    const recipe = {id: 1, arguments: {rolloutSlug: "test-rollout"}};
+
+    const action = new PreferenceRollbackAction();
+    await action.runRecipe(recipe);
+    await action.finalize();
+
+    is(Services.prefs.getDefaultBranch("").getCharPref("test.pref"), "builtin value", "default branch should be reset");
+    is(Services.prefs.getCharPref("test.pref"), "user value", "user branch should remain the same");
+
+    // Cleanup
+    Services.prefs.deleteBranch("test.pref");
+    Services.prefs.getDefaultBranch("").deleteBranch("test.pref");
+  },
+);