/* 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/. */ import { BaseAction } from "resource://normandy/actions/BaseAction.sys.mjs"; const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { ActionSchemas: "resource://normandy/actions/schemas/index.sys.mjs", NormandyUtils: "resource://normandy/lib/NormandyUtils.sys.mjs", PrefUtils: "resource://normandy/lib/PrefUtils.sys.mjs", PreferenceRollouts: "resource://normandy/lib/PreferenceRollouts.sys.mjs", TelemetryEnvironment: "resource://gre/modules/TelemetryEnvironment.sys.mjs", TelemetryEvents: "resource://normandy/lib/TelemetryEvents.sys.mjs", }); const PREFERENCE_TYPE_MAP = { boolean: Services.prefs.PREF_BOOL, string: Services.prefs.PREF_STRING, number: Services.prefs.PREF_INT, }; export class PreferenceRolloutAction extends BaseAction { get schema() { return lazy.ActionSchemas["preference-rollout"]; } async _run(recipe) { const args = recipe.arguments; // Check if the rollout is on the list of rollouts to stop applying. if (lazy.PreferenceRollouts.GRADUATION_SET.has(args.slug)) { this.log.debug( `Skipping rollout "${args.slug}" because it is in the graduation set.` ); return; } // 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: lazy.PrefUtils.getPref(preferenceName, { branch: "default", }), })), }; const existingRollout = await lazy.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 lazy.PreferenceRollouts.update(newRollout); lazy.TelemetryEvents.sendEvent( "update", "preference_rollout", args.slug, { previousState: existingRollout.state, enrollmentId: existingRollout.enrollmentId || lazy.TelemetryEvents.NO_ENROLLMENT_ID_MARKER, } ); switch (existingRollout.state) { case lazy.PreferenceRollouts.STATE_ACTIVE: { this.log.debug(`Updated preference rollout ${args.slug}`); break; } case lazy.PreferenceRollouts.STATE_GRADUATED: { this.log.debug(`Ungraduated preference rollout ${args.slug}`); lazy.TelemetryEnvironment.setExperimentActive( args.slug, newRollout.state, { type: "normandy-prefrollout" } ); break; } default: { console.error( 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 ) ) { lazy.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 = lazy.NormandyUtils.generateUuid(); newRollout.enrollmentId = enrollmentId; await lazy.PreferenceRollouts.add(newRollout); for (const { preferenceName, value } of args.preferences) { lazy.PrefUtils.setPref(preferenceName, value, { branch: "default" }); } this.log.debug(`Enrolled in preference rollout ${args.slug}`); lazy.TelemetryEnvironment.setExperimentActive( args.slug, newRollout.state, { type: "normandy-prefrollout", enrollmentId: enrollmentId || lazy.TelemetryEvents.NO_ENROLLMENT_ID_MARKER, } ); lazy.TelemetryEvents.sendEvent( "enroll", "preference_rollout", args.slug, { enrollmentId: enrollmentId || lazy.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 lazy.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)) { lazy.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 ) { lazy.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; lazy.PrefUtils.setPref(preferenceName, previousValue, { branch: "default", }); } } // 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}` ); lazy.PrefUtils.setPref(prefSpec.preferenceName, prefSpec.value, { branch: "default", }); } } return anyChanged; } async _finalize() { await lazy.PreferenceRollouts.saveStartupPrefs(); } }