-
Notifications
You must be signed in to change notification settings - Fork 1.1k
Rc a/b testing & rollouts cli #9213
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
77ea009
16b6ecd
9b3f5c0
70e6f99
91f4068
e7cbf30
1119305
e7bcb47
9ecddd4
5b8fa89
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1 +1,11 @@ | ||
- CloudSQL instances created with `firebase dataconnect:connect` now default to Postgres 17. | ||
- Fixed an issue with deploying indexes to Firestore Enterprise edition databases where explicit `__name__` fields could be incorrectly handled. | ||
- The `experimental:mcp` command has been renamed to `mcp`. The old name is now an alias. | ||
- `firebase_update_environment` MCP tool supports accepting Gemini in Firebase Terms of Service. | ||
- Fixed a bug when `firebase init dataconnect` failed to create a React app when launched from VS Code extension (#9171). | ||
- Improved the clarity of the `firebase apptesting:execute` command when you have zero or multiple apps. | ||
- `firebase dataconnect:sql:migrate` now supports Cloud SQL instances with only private IPs. The command must be run in the same VPC of the instance to work. (##9200) | ||
- Fixed an issue where `firebase deploy --only firestore` would fail with 403 errors on projects that never had a database created. | ||
- Added `remoteconfig:experiments:get`, `remoteconfig:experiments:list`, and `remoteconfig:experiments:delete` commands to manage Remote Config experiments. | ||
- Added `remoteconfig:rollouts:get`, `remoteconfig:rollouts:list`, and `remoteconfig:rollouts:delete` commands to manage Remote Config rollouts. | ||
- Fix Functions MCP log tool to normalize sort order and surface Cloud Logging error details (#9247) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,33 @@ | ||
import { Command } from "../command"; | ||
import { Options } from "../options"; | ||
import { requireAuth } from "../requireAuth"; | ||
import { requirePermissions } from "../requirePermissions"; | ||
import { logger } from "../logger"; | ||
import { needProjectNumber } from "../projectUtils"; | ||
import { NAMESPACE_FIREBASE } from "../remoteconfig/interfaces"; | ||
import * as rcExperiment from "../remoteconfig/deleteExperiment"; | ||
import { getExperiment, parseExperiment } from "../remoteconfig/getExperiment"; | ||
import { confirm } from "../prompt"; | ||
|
||
export const command = new Command("remoteconfig:experiments:delete <experimentId>") | ||
.description("delete a Remote Config experiment.") | ||
.before(requireAuth) | ||
.before(requirePermissions, [ | ||
"firebaseabt.experiments.delete", | ||
"firebaseanalytics.resources.googleAnalyticsEdit", | ||
]) | ||
.action(async (experimentId: string, options: Options) => { | ||
const projectNumber: string = await needProjectNumber(options); | ||
const experiment = await getExperiment(projectNumber, NAMESPACE_FIREBASE, experimentId); | ||
logger.info(parseExperiment(experiment)); | ||
const confirmDeletion = await confirm({ | ||
message: "Are you sure you want to delete this experiment? This cannot be undone.", | ||
default: false, | ||
}); | ||
if (!confirmDeletion) { | ||
return; | ||
} | ||
logger.info( | ||
await rcExperiment.deleteExperiment(projectNumber, NAMESPACE_FIREBASE, experimentId), | ||
); | ||
}); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,23 @@ | ||
import { Command } from "../command"; | ||
import { Options } from "../options"; | ||
import { requireAuth } from "../requireAuth"; | ||
import { requirePermissions } from "../requirePermissions"; | ||
import { logger } from "../logger"; | ||
import { needProjectNumber } from "../projectUtils"; | ||
import { GetExperimentResult, NAMESPACE_FIREBASE } from "../remoteconfig/interfaces"; | ||
import * as rcExperiment from "../remoteconfig/getExperiment"; | ||
|
||
export const command = new Command("remoteconfig:experiments:get <experimentId>") | ||
.description("get a Remote Config experiment.") | ||
.before(requireAuth) | ||
.before(requirePermissions, ["firebaseabt.experiments.get"]) | ||
.action(async (experimentId: string, options: Options) => { | ||
const projectNumber: string = await needProjectNumber(options); | ||
const experiment: GetExperimentResult = await rcExperiment.getExperiment( | ||
projectNumber, | ||
NAMESPACE_FIREBASE, | ||
experimentId, | ||
); | ||
logger.info(rcExperiment.parseExperiment(experiment)); | ||
return experiment; | ||
}); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,51 @@ | ||
import * as rcExperiment from "../remoteconfig/listExperiments"; | ||
import { | ||
DEFAULT_PAGE_SIZE, | ||
ListExperimentOptions, | ||
ListExperimentsResult, | ||
NAMESPACE_FIREBASE, | ||
} from "../remoteconfig/interfaces"; | ||
import { Command } from "../command"; | ||
import { Options } from "../options"; | ||
import { requireAuth } from "../requireAuth"; | ||
import { requirePermissions } from "../requirePermissions"; | ||
import { logger } from "../logger"; | ||
import { needProjectNumber } from "../projectUtils"; | ||
|
||
export const command = new Command("remoteconfig:experiments:list") | ||
.description("get a list of Remote Config experiments") | ||
.option( | ||
"--pageSize <pageSize>", | ||
"Maximum number of experiments to return per page. Defaults to 10. Pass '0' to fetch all experiments", | ||
) | ||
.option( | ||
"--pageToken <pageToken>", | ||
"Token from a previous list operation to retrieve the next page of results. Listing starts from the beginning if omitted.", | ||
) | ||
.option( | ||
"--filter <filter>", | ||
"Filters experiments by their full resource name. Format: `name:projects/{project_number}/namespaces/{namespace}/experiments/{experiment_id}`", | ||
) | ||
.before(requireAuth) | ||
.before(requirePermissions, [ | ||
"firebaseabt.experiments.list", | ||
"firebaseanalytics.resources.googleAnalyticsReadAndAnalyze", | ||
]) | ||
.action(async (options: Options) => { | ||
const projectNumber = await needProjectNumber(options); | ||
const listExperimentOptions: ListExperimentOptions = { | ||
pageSize: (options.pageSize as string) ?? DEFAULT_PAGE_SIZE, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I like to avoid this sort of casting by adding a specialized RemoteConfigOptions type that is fulfilled by Options. Here's an example from hosting, would love something similar here: https://github.com/firebase/firebase-tools/blob/master/src/hosting/options.ts Applies for all the commands. |
||
pageToken: options.pageToken as string, | ||
filter: options.filter as string, | ||
}; | ||
const { experiments, nextPageToken }: ListExperimentsResult = | ||
await rcExperiment.listExperiments(projectNumber, NAMESPACE_FIREBASE, listExperimentOptions); | ||
logger.info(rcExperiment.parseExperimentList(experiments ?? [])); | ||
if (nextPageToken) { | ||
logger.info(`\nNext Page Token: \x1b[32m${nextPageToken}\x1b[0m\n`); | ||
} | ||
return { | ||
experiments, | ||
nextPageToken, | ||
}; | ||
}); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,31 @@ | ||
import { Command } from "../command"; | ||
import { Options } from "../options"; | ||
import { requireAuth } from "../requireAuth"; | ||
import { requirePermissions } from "../requirePermissions"; | ||
import { logger } from "../logger"; | ||
import { needProjectNumber } from "../projectUtils"; | ||
import { NAMESPACE_FIREBASE } from "../remoteconfig/interfaces"; | ||
import * as rcRollout from "../remoteconfig/deleteRollout"; | ||
import { getRollout, parseRolloutIntoTable } from "../remoteconfig/getRollout"; | ||
import { confirm } from "../prompt"; | ||
|
||
export const command = new Command("remoteconfig:rollouts:delete <rolloutId>") | ||
.description("delete a Remote Config rollout.") | ||
.before(requireAuth) | ||
.before(requirePermissions, [ | ||
"cloud.configs.update", | ||
"firebaseanalytics.resources.googleAnalyticsEdit", | ||
]) | ||
.action(async (rolloutId: string, options: Options) => { | ||
const projectNumber: string = await needProjectNumber(options); | ||
const rollout = await getRollout(projectNumber, NAMESPACE_FIREBASE, rolloutId); | ||
logger.info(parseRolloutIntoTable(rollout)); | ||
const confirmDeletion = await confirm({ | ||
message: "Are you sure you want to delete this rollout? This cannot be undone.", | ||
default: false, | ||
}); | ||
if (!confirmDeletion) { | ||
return; | ||
} | ||
logger.info(await rcRollout.deleteRollout(projectNumber, NAMESPACE_FIREBASE, rolloutId)); | ||
}); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,23 @@ | ||
import { Command } from "../command"; | ||
import { Options } from "../options"; | ||
import { requireAuth } from "../requireAuth"; | ||
import { requirePermissions } from "../requirePermissions"; | ||
import { logger } from "../logger"; | ||
import { needProjectNumber } from "../projectUtils"; | ||
import { RemoteConfigRollout, NAMESPACE_FIREBASE } from "../remoteconfig/interfaces"; | ||
import * as rcRollout from "../remoteconfig/getRollout"; | ||
|
||
export const command = new Command("remoteconfig:rollouts:get <rolloutId>") | ||
.description("get a Remote Config rollout") | ||
.before(requireAuth) | ||
.before(requirePermissions, ["cloud.configs.get"]) | ||
.action(async (rolloutId: string, options: Options) => { | ||
const projectNumber: string = await needProjectNumber(options); | ||
const rollout: RemoteConfigRollout = await rcRollout.getRollout( | ||
projectNumber, | ||
NAMESPACE_FIREBASE, | ||
rolloutId, | ||
); | ||
logger.info(rcRollout.parseRolloutIntoTable(rollout)); | ||
return rollout; | ||
}); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,54 @@ | ||
import { Command } from "../command"; | ||
import { Options } from "../options"; | ||
import { requireAuth } from "../requireAuth"; | ||
import { requirePermissions } from "../requirePermissions"; | ||
import { logger } from "../logger"; | ||
import { needProjectNumber } from "../projectUtils"; | ||
import { | ||
ListRollouts, | ||
NAMESPACE_FIREBASE, | ||
ListRolloutOptions, | ||
DEFAULT_PAGE_SIZE, | ||
} from "../remoteconfig/interfaces"; | ||
import * as rcRollout from "../remoteconfig/listRollouts"; | ||
|
||
export const command = new Command("remoteconfig:rollouts:list") | ||
.description("get a list of Remote Config rollouts.") | ||
.option( | ||
"--pageSize <pageSize>", | ||
"Maximum number of rollouts to return per page. Defaults to 10. Pass '0' to fetch all rollouts", | ||
) | ||
.option( | ||
"--pageToken <pageToken>", | ||
"Token from a previous list operation to retrieve the next page of results. Listing starts from the beginning if omitted.", | ||
) | ||
.option( | ||
"--filter <filter>", | ||
"Filters rollouts by their full resource name. Format: `name:projects/{project_id}/namespaces/{namespace}/rollouts/{rollout_id}`", | ||
) | ||
.before(requireAuth) | ||
.before(requirePermissions, [ | ||
"cloud.configs.get", | ||
"firebaseanalytics.resources.googleAnalyticsReadAndAnalyze", | ||
]) | ||
.action(async (options: Options) => { | ||
const projectNumber = await needProjectNumber(options); | ||
const listRolloutOptions: ListRolloutOptions = { | ||
pageSize: (options.pageSize as string) ?? DEFAULT_PAGE_SIZE, | ||
pageToken: options.pageToken as string, | ||
filter: options.filter as string, | ||
}; | ||
const { rollouts, nextPageToken }: ListRollouts = await rcRollout.listRollouts( | ||
projectNumber, | ||
NAMESPACE_FIREBASE, | ||
listRolloutOptions, | ||
); | ||
logger.info(rcRollout.parseRolloutList(rollouts ?? [])); | ||
if (nextPageToken) { | ||
logger.info(`\nNext Page Token: \x1b[32m${nextPageToken}\x1b[0m\n`); | ||
} | ||
return { | ||
rollouts, | ||
nextPageToken, | ||
}; | ||
}); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,66 @@ | ||
import { expect } from "chai"; | ||
import * as nock from "nock"; | ||
import * as clc from "colorette"; | ||
|
||
import { remoteConfigApiOrigin } from "../api"; | ||
import { FirebaseError } from "../error"; | ||
import { deleteExperiment } from "./deleteExperiment"; | ||
import { NAMESPACE_FIREBASE } from "./interfaces"; | ||
|
||
const PROJECT_ID = "12345679"; | ||
const EXPERIMENT_ID = "1"; | ||
|
||
describe("Remote Config Experiment Delete", () => { | ||
afterEach(() => { | ||
expect(nock.isDone()).to.equal(true, "all nock stubs should have been called"); | ||
nock.cleanAll(); | ||
}); | ||
|
||
it("should delete an experiment successfully", async () => { | ||
nock(remoteConfigApiOrigin()) | ||
.delete( | ||
`/v1/projects/${PROJECT_ID}/namespaces/${NAMESPACE_FIREBASE}/experiments/${EXPERIMENT_ID}`, | ||
) | ||
.reply(200); | ||
|
||
await expect( | ||
deleteExperiment(PROJECT_ID, NAMESPACE_FIREBASE, EXPERIMENT_ID), | ||
).to.eventually.equal(clc.bold(`Successfully deleted experiment ${clc.yellow(EXPERIMENT_ID)}`)); | ||
}); | ||
|
||
it("should throw FirebaseError if experiment is running", async () => { | ||
const errorMessage = `Experiment ${EXPERIMENT_ID} is currently running and cannot be deleted. If you want to delete this experiment, stop it at https://console.firebase.google.com/project/${PROJECT_ID}/config/experiment/results/${EXPERIMENT_ID}`; | ||
nock(remoteConfigApiOrigin()) | ||
.delete( | ||
`/v1/projects/${PROJECT_ID}/namespaces/${NAMESPACE_FIREBASE}/experiments/${EXPERIMENT_ID}`, | ||
) | ||
.reply(400, { | ||
error: { | ||
message: errorMessage, | ||
}, | ||
}); | ||
|
||
await expect( | ||
deleteExperiment(PROJECT_ID, NAMESPACE_FIREBASE, EXPERIMENT_ID), | ||
).to.be.rejectedWith(FirebaseError, errorMessage); | ||
}); | ||
|
||
it("should throw FirebaseError if an internal error occurred", async () => { | ||
nock(remoteConfigApiOrigin()) | ||
.delete( | ||
`/v1/projects/${PROJECT_ID}/namespaces/${NAMESPACE_FIREBASE}/experiments/${EXPERIMENT_ID}`, | ||
) | ||
.reply(500, { | ||
error: { | ||
message: "Internal server error", | ||
}, | ||
}); | ||
|
||
await expect( | ||
deleteExperiment(PROJECT_ID, NAMESPACE_FIREBASE, EXPERIMENT_ID), | ||
).to.be.rejectedWith( | ||
FirebaseError, | ||
`Failed to delete Remote Config experiment with ID ${EXPERIMENT_ID} for project ${PROJECT_ID}. Error: Request to https://firebaseremoteconfig.googleapis.com/v1/projects/12345679/namespaces/firebase/experiments/1 had HTTP Error: 500, Internal server error`, | ||
); | ||
}); | ||
}); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,48 @@ | ||
import * as clc from "colorette"; | ||
|
||
import { remoteConfigApiOrigin } from "../api"; | ||
import { Client } from "../apiv2"; | ||
import { FirebaseError, getErrMsg, getError } from "../error"; | ||
import { consoleUrl } from "../utils"; | ||
|
||
const TIMEOUT = 30000; | ||
|
||
const apiClient = new Client({ | ||
urlPrefix: remoteConfigApiOrigin(), | ||
apiVersion: "v1", | ||
}); | ||
|
||
/** | ||
* Deletes a Remote Config experiment. | ||
* @param projectId The ID of the project. | ||
* @param namespace The namespace under which the experiment is created. | ||
* @param experimentId The ID of the experiment to retrieve. | ||
* @return A promise that resolves to the experiment object. | ||
*/ | ||
export async function deleteExperiment( | ||
projectId: string, | ||
namespace: string, | ||
experimentId: string, | ||
): Promise<string> { | ||
try { | ||
await apiClient.request<void, void>({ | ||
method: "DELETE", | ||
path: `projects/${projectId}/namespaces/${namespace}/experiments/${experimentId}`, | ||
timeout: TIMEOUT, | ||
}); | ||
return clc.bold(`Successfully deleted experiment ${clc.yellow(experimentId)}`); | ||
} catch (err: unknown) { | ||
const error: Error = getError(err); | ||
if (error.message.includes("is running and cannot be deleted")) { | ||
const rcConsoleUrl = consoleUrl(projectId, `/config/experiment/results/${experimentId}`); | ||
throw new FirebaseError( | ||
`Experiment ${experimentId} is currently running and cannot be deleted. If you want to delete this experiment, stop it at ${rcConsoleUrl}`, | ||
{ original: error }, | ||
); | ||
} | ||
throw new FirebaseError( | ||
`Failed to delete Remote Config experiment with ID ${experimentId} for project ${projectId}. Error: ${getErrMsg(err)}`, | ||
{ original: error }, | ||
); | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks like this CHANGELOG is out of date and is pulling in things that went out in the last release. Can you change this to only include lines 9, 10, and 11?