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