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");
+ },
+);