1/* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4
5"use strict";
6
7const { BaseAction } = ChromeUtils.import(
8  "resource://normandy/actions/BaseAction.jsm"
9);
10const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
11ChromeUtils.defineModuleGetter(
12  this,
13  "TelemetryEnvironment",
14  "resource://gre/modules/TelemetryEnvironment.jsm"
15);
16ChromeUtils.defineModuleGetter(
17  this,
18  "PreferenceRollouts",
19  "resource://normandy/lib/PreferenceRollouts.jsm"
20);
21ChromeUtils.defineModuleGetter(
22  this,
23  "PrefUtils",
24  "resource://normandy/lib/PrefUtils.jsm"
25);
26ChromeUtils.defineModuleGetter(
27  this,
28  "ActionSchemas",
29  "resource://normandy/actions/schemas/index.js"
30);
31ChromeUtils.defineModuleGetter(
32  this,
33  "TelemetryEvents",
34  "resource://normandy/lib/TelemetryEvents.jsm"
35);
36ChromeUtils.defineModuleGetter(
37  this,
38  "NormandyUtils",
39  "resource://normandy/lib/NormandyUtils.jsm"
40);
41
42var EXPORTED_SYMBOLS = ["PreferenceRolloutAction"];
43
44const PREFERENCE_TYPE_MAP = {
45  boolean: Services.prefs.PREF_BOOL,
46  string: Services.prefs.PREF_STRING,
47  number: Services.prefs.PREF_INT,
48};
49
50class PreferenceRolloutAction extends BaseAction {
51  get schema() {
52    return ActionSchemas["preference-rollout"];
53  }
54
55  async _run(recipe) {
56    const args = recipe.arguments;
57
58    // Check if the rollout is on the list of rollouts to stop applying.
59    if (PreferenceRollouts.GRADUATION_SET.has(args.slug)) {
60      this.log.debug(
61        `Skipping rollout "${args.slug}" because it is in the graduation set.`
62      );
63      return;
64    }
65
66    // Determine which preferences are already being managed, to avoid
67    // conflicts between recipes. This will throw if there is a problem.
68    await this._verifyRolloutPrefs(args);
69
70    const newRollout = {
71      slug: args.slug,
72      state: "active",
73      preferences: args.preferences.map(({ preferenceName, value }) => ({
74        preferenceName,
75        value,
76        previousValue: PrefUtils.getPref(preferenceName, { branch: "default" }),
77      })),
78    };
79
80    const existingRollout = await PreferenceRollouts.get(args.slug);
81    if (existingRollout) {
82      const anyChanged = await this._updatePrefsForExistingRollout(
83        existingRollout,
84        newRollout
85      );
86
87      // If anything was different about the new rollout, write it to the db and send an event about it
88      if (anyChanged) {
89        await PreferenceRollouts.update(newRollout);
90        TelemetryEvents.sendEvent("update", "preference_rollout", args.slug, {
91          previousState: existingRollout.state,
92          enrollmentId:
93            existingRollout.enrollmentId ||
94            TelemetryEvents.NO_ENROLLMENT_ID_MARKER,
95        });
96
97        switch (existingRollout.state) {
98          case PreferenceRollouts.STATE_ACTIVE: {
99            this.log.debug(`Updated preference rollout ${args.slug}`);
100            break;
101          }
102          case PreferenceRollouts.STATE_GRADUATED: {
103            this.log.debug(`Ungraduated preference rollout ${args.slug}`);
104            TelemetryEnvironment.setExperimentActive(
105              args.slug,
106              newRollout.state,
107              { type: "normandy-prefrollout" }
108            );
109            break;
110          }
111          default: {
112            Cu.reportError(
113              new Error(
114                `Updated pref rollout in unexpected state: ${existingRollout.state}`
115              )
116            );
117          }
118        }
119      } else {
120        this.log.debug(`No updates to preference rollout ${args.slug}`);
121      }
122    } else {
123      // new enrollment
124      // Check if this rollout would be a no-op, which is not allowed.
125      if (
126        newRollout.preferences.every(
127          ({ value, previousValue }) => value === previousValue
128        )
129      ) {
130        TelemetryEvents.sendEvent(
131          "enrollFailed",
132          "preference_rollout",
133          args.slug,
134          { reason: "would-be-no-op" }
135        );
136        // Throw so that this recipe execution is marked as a failure
137        throw new Error(
138          `New rollout ${args.slug} does not change any preferences.`
139        );
140      }
141
142      let enrollmentId = NormandyUtils.generateUuid();
143      newRollout.enrollmentId = enrollmentId;
144
145      await PreferenceRollouts.add(newRollout);
146
147      for (const { preferenceName, value } of args.preferences) {
148        PrefUtils.setPref(preferenceName, value, { branch: "default" });
149      }
150
151      this.log.debug(`Enrolled in preference rollout ${args.slug}`);
152      TelemetryEnvironment.setExperimentActive(args.slug, newRollout.state, {
153        type: "normandy-prefrollout",
154        enrollmentId: enrollmentId || TelemetryEvents.NO_ENROLLMENT_ID_MARKER,
155      });
156      TelemetryEvents.sendEvent("enroll", "preference_rollout", args.slug, {
157        enrollmentId: enrollmentId || TelemetryEvents.NO_ENROLLMENT_ID_MARKER,
158      });
159    }
160  }
161
162  /**
163   * Check that all the preferences in a rollout are ok to set. This means 1) no
164   * other rollout is managing them, and 2) they match the types of the builtin
165   * values.
166   * @param {PreferenceRollout} rollout The arguments from a rollout recipe.
167   * @throws If the preferences are not valid, with details in the error message.
168   */
169  async _verifyRolloutPrefs({ slug, preferences }) {
170    const existingManagedPrefs = new Set();
171    for (const rollout of await PreferenceRollouts.getAllActive()) {
172      if (rollout.slug === slug) {
173        continue;
174      }
175      for (const prefSpec of rollout.preferences) {
176        existingManagedPrefs.add(prefSpec.preferenceName);
177      }
178    }
179
180    for (const prefSpec of preferences) {
181      if (existingManagedPrefs.has(prefSpec.preferenceName)) {
182        TelemetryEvents.sendEvent("enrollFailed", "preference_rollout", slug, {
183          reason: "conflict",
184          preference: prefSpec.preferenceName,
185        });
186        // Throw so that this recipe execution is marked as a failure
187        throw new Error(
188          `Cannot start rollout ${slug}. Preference ${prefSpec.preferenceName} is already managed.`
189        );
190      }
191      const existingPrefType = Services.prefs.getPrefType(
192        prefSpec.preferenceName
193      );
194      const rolloutPrefType = PREFERENCE_TYPE_MAP[typeof prefSpec.value];
195
196      if (
197        existingPrefType !== Services.prefs.PREF_INVALID &&
198        existingPrefType !== rolloutPrefType
199      ) {
200        TelemetryEvents.sendEvent("enrollFailed", "preference_rollout", slug, {
201          reason: "invalid type",
202          preference: prefSpec.preferenceName,
203        });
204        // Throw so that this recipe execution is marked as a failure
205        throw new Error(
206          `Cannot start rollout "${slug}" on "${prefSpec.preferenceName}". ` +
207            `Existing preference is of type ${existingPrefType}, but rollout ` +
208            `specifies type ${rolloutPrefType}`
209        );
210      }
211    }
212  }
213
214  async _updatePrefsForExistingRollout(existingRollout, newRollout) {
215    let anyChanged = false;
216    const oldPrefSpecs = new Map(
217      existingRollout.preferences.map(p => [p.preferenceName, p])
218    );
219    const newPrefSpecs = new Map(
220      newRollout.preferences.map(p => [p.preferenceName, p])
221    );
222
223    // Check for any preferences that no longer exist, and un-set them.
224    for (const { preferenceName, previousValue } of oldPrefSpecs.values()) {
225      if (!newPrefSpecs.has(preferenceName)) {
226        this.log.debug(
227          `updating ${existingRollout.slug}: ${preferenceName} no longer exists`
228        );
229        anyChanged = true;
230        PrefUtils.setPref(preferenceName, previousValue, { branch: "default" });
231      }
232    }
233
234    // Check for any preferences that are new and need added, or changed and need updated.
235    for (const prefSpec of Object.values(newRollout.preferences)) {
236      let oldValue = null;
237      if (oldPrefSpecs.has(prefSpec.preferenceName)) {
238        let oldPrefSpec = oldPrefSpecs.get(prefSpec.preferenceName);
239        oldValue = oldPrefSpec.value;
240
241        // Trust the old rollout for the values of `previousValue`, but don't
242        // consider this a change, since it already matches the DB, and doesn't
243        // have any other stateful effect.
244        prefSpec.previousValue = oldPrefSpec.previousValue;
245      }
246      if (oldValue !== newPrefSpecs.get(prefSpec.preferenceName).value) {
247        anyChanged = true;
248        this.log.debug(
249          `updating ${existingRollout.slug}: ${prefSpec.preferenceName} value changed from ${oldValue} to ${prefSpec.value}`
250        );
251        PrefUtils.setPref(prefSpec.preferenceName, prefSpec.value, {
252          branch: "default",
253        });
254      }
255    }
256    return anyChanged;
257  }
258
259  async _finalize() {
260    await PreferenceRollouts.saveStartupPrefs();
261  }
262}
263