From c61be0325c7436d3622bb46ffec243682349fb57 Mon Sep 17 00:00:00 2001 From: Thomas Bouldin Date: Thu, 29 Sep 2022 11:28:27 -0700 Subject: [PATCH] Implement Experiments feature (#4994) Add experiments to the Firebase CLI. New commands: ``` firebase experiments:enable firebase experiments:disable firebase experiments:describe firebase experiments:list ``` --- .github/workflows/node-test.yml | 2 - CHANGELOG.md | 1 + src/commands/database-instances-list.ts | 6 +- src/commands/experiments-describe.ts | 32 ++++ src/commands/experiments-disable.ts | 29 +++ src/commands/experiments-enable.ts | 29 +++ src/commands/experiments-list.ts | 25 +++ src/commands/ext-install.ts | 4 +- src/commands/ext-update.ts | 4 +- src/commands/index.ts | 18 +- src/deploy/functions/build.ts | 4 +- src/deploy/functions/prepare.ts | 7 +- src/deploy/functions/release/planner.ts | 14 +- src/deploy/functions/runtimes/index.ts | 5 +- src/deploy/index.ts | 5 +- src/emulator/controller.ts | 26 ++- src/emulator/downloadableEmulators.ts | 10 +- src/emulator/storage/rules/runtime.ts | 2 +- src/experiments.ts | 176 ++++++++++++++++++ src/handlePreviewToggles.ts | 51 +++-- src/init/features/functions/index.ts | 7 - src/init/features/hosting/index.ts | 15 +- src/previews.ts | 39 ---- src/serve/index.ts | 4 +- src/test/database/api.spec.ts | 1 - .../deploy/functions/release/planner.spec.ts | 10 +- src/utils.ts | 22 ++- 27 files changed, 424 insertions(+), 124 deletions(-) create mode 100644 src/commands/experiments-describe.ts create mode 100644 src/commands/experiments-disable.ts create mode 100644 src/commands/experiments-enable.ts create mode 100644 src/commands/experiments-list.ts create mode 100644 src/experiments.ts delete mode 100644 src/previews.ts diff --git a/.github/workflows/node-test.yml b/.github/workflows/node-test.yml index 9e3f165862c..6051e5590ca 100644 --- a/.github/workflows/node-test.yml +++ b/.github/workflows/node-test.yml @@ -67,7 +67,6 @@ jobs: runs-on: ubuntu-latest env: - FIREBASE_CLI_PREVIEWS: none FIREBASE_EMULATORS_PATH: ${{ github.workspace }}/emulator-cache COMMIT_SHA: ${{ github.sha }} CI_JOB_ID: ${{ github.action }} @@ -122,7 +121,6 @@ jobs: runs-on: windows-latest env: - FIREBASE_CLI_PREVIEWS: none FIREBASE_EMULATORS_PATH: ${{ github.workspace }}/emulator-cache COMMIT_SHA: ${{ github.sha }} CI_JOB_ID: ${{ github.action }} diff --git a/CHANGELOG.md b/CHANGELOG.md index e69de29bb2d..677be0e5392 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -0,0 +1 @@ +- Add the "experiments" family of commands (#4994) diff --git a/src/commands/database-instances-list.ts b/src/commands/database-instances-list.ts index 76156a564fc..fe59b786fad 100644 --- a/src/commands/database-instances-list.ts +++ b/src/commands/database-instances-list.ts @@ -9,7 +9,7 @@ import { needProjectNumber } from "../projectUtils"; import firedata = require("../gcp/firedata"); import { Emulators } from "../emulator/types"; import { warnEmulatorNotSupported } from "../emulator/commandUtils"; -import { previews } from "../previews"; +import * as experiments from "../experiments"; import { needProjectId } from "../projectUtils"; import { listDatabaseInstances, @@ -52,7 +52,7 @@ export let command = new Command("database:instances:list") ).start(); let instances; - if (previews.rtdbmanagement) { + if (experiments.isEnabled("rtdbmanagement")) { const projectId = needProjectId(options); try { instances = await listDatabaseInstances(projectId, location); @@ -80,7 +80,7 @@ export let command = new Command("database:instances:list") return instances; }); -if (previews.rtdbmanagement) { +if (experiments.isEnabled("rtdbmanagement")) { command = command.option( "-l, --location ", "(optional) location for the database instance, defaults to us-central1" diff --git a/src/commands/experiments-describe.ts b/src/commands/experiments-describe.ts new file mode 100644 index 00000000000..079bc08ed4a --- /dev/null +++ b/src/commands/experiments-describe.ts @@ -0,0 +1,32 @@ +import { bold } from "colorette"; + +import { Command } from "../command"; +import { FirebaseError } from "../error"; +import * as experiments from "../experiments"; +import { logger } from "../logger"; +import { last } from "../utils"; + +export const command = new Command("experiments:describe ") + .description("enable an experiment on this machine") + .action((experiment: string) => { + if (!experiments.isValidExperiment(experiment)) { + let message = `Cannot find experiment ${bold(experiment)}`; + const potentials = experiments.experimentNameAutocorrect(experiment); + if (potentials.length === 1) { + message = `${message}\nDid you mean ${potentials[0]}?`; + } else if (potentials.length) { + message = `${message}\nDid you mean ${potentials.slice(0, -1).join(",")} or ${last( + potentials + )}?`; + } + throw new FirebaseError(message); + } + + const spec = experiments.ALL_EXPERIMENTS[experiment]; + logger.info(`${bold("Name")}: ${experiment}`); + logger.info(`${bold("Enabled")}: ${experiments.isEnabled(experiment) ? "yes" : "no"}`); + if (spec.docsUri) { + logger.info(`${bold("Documentation")}: ${spec.docsUri}`); + } + logger.info(`${bold("Description")}: ${spec.fullDescription || spec.shortDescription}`); + }); diff --git a/src/commands/experiments-disable.ts b/src/commands/experiments-disable.ts new file mode 100644 index 00000000000..ab8fab40e77 --- /dev/null +++ b/src/commands/experiments-disable.ts @@ -0,0 +1,29 @@ +import { bold } from "colorette"; + +import { Command } from "../command"; +import { FirebaseError } from "../error"; +import * as experiments from "../experiments"; +import { logger } from "../logger"; +import { last } from "../utils"; + +export const command = new Command("experiments:disable ") + .description("disable an experiment on this machine") + .action((experiment: string) => { + if (experiments.isValidExperiment(experiment)) { + experiments.setEnabled(experiment, false); + experiments.flushToDisk(); + logger.info(`Disabled experiment ${bold(experiment)}`); + return; + } + + let message = `Cannot find experiment ${bold(experiment)}`; + const potentials = experiments.experimentNameAutocorrect(experiment); + if (potentials.length === 1) { + message = `${message}\nDid you mean ${potentials[0]}?`; + } else if (potentials.length) { + message = `${message}\nDid you mean ${potentials.slice(0, -1).join(",")} or ${last( + potentials + )}?`; + } + throw new FirebaseError(message); + }); diff --git a/src/commands/experiments-enable.ts b/src/commands/experiments-enable.ts new file mode 100644 index 00000000000..9403bdfc740 --- /dev/null +++ b/src/commands/experiments-enable.ts @@ -0,0 +1,29 @@ +import { bold } from "colorette"; + +import { Command } from "../command"; +import { FirebaseError } from "../error"; +import * as experiments from "../experiments"; +import { logger } from "../logger"; +import { last } from "../utils"; + +export const command = new Command("experiments:enable ") + .description("enable an experiment on this machine") + .action((experiment: string) => { + if (experiments.isValidExperiment(experiment)) { + experiments.setEnabled(experiment, true); + experiments.flushToDisk(); + logger.info(`Enabled experiment ${bold(experiment)}`); + return; + } + + let message = `Cannot find experiment ${bold(experiment)}`; + const potentials = experiments.experimentNameAutocorrect(experiment); + if (potentials.length === 1) { + message = `${message}\nDid you mean ${potentials[0]}?`; + } else if (potentials.length) { + message = `${message}\nDid you mean ${potentials.slice(0, -1).join(",")} or ${last( + potentials + )}?`; + } + throw new FirebaseError(message); + }); diff --git a/src/commands/experiments-list.ts b/src/commands/experiments-list.ts new file mode 100644 index 00000000000..87d4efe8afb --- /dev/null +++ b/src/commands/experiments-list.ts @@ -0,0 +1,25 @@ +import { Command } from "../command"; +import Table = require("cli-table"); +import * as experiments from "../experiments"; +import { partition } from "../functional"; +import { logger } from "../logger"; + +export const command = new Command("experiments:list").action(() => { + const table = new Table({ + head: ["Enabled", "Name", "Description"], + style: { head: ["yellow"] }, + }); + const [enabled, disabled] = partition(Object.entries(experiments.ALL_EXPERIMENTS), ([name]) => { + return experiments.isEnabled(name as experiments.ExperimentName); + }); + for (const [name, exp] of enabled) { + table.push(["y", name, exp.shortDescription]); + } + for (const [name, exp] of disabled) { + if (!exp.public) { + continue; + } + table.push(["n", name, exp.shortDescription]); + } + logger.info(table.toString()); +}); diff --git a/src/commands/ext-install.ts b/src/commands/ext-install.ts index 7644c4c68c1..653ab1cd61b 100644 --- a/src/commands/ext-install.ts +++ b/src/commands/ext-install.ts @@ -30,7 +30,7 @@ import { getRandomString } from "../extensions/utils"; import { requirePermissions } from "../requirePermissions"; import * as utils from "../utils"; import { track } from "../track"; -import { previews } from "../previews"; +import * as experiments from "../experiments"; import { Options } from "../options"; import * as manifest from "../extensions/manifest"; @@ -44,7 +44,7 @@ marked.setOptions({ export const command = new Command("ext:install [extensionName]") .description( "install an official extension if [extensionName] or [extensionName@version] is provided; " + - (previews.extdev + (experiments.isEnabled("extdev") ? "install a local extension if [localPathOrUrl] or [url#root] is provided; install a published extension (not authored by Firebase) if [publisherId/extensionId] is provided " : "") + "or run with `-i` to see all available extensions." diff --git a/src/commands/ext-update.ts b/src/commands/ext-update.ts index a643ec0340b..011d3f42dc4 100644 --- a/src/commands/ext-update.ts +++ b/src/commands/ext-update.ts @@ -22,7 +22,7 @@ import * as refs from "../extensions/refs"; import { getProjectId } from "../projectUtils"; import { requirePermissions } from "../requirePermissions"; import * as utils from "../utils"; -import { previews } from "../previews"; +import * as experiments from "../experiments"; import * as manifest from "../extensions/manifest"; import { Options } from "../options"; import * as askUserForEventsConfig from "../extensions/askUserForEventsConfig"; @@ -36,7 +36,7 @@ marked.setOptions({ */ export const command = new Command("ext:update [updateSource]") .description( - previews.extdev + experiments.isEnabled("extdev") ? "update an existing extension instance to the latest version or from a local or URL source" : "update an existing extension instance to the latest version" ) diff --git a/src/commands/index.ts b/src/commands/index.ts index 173ee8084dd..8554f94e727 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -1,5 +1,8 @@ -import { previews } from "../previews"; +import * as experiments from "../experiments"; +/** + * Loads all commands for our parser. + */ export function load(client: any): any { function loadCommand(name: string) { const t0 = process.hrtime.bigint(); @@ -48,7 +51,7 @@ export function load(client: any): any { client.database.profile = loadCommand("database-profile"); client.database.push = loadCommand("database-push"); client.database.remove = loadCommand("database-remove"); - if (previews.rtdbrules) { + if (experiments.isEnabled("rtdbrules")) { client.database.rules = {}; client.database.rules.get = loadCommand("database-rules-get"); client.database.rules.list = loadCommand("database-rules-list"); @@ -69,6 +72,11 @@ export function load(client: any): any { client.experimental = {}; client.experimental.functions = {}; client.experimental.functions.shell = loadCommand("experimental-functions-shell"); + client.experiments = {}; + client.experiments.list = loadCommand("experiments-list"); + client.experiments.describe = loadCommand("experiments-describe"); + client.experiments.enable = loadCommand("experiments-enable"); + client.experiments.disable = loadCommand("experiments-disable"); client.ext = loadCommand("ext"); client.ext.configure = loadCommand("ext-configure"); client.ext.info = loadCommand("ext-info"); @@ -77,11 +85,11 @@ export function load(client: any): any { client.ext.list = loadCommand("ext-list"); client.ext.uninstall = loadCommand("ext-uninstall"); client.ext.update = loadCommand("ext-update"); - if (previews.ext) { + if (experiments.isEnabled("ext")) { client.ext.sources = {}; client.ext.sources.create = loadCommand("ext-sources-create"); } - if (previews.extdev) { + if (experiments.isEnabled("extdev")) { client.ext.dev = {}; client.ext.dev.init = loadCommand("ext-dev-init"); client.ext.dev.list = loadCommand("ext-dev-list"); @@ -110,7 +118,7 @@ export function load(client: any): any { client.functions.log = loadCommand("functions-log"); client.functions.shell = loadCommand("functions-shell"); client.functions.list = loadCommand("functions-list"); - if (previews.deletegcfartifacts) { + if (experiments.isEnabled("deletegcfartifacts")) { client.functions.deletegcfartifacts = loadCommand("functions-deletegcfartifacts"); } client.functions.secrets = {}; diff --git a/src/deploy/functions/build.ts b/src/deploy/functions/build.ts index b81dd886ef5..24ce606fac6 100644 --- a/src/deploy/functions/build.ts +++ b/src/deploy/functions/build.ts @@ -2,7 +2,7 @@ import * as backend from "./backend"; import * as proto from "../../gcp/proto"; import * as api from "../../.../../api"; import * as params from "./params"; -import { previews } from "../../previews"; +import * as experiments from "../../experiments"; import { FirebaseError } from "../../error"; import { assertExhaustive, mapObject, nullsafeVisitor } from "../../functional"; import { UserEnvsOpts, writeUserEnvs } from "../../functions/env"; @@ -281,7 +281,7 @@ export async function resolveBackend( nonInteractive?: boolean ): Promise<{ backend: backend.Backend; envs: Record }> { let paramValues: Record = {}; - if (previews.functionsparams) { + if (experiments.isEnabled("functionsparams")) { paramValues = await params.resolveParams( build.params, firebaseConfig, diff --git a/src/deploy/functions/prepare.ts b/src/deploy/functions/prepare.ts index 73145d3f177..b638ffba431 100644 --- a/src/deploy/functions/prepare.ts +++ b/src/deploy/functions/prepare.ts @@ -29,7 +29,7 @@ import { FirebaseError } from "../../error"; import { configForCodebase, normalizeAndValidate } from "../../functions/projectConfig"; import { AUTH_BLOCKING_EVENTS } from "../../functions/events/v1"; import { generateServiceIdentity } from "../../gcp/serviceusage"; -import { previews } from "../../previews"; +import * as experiments from "../../experiments"; import { applyBackendHashToBackends } from "./cache/applyHash"; import { allEndpoints, Backend } from "./backend"; @@ -272,7 +272,7 @@ export async function prepare( * This must be called after `await validate.secretsAreValid`. */ updateEndpointTargetedStatus(wantBackends, context.filters || []); - if (previews.skipdeployingnoopfunctions) { + if (experiments.isEnabled("skipdeployingnoopfunctions")) { applyBackendHashToBackends(wantBackends, context); } } @@ -346,6 +346,9 @@ function maybeCopyTriggerRegion(wantE: backend.Endpoint, haveE: backend.Endpoint wantE.eventTrigger.region = haveE.eventTrigger.region; } +/** + * Determines whether endpoints are targeted by an --only flag. + */ export function updateEndpointTargetedStatus( wantBackends: Record, endpointFilters: EndpointFilter[] diff --git a/src/deploy/functions/release/planner.ts b/src/deploy/functions/release/planner.ts index 0ee0f570b27..1767a4199f7 100644 --- a/src/deploy/functions/release/planner.ts +++ b/src/deploy/functions/release/planner.ts @@ -1,3 +1,5 @@ +import * as clc from "colorette"; + import { EndpointFilter, endpointMatchesAnyFilter, @@ -8,7 +10,7 @@ import { FirebaseError } from "../../../error"; import * as utils from "../../../utils"; import * as backend from "../backend"; import * as v2events from "../../../functions/events/v2"; -import { previews } from "../../../previews"; +import * as experiments from "../../../experiments"; export interface EndpointUpdate { endpoint: backend.Endpoint; @@ -54,7 +56,7 @@ export function calculateChangesets( keyFn ); - const { skipdeployingnoopfunctions } = previews; + const skipdeployingnoopfunctions = experiments.isEnabled("skipdeployingnoopfunctions"); // If the hashes are matching, that means the local function is the same as the server copy. const toSkipPredicate = (id: string): boolean => @@ -75,6 +77,14 @@ export function calculateChangesets( }, {}); const toSkip = utils.groupBy(Object.values(toSkipEndpointsMap), keyFn); + if (Object.keys(toSkip).length) { + utils.logLabeledBullet( + "functions", + `Skipping the deploy of unchanged functions with ${clc.bold( + "experimental" + )} support for skipdeployingnoopfunctions` + ); + } const toUpdate = utils.groupBy( Object.keys(want) diff --git a/src/deploy/functions/runtimes/index.ts b/src/deploy/functions/runtimes/index.ts index d64a886f415..537d8141238 100644 --- a/src/deploy/functions/runtimes/index.ts +++ b/src/deploy/functions/runtimes/index.ts @@ -1,6 +1,5 @@ import * as backend from "../backend"; import * as build from "../build"; -import * as golang from "./golang"; import * as node from "./node"; import * as validate from "../validate"; import { FirebaseError } from "../../../error"; @@ -109,7 +108,9 @@ export interface DelegateContext { } type Factory = (context: DelegateContext) => Promise; -const factories: Factory[] = [node.tryCreateDelegate, golang.tryCreateDelegate]; +// Note: golang has been removed from delegates because it does not work and it +// is not worth having an experiment for yet. +const factories: Factory[] = [node.tryCreateDelegate]; /** * diff --git a/src/deploy/index.ts b/src/deploy/index.ts index ead278ee924..b87fd2921dd 100644 --- a/src/deploy/index.ts +++ b/src/deploy/index.ts @@ -7,7 +7,7 @@ import { logBullet, logSuccess, consoleUrl, addSubdomain } from "../utils"; import { FirebaseError } from "../error"; import { track } from "../track"; import { lifecycleHooks } from "./lifecycleHooks"; -import { previews } from "../previews"; +import * as experiments from "../experiments"; import * as HostingTarget from "./hosting"; import * as DatabaseTarget from "./database"; import * as FirestoreTarget from "./firestore"; @@ -56,9 +56,10 @@ export const deploy = async function ( const postdeploys: Chain = []; const startTime = Date.now(); - if (previews.frameworkawareness && targetNames.includes("hosting")) { + if (targetNames.includes("hosting")) { const config = options.config.get("hosting"); if (Array.isArray(config) ? config.some((it) => it.source) : config.source) { + experiments.assertEnabled("frameworkawareness", "deploy a web framework to hosting"); await prepareFrameworks(targetNames, context, options); } } diff --git a/src/emulator/controller.ts b/src/emulator/controller.ts index 1be545539a9..f59f7c5787b 100644 --- a/src/emulator/controller.ts +++ b/src/emulator/controller.ts @@ -52,7 +52,7 @@ import { ExtensionsEmulator } from "./extensionsEmulator"; import { normalizeAndValidate } from "../functions/projectConfig"; import { requiresJava } from "./downloadableEmulators"; import { prepareFrameworks } from "../frameworks"; -import { previews } from "../previews"; +import * as experiments from "../experiments"; const START_LOGGING_EMULATOR = utils.envOverride( "START_LOGGING_EMULATOR", @@ -369,6 +369,9 @@ interface EmulatorOptions extends Options { extDevEnv?: Record; } +/** + * Start all emulators. + */ export async function startAll( options: EmulatorOptions, showUI = true @@ -489,16 +492,21 @@ export async function startAll( } } - if (previews.frameworkawareness) { - const config = options.config.get("hosting"); + // TODO: turn this into hostingConfig.extract or hostingConfig.hostingConfig + // once those branches merge + const hostingConfig = options.config.get("hosting"); + if ( + Array.isArray(hostingConfig) ? hostingConfig.some((it) => it.source) : hostingConfig?.source + ) { + experiments.assertEnabled("frameworkawareness", "emulate a web framework"); const emulators: EmulatorInfo[] = []; - for (const e of EMULATORS_SUPPORTED_BY_UI) { - const info = EmulatorRegistry.getInfo(e); - if (info) emulators.push(info); - } - if (Array.isArray(config) ? config.some((it) => it.source) : config?.source) { - await prepareFrameworks(targets, options, options, emulators); + if (experiments.isEnabled("frameworkawareness")) { + for (const e of EMULATORS_SUPPORTED_BY_UI) { + const info = EmulatorRegistry.getInfo(e); + if (info) emulators.push(info); + } } + await prepareFrameworks(targets, options, options, emulators); } const emulatableBackends: EmulatableBackend[] = []; diff --git a/src/emulator/downloadableEmulators.ts b/src/emulator/downloadableEmulators.ts index b5e4464cd56..52ff258cfd9 100644 --- a/src/emulator/downloadableEmulators.ts +++ b/src/emulator/downloadableEmulators.ts @@ -18,7 +18,7 @@ import * as path from "path"; import * as os from "os"; import { EmulatorRegistry } from "./registry"; import { downloadEmulator } from "../emulator/download"; -import { previews } from "../previews"; +import * as experiments from "../experiments"; const EMULATOR_INSTANCE_KILL_TIMEOUT = 4000; /* ms */ @@ -62,7 +62,7 @@ export const DownloadDetails: { [s in DownloadableEmulators]: EmulatorDownloadDe namePrefix: "cloud-storage-rules-emulator", }, }, - ui: previews.emulatoruisnapshot + ui: experiments.isEnabled("emulatoruisnapshot") ? { version: "SNAPSHOT", downloadPath: path.join(CACHE_DIR, "ui-vSNAPSHOT.zip"), @@ -291,6 +291,9 @@ async function _fatal(emulator: Emulators, errorMsg: string): Promise { } } +/** + * Handle errors in an emulator process. + */ export async function handleEmulatorProcessError(emulator: Emulators, err: any): Promise { const description = Constants.description(emulator); if (err.path === "java" && err.code === "ENOENT") { @@ -303,6 +306,9 @@ export async function handleEmulatorProcessError(emulator: Emulators, err: any): } } +/** + * Do the selected list of emulators depend on the JRE. + */ export function requiresJava(emulator: Emulators): boolean { if (emulator in Commands) { return Commands[emulator as keyof typeof Commands].binary === "java"; diff --git a/src/emulator/storage/rules/runtime.ts b/src/emulator/storage/rules/runtime.ts index 03985238338..95b1f4f10bb 100644 --- a/src/emulator/storage/rules/runtime.ts +++ b/src/emulator/storage/rules/runtime.ts @@ -34,7 +34,7 @@ import { EmulatorRegistry } from "../../registry"; import { Client } from "../../../apiv2"; const lock = new AsyncLock(); -const synchonizationKey: string = "key"; +const synchonizationKey = "key"; export interface RulesetVerificationOpts { file: { diff --git a/src/experiments.ts b/src/experiments.ts new file mode 100644 index 00000000000..7174b041dfb --- /dev/null +++ b/src/experiments.ts @@ -0,0 +1,176 @@ +import { bold } from "colorette"; +import * as leven from "leven"; + +import { configstore } from "./configstore"; +import { FirebaseError } from "./error"; + +export interface Experiment { + shortDescription: string; + fullDescription?: string; + public?: boolean; + docsUri?: string; + default?: boolean; +} + +// Utility method to ensure there are no typos in defining ALL_EXPERIMENTS +function experiments(exp: Record): Record { + return Object.freeze(exp); +} + +export const ALL_EXPERIMENTS = experiments({ + // meta: + experiments: { + shortDescription: "enables the experiments family of commands", + }, + + // Realtime Database experiments + rtdbrules: { + shortDescription: "Advanced security rules management", + }, + rtdbmanagement: { + shortDescription: "Use new endpoint to administer realtime database instances", + }, + + // Extensions experiments + ext: { + shortDescription: `Enables the ${bold("ext:sources:create")} command`, + }, + extdev: { + shortDescription: `Enables the ${bold("ext:dev")} family of commands`, + docsUri: "https://firebase.google.com/docs/extensions/alpha/overview-build-extensions", + }, + + // Cloud Functions for Firebase experiments + pythonfunctions: { + shortDescription: "Python support for Cloud Functions for Firebase", + fullDescription: + "Adds the ability to initializea and deploy Cloud " + + "Functions for Firebase in Python. While this feature is experimental " + + "breaking API changes are allowed in MINOR API revisions", + }, + deletegcfartifacts: { + shortDescription: `Add the ${bold( + "functions:deletegcfartifacts" + )} command to purge docker build images`, + fullDescription: + `Add the ${bold("functions:deletegcfartifacts")}` + + "command. Google Cloud Functions creates Docker images when building your " + + "functions. Cloud Functions for Firebase automatically cleans up these " + + "images for you on deploy. Customers who predated this cleanup, or customers " + + "who also deploy Google Cloud Functions with non-Firebase tooling may have " + + "old Docker images stored in either Google Container Repository or Artifact " + + `Registry. The ${bold("functions:deletegcfartifacts")} command ` + + "will delete all Docker images created by Google Cloud Functions irrespective " + + "of how that image was created.", + public: true, + }, + functionsparams: { + shortDescription: "Adds support for paramaterizing functions deployments", + }, + skipdeployingnoopfunctions: { + shortDescription: "Detect that there have been no changes to a function and skip deployment", + }, + + // Emulator experiments + emulatoruisnapshot: { + shortDescription: "Load pre-release versions of the emulator UI", + }, + + // Hosting experiments + frameworkawareness: { + shortDescription: "Native support for popular web frameworks", + fullDescription: + "Adds support for popular web frameworks such as Next.js " + + "Nuxt, Netlify, Angular, and Vite-compatible frameworks. Firebase is " + + "committed to support these platforms long-term, but a manual migration " + + "may be required when the non-experimental support for these frameworks " + + "is released", + }, + + // Access experiments + crossservicerules: { + shortDescription: "Allow Firebase Rules to reference resources in other services", + }, +}); + +export type ExperimentName = keyof typeof ALL_EXPERIMENTS; + +/** Determines whether a name is a valid experiment name. */ +export function isValidExperiment(name: string): name is ExperimentName { + return Object.keys(ALL_EXPERIMENTS).includes(name); +} + +/** + * Detects experiment names that were potentially what a customer intended to + * type when they provided malformed. + * Returns null if the malformed name is actually an experiment. Returns all + * possible typos. + */ +export function experimentNameAutocorrect(malformed: string): string[] { + if (isValidExperiment(malformed)) { + throw new FirebaseError( + "Assertion failed: experimentNameAutocorrect given actual experiment name", + { exit: 2 } + ); + } + + // N.B. I personally would use < (name.length + malformed.length) * 0.2 + // but this logic matches src/index.ts. I neither want to change something + // with such potential impact nor to create divergent behavior. + return Object.keys(ALL_EXPERIMENTS).filter( + (name) => leven(name, malformed) < malformed.length * 0.4 + ); +} + +let localPreferencesCache: Record | undefined = undefined; +function localPreferences(): Record { + if (!localPreferencesCache) { + localPreferencesCache = (configstore.get("previews") || {}) as Record; + for (const key of Object.keys(localPreferencesCache)) { + if (!isValidExperiment(key)) { + delete localPreferencesCache[key as ExperimentName]; + } + } + } + return localPreferencesCache; +} + +/** Returns whether an experiment is enabled. */ +export function isEnabled(name: ExperimentName): boolean { + return localPreferences()[name] ?? ALL_EXPERIMENTS[name]?.default ?? false; +} + +/** + * Sets whether an experiment is enabled. + * Set to a boolean value to explicitly opt in or out of an experiment. + * Set to null to go on the default track for this experiment. + */ +export function setEnabled(name: ExperimentName, to: boolean | null): void { + if (to === null) { + delete localPreferences()[name]; + } else { + localPreferences()[name] = to; + } +} + +/** + * Assert that an experiment is enabled before following a code path. + * This code is unnecessary in code paths guarded by ifEnabled. When + * a customer's project was clearly written against an experiment that + * was not enabled, assertEnabled will throw a standard error. The "task" + * param is part of this error. It will be presented as "Cannot ${task}". + */ +export function assertEnabled(name: ExperimentName, task: string): void { + if (!isEnabled(name)) { + throw new FirebaseError( + `Cannot ${task} because the experiment ${bold(name)} is not enabled. To enable ${bold( + name + )} run ${bold(`firebase experiments:enable ${name}`)}` + ); + } +} + +/** Saves the current set of enabled experiments to disk. */ +export function flushToDisk(): void { + configstore.set("previews", localPreferences()); +} diff --git a/src/handlePreviewToggles.ts b/src/handlePreviewToggles.ts index 8673c9ff05d..7ae200c7b6c 100644 --- a/src/handlePreviewToggles.ts +++ b/src/handlePreviewToggles.ts @@ -1,36 +1,51 @@ "use strict"; -import { unset, has } from "lodash"; import { bold, red } from "colorette"; -import { configstore } from "./configstore"; -import { previews } from "./previews"; +import * as experiments from "./experiments"; -function _errorOut(name?: string) { - console.log(bold(red("Error:")), "Did not recognize preview feature", bold(name || "")); +function errorOut(name?: string): void { + console.log(`${bold(red("Error:"))} Did not recognize preview feature ${bold(name || "")}`); process.exit(1); } -export function handlePreviewToggles(args: string[]) { - const isValidPreview = has(previews, args[1]); +/** + * Implement --open-sesame and --close-sesame + */ +export function handlePreviewToggles(args: string[]): boolean { + const name = args[1]; + const isValid = experiments.isValidExperiment(name); if (args[0] === "--open-sesame") { - if (isValidPreview) { - console.log("Enabling preview feature", bold(args[1]) + "..."); - (previews as any)[args[1]] = true; - configstore.set("previews", previews); - console.log("Preview feature enabled!"); + console.log( + `${bold("firebase --open-sesame")} is deprecated and wil be removed in a future ` + + `version. Use the new "expirments" family of commands, including ${bold( + "firebase experiments:enable" + )}` + ); + if (isValid) { + console.log(`Enabling experiment ${bold(name)} ...`); + experiments.setEnabled(name, true); + experiments.flushToDisk(); + console.log("Experiment enabled!"); return process.exit(0); } - _errorOut(); + errorOut(name); } else if (args[0] === "--close-sesame") { - if (isValidPreview) { - console.log("Disabling preview feature", bold(args[1])); - unset(previews, args[1]); - configstore.set("previews", previews); + console.log( + `${bold("firebase --open-sesame")} is deprecated and wil be removed in a future ` + + `version. Use the new "expirments" family of commands, including ${bold( + "firebase experiments:disable" + )}` + ); + if (isValid) { + console.log(`Disabling experiment ${bold(name)}...`); + experiments.setEnabled(name, false); + experiments.flushToDisk(); return process.exit(0); } - _errorOut(); + errorOut(name); } + return false; } diff --git a/src/init/features/functions/index.ts b/src/init/features/functions/index.ts index 5bb1fa96eb7..f80875a5e67 100644 --- a/src/init/features/functions/index.ts +++ b/src/init/features/functions/index.ts @@ -3,7 +3,6 @@ import * as clc from "colorette"; import { logger } from "../../../logger"; import { promptOnce } from "../../../prompt"; import { requirePermissions } from "../../../requirePermissions"; -import { previews } from "../../../previews"; import { Options } from "../../../options"; import { ensure } from "../../../ensureApiEnabled"; import { Config } from "../../../config"; @@ -168,12 +167,6 @@ async function languageSetup(setup: any, config: Config): Promise { value: "typescript", }, ]; - if (previews.golang) { - choices.push({ - name: "Go", - value: "golang", - }); - } const language = await promptOnce({ type: "list", message: "What language would you like to use to write Cloud Functions?", diff --git a/src/init/features/hosting/index.ts b/src/init/features/hosting/index.ts index 57df39b499c..cdc97211daf 100644 --- a/src/init/features/hosting/index.ts +++ b/src/init/features/hosting/index.ts @@ -7,7 +7,7 @@ import { initGitHub } from "./github"; import { prompt, promptOnce } from "../../../prompt"; import { logger } from "../../../logger"; import { discover, WebFrameworks } from "../../../frameworks"; -import { previews } from "../../../previews"; +import * as experiments from "../../../experiments"; const INDEX_TEMPLATE = fs.readFileSync( __dirname + "/../../../../templates/init/hosting/index.html", @@ -19,14 +19,17 @@ const MISSING_TEMPLATE = fs.readFileSync( ); const DEFAULT_IGNORES = ["firebase.json", "**/.*", "**/node_modules/**"]; +/** + * + */ export async function doSetup(setup: any, config: any): Promise { setup.hosting = {}; - let discoveredFramework = previews.frameworkawareness + let discoveredFramework = experiments.isEnabled("frameworkawareness") ? await discover(config.projectDir, false) : undefined; - if (previews.frameworkawareness) { + if (experiments.isEnabled("frameworkawareness")) { if (discoveredFramework) { const name = WebFrameworks[discoveredFramework.framework].name; await promptOnce( @@ -48,7 +51,7 @@ export async function doSetup(setup: any, config: any): Promise { name: "useWebFrameworks", type: "confirm", default: false, - message: `Do you want to use a web framework?`, + message: `Do you want to use a web framework? (${clc.bold("experimental")})`, }, setup.hosting ); @@ -82,8 +85,8 @@ export async function doSetup(setup: any, config: any): Promise { ); } - if (setup.hosting.useDiscoveredFramework) { - setup.hosting.webFramework = discoveredFramework!.framework; + if (setup.hosting.useDiscoveredFramework && discoveredFramework) { + setup.hosting.webFramework = discoveredFramework.framework; } else { const choices: { name: string; value: string }[] = []; for (const value in WebFrameworks) { diff --git a/src/previews.ts b/src/previews.ts deleted file mode 100644 index 7ed7b767db0..00000000000 --- a/src/previews.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { has, set } from "lodash"; -import { configstore } from "./configstore"; - -interface PreviewFlags { - rtdbrules: boolean; - ext: boolean; - extdev: boolean; - rtdbmanagement: boolean; - golang: boolean; - deletegcfartifacts: boolean; - emulatoruisnapshot: boolean; - frameworkawareness: boolean; - functionsparams: boolean; - skipdeployingnoopfunctions: boolean; -} - -export const previews: PreviewFlags = { - // insert previews here... - rtdbrules: false, - ext: false, - extdev: false, - rtdbmanagement: false, - golang: false, - deletegcfartifacts: false, - emulatoruisnapshot: false, - frameworkawareness: false, - functionsparams: false, - skipdeployingnoopfunctions: false, - - ...(configstore.get("previews") as Partial), -}; - -if (process.env.FIREBASE_CLI_PREVIEWS) { - process.env.FIREBASE_CLI_PREVIEWS.split(",").forEach((feature) => { - if (has(previews, feature)) { - set(previews, feature, true); - } - }); -} diff --git a/src/serve/index.ts b/src/serve/index.ts index b0f41e00d8e..43bb9e634f6 100644 --- a/src/serve/index.ts +++ b/src/serve/index.ts @@ -1,7 +1,7 @@ import { EmulatorServer } from "../emulator/emulatorServer"; import { logger } from "../logger"; import { prepareFrameworks } from "../frameworks"; -import { previews } from "../previews"; +import * as experiments from "../experiments"; import { trackEmulator } from "../track"; import { getProjectId } from "../projectUtils"; import { Constants } from "../emulator/constants"; @@ -25,10 +25,10 @@ export async function serve(options: any): Promise { const targetNames: string[] = options.targets || []; options.port = parseInt(options.port, 10); if ( - previews.frameworkawareness && targetNames.includes("hosting") && [].concat(options.config.get("hosting")).some((it: any) => it.source) ) { + experiments.assertEnabled("frameworkawareness", "emulate a web framework"); await prepareFrameworks(targetNames, options, options); } const isDemoProject = Constants.isDemoProject(getProjectId(options) || ""); diff --git a/src/test/database/api.spec.ts b/src/test/database/api.spec.ts index c0085814bf4..360d13dbb79 100644 --- a/src/test/database/api.spec.ts +++ b/src/test/database/api.spec.ts @@ -7,7 +7,6 @@ describe("api", () => { afterEach(() => { delete process.env.FIREBASE_DATABASE_EMULATOR_HOST; delete process.env.FIREBASE_REALTIME_URL; - delete process.env.FIREBASE_CLI_PREVIEWS; // This is dirty, but utils keeps stateful overrides and we need to clear it utils.envOverrides.length = 0; }); diff --git a/src/test/deploy/functions/release/planner.spec.ts b/src/test/deploy/functions/release/planner.spec.ts index 67bb3e1e2aa..0a272f1191b 100644 --- a/src/test/deploy/functions/release/planner.spec.ts +++ b/src/test/deploy/functions/release/planner.spec.ts @@ -6,7 +6,7 @@ import * as planner from "../../../../deploy/functions/release/planner"; import * as deploymentTool from "../../../../deploymentTool"; import * as utils from "../../../../utils"; import * as v2events from "../../../../functions/events/v2"; -import { previews } from "../../../../previews"; +import * as experiments from "../../../../experiments"; describe("planner", () => { let logLabeledBullet: sinon.SinonStub; @@ -16,12 +16,12 @@ describe("planner", () => { } beforeEach(() => { - previews.skipdeployingnoopfunctions = true; + experiments.setEnabled("skipdeployingnoopfunctions", true); logLabeledBullet = sinon.stub(utils, "logLabeledBullet"); }); afterEach(() => { - previews.skipdeployingnoopfunctions = false; + experiments.setEnabled("skipdeployingnoopfunctions", false); sinon.verifyAndRestore(); }); @@ -233,7 +233,7 @@ describe("planner", () => { updatedWant.hash = "to_skip"; updatedHave.hash = "to_skip"; - previews.skipdeployingnoopfunctions = false; + experiments.setEnabled("skipdeployingnoopfunctions", false); const want = { updated: updatedWant }; const have = { updated: updatedHave }; @@ -261,7 +261,7 @@ describe("planner", () => { updatedHave.hash = "to_skip"; updatedWant.targetedByOnly = true; - previews.skipdeployingnoopfunctions = true; + experiments.setEnabled("skipdeployingnoopfunctions", true); const want = { updated: updatedWant }; const have = { updated: updatedHave }; diff --git a/src/utils.ts b/src/utils.ts index d3f2c811f37..bc289c60686 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -341,19 +341,19 @@ export function getFunctionsEventProvider(eventType: string): string { return _.capitalize(provider); } // New event types: - if (eventType.match(/google.pubsub/)) { + if (/google.pubsub/.exec(eventType)) { return "PubSub"; - } else if (eventType.match(/google.storage/)) { + } else if (/google.storage/.exec(eventType)) { return "Storage"; - } else if (eventType.match(/google.analytics/)) { + } else if (/google.analytics/.exec(eventType)) { return "Analytics"; - } else if (eventType.match(/google.firebase.database/)) { + } else if (/google.firebase.database/.exec(eventType)) { return "Database"; - } else if (eventType.match(/google.firebase.auth/)) { + } else if (/google.firebase.auth/.exec(eventType)) { return "Auth"; - } else if (eventType.match(/google.firebase.crashlytics/)) { + } else if (/google.firebase.crashlytics/.exec(eventType)) { return "Crashlytics"; - } else if (eventType.match(/google.firestore/)) { + } else if (/google.firestore/.exec(eventType)) { return "Firestore"; } return _.capitalize(eventType.split(".")[1]); @@ -416,7 +416,7 @@ export async function promiseWhile( * Return a promise that rejects after timeoutMs but otherwise behave the same. * @param timeoutMs the time in milliseconds before forced rejection * @param promise the original promise - * @returns a promise wrapping the original promise with rejection on timeout + * @return a promise wrapping the original promise with rejection on timeout */ export function withTimeout(timeoutMs: number, promise: Promise): Promise { return new Promise((resolve, reject) => { @@ -700,9 +700,11 @@ export function cloneDeep(obj: T): T { * Returns the last element in the array, or undefined if no array is passed or * the array is empty. */ -export function last(arr?: Array): T | undefined { +export function last(arr?: T[]): T { + // The type system should never allow this, so return something that violates + // the type system when passing in something that violates the type system. if (!Array.isArray(arr)) { - return; + return undefined as unknown as T; } return arr[arr.length - 1]; }