summaryrefslogtreecommitdiffstats
path: root/toolkit/components/normandy/actions/PreferenceRolloutAction.jsm
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/components/normandy/actions/PreferenceRolloutAction.jsm')
-rw-r--r--toolkit/components/normandy/actions/PreferenceRolloutAction.jsm252
1 files changed, 252 insertions, 0 deletions
diff --git a/toolkit/components/normandy/actions/PreferenceRolloutAction.jsm b/toolkit/components/normandy/actions/PreferenceRolloutAction.jsm
new file mode 100644
index 0000000000..4c595f955b
--- /dev/null
+++ b/toolkit/components/normandy/actions/PreferenceRolloutAction.jsm
@@ -0,0 +1,252 @@
+/* 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 { BaseAction } = ChromeUtils.import(
+ "resource://normandy/actions/BaseAction.jsm"
+);
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.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"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "NormandyUtils",
+ "resource://normandy/lib/NormandyUtils.jsm"
+);
+
+var EXPORTED_SYMBOLS = ["PreferenceRolloutAction"];
+
+const PREFERENCE_TYPE_MAP = {
+ boolean: Services.prefs.PREF_BOOL,
+ string: Services.prefs.PREF_STRING,
+ number: Services.prefs.PREF_INT,
+};
+
+class PreferenceRolloutAction extends BaseAction {
+ get schema() {
+ return ActionSchemas["preference-rollout"];
+ }
+
+ async _run(recipe) {
+ const args = recipe.arguments;
+
+ // First determine which preferences are already being managed, to avoid
+ // conflicts between recipes. This will throw if there is a problem.
+ await this._verifyRolloutPrefs(args);
+
+ const newRollout = {
+ slug: args.slug,
+ state: "active",
+ preferences: args.preferences.map(({ preferenceName, value }) => ({
+ preferenceName,
+ value,
+ previousValue: PrefUtils.getPref("default", preferenceName),
+ })),
+ };
+
+ const existingRollout = await PreferenceRollouts.get(args.slug);
+ if (existingRollout) {
+ const anyChanged = await this._updatePrefsForExistingRollout(
+ existingRollout,
+ newRollout
+ );
+
+ // If anything was different about the new rollout, write it to the db and send an event about it
+ if (anyChanged) {
+ await PreferenceRollouts.update(newRollout);
+ TelemetryEvents.sendEvent("update", "preference_rollout", args.slug, {
+ previousState: existingRollout.state,
+ enrollmentId:
+ existingRollout.enrollmentId ||
+ TelemetryEvents.NO_ENROLLMENT_ID_MARKER,
+ });
+
+ switch (existingRollout.state) {
+ case PreferenceRollouts.STATE_ACTIVE: {
+ this.log.debug(`Updated preference rollout ${args.slug}`);
+ break;
+ }
+ case PreferenceRollouts.STATE_GRADUATED: {
+ this.log.debug(`Ungraduated preference rollout ${args.slug}`);
+ TelemetryEnvironment.setExperimentActive(
+ args.slug,
+ newRollout.state,
+ { type: "normandy-prefrollout" }
+ );
+ break;
+ }
+ default: {
+ Cu.reportError(
+ new Error(
+ `Updated pref rollout in unexpected state: ${existingRollout.state}`
+ )
+ );
+ }
+ }
+ } else {
+ this.log.debug(`No updates to preference rollout ${args.slug}`);
+ }
+ } else {
+ // new enrollment
+ // Check if this rollout would be a no-op, which is not allowed.
+ if (
+ newRollout.preferences.every(
+ ({ value, previousValue }) => value === previousValue
+ )
+ ) {
+ TelemetryEvents.sendEvent(
+ "enrollFailed",
+ "preference_rollout",
+ args.slug,
+ { reason: "would-be-no-op" }
+ );
+ // Throw so that this recipe execution is marked as a failure
+ throw new Error(
+ `New rollout ${args.slug} does not change any preferences.`
+ );
+ }
+
+ let enrollmentId = NormandyUtils.generateUuid();
+ newRollout.enrollmentId = enrollmentId;
+
+ await PreferenceRollouts.add(newRollout);
+
+ for (const { preferenceName, value } of args.preferences) {
+ PrefUtils.setPref("default", preferenceName, value);
+ }
+
+ this.log.debug(`Enrolled in preference rollout ${args.slug}`);
+ TelemetryEnvironment.setExperimentActive(args.slug, newRollout.state, {
+ type: "normandy-prefrollout",
+ enrollmentId: enrollmentId || TelemetryEvents.NO_ENROLLMENT_ID_MARKER,
+ });
+ TelemetryEvents.sendEvent("enroll", "preference_rollout", args.slug, {
+ enrollmentId: enrollmentId || TelemetryEvents.NO_ENROLLMENT_ID_MARKER,
+ });
+ }
+ }
+
+ /**
+ * Check that all the preferences in a rollout are ok to set. This means 1) no
+ * other rollout is managing them, and 2) they match the types of the builtin
+ * values.
+ * @param {PreferenceRollout} rollout The arguments from a rollout recipe.
+ * @throws If the preferences are not valid, with details in the error message.
+ */
+ async _verifyRolloutPrefs({ slug, preferences }) {
+ const existingManagedPrefs = new Set();
+ for (const rollout of await PreferenceRollouts.getAllActive()) {
+ if (rollout.slug === slug) {
+ continue;
+ }
+ for (const prefSpec of rollout.preferences) {
+ existingManagedPrefs.add(prefSpec.preferenceName);
+ }
+ }
+
+ for (const prefSpec of preferences) {
+ if (existingManagedPrefs.has(prefSpec.preferenceName)) {
+ TelemetryEvents.sendEvent("enrollFailed", "preference_rollout", slug, {
+ reason: "conflict",
+ preference: prefSpec.preferenceName,
+ });
+ // Throw so that this recipe execution is marked as a failure
+ throw new Error(
+ `Cannot start rollout ${slug}. Preference ${prefSpec.preferenceName} is already managed.`
+ );
+ }
+ const existingPrefType = Services.prefs.getPrefType(
+ prefSpec.preferenceName
+ );
+ const rolloutPrefType = PREFERENCE_TYPE_MAP[typeof prefSpec.value];
+
+ if (
+ existingPrefType !== Services.prefs.PREF_INVALID &&
+ existingPrefType !== rolloutPrefType
+ ) {
+ TelemetryEvents.sendEvent("enrollFailed", "preference_rollout", slug, {
+ reason: "invalid type",
+ preference: prefSpec.preferenceName,
+ });
+ // Throw so that this recipe execution is marked as a failure
+ throw new Error(
+ `Cannot start rollout "${slug}" on "${prefSpec.preferenceName}". ` +
+ `Existing preference is of type ${existingPrefType}, but rollout ` +
+ `specifies type ${rolloutPrefType}`
+ );
+ }
+ }
+ }
+
+ async _updatePrefsForExistingRollout(existingRollout, newRollout) {
+ let anyChanged = false;
+ const oldPrefSpecs = new Map(
+ existingRollout.preferences.map(p => [p.preferenceName, p])
+ );
+ const newPrefSpecs = new Map(
+ newRollout.preferences.map(p => [p.preferenceName, p])
+ );
+
+ // Check for any preferences that no longer exist, and un-set them.
+ for (const { preferenceName, previousValue } of oldPrefSpecs.values()) {
+ if (!newPrefSpecs.has(preferenceName)) {
+ this.log.debug(
+ `updating ${existingRollout.slug}: ${preferenceName} no longer exists`
+ );
+ anyChanged = true;
+ PrefUtils.setPref("default", preferenceName, previousValue);
+ }
+ }
+
+ // Check for any preferences that are new and need added, or changed and need updated.
+ for (const prefSpec of Object.values(newRollout.preferences)) {
+ let oldValue = null;
+ if (oldPrefSpecs.has(prefSpec.preferenceName)) {
+ let oldPrefSpec = oldPrefSpecs.get(prefSpec.preferenceName);
+ oldValue = oldPrefSpec.value;
+
+ // Trust the old rollout for the values of `previousValue`, but don't
+ // consider this a change, since it already matches the DB, and doesn't
+ // have any other stateful effect.
+ prefSpec.previousValue = oldPrefSpec.previousValue;
+ }
+ if (oldValue !== newPrefSpecs.get(prefSpec.preferenceName).value) {
+ anyChanged = true;
+ this.log.debug(
+ `updating ${existingRollout.slug}: ${prefSpec.preferenceName} value changed from ${oldValue} to ${prefSpec.value}`
+ );
+ PrefUtils.setPref("default", prefSpec.preferenceName, prefSpec.value);
+ }
+ }
+ return anyChanged;
+ }
+
+ async _finalize() {
+ await PreferenceRollouts.saveStartupPrefs();
+ }
+}