Skip to content

Commit b538138

Browse files
rathovarun1032NamyalgVarun Rathorejoehan
authored
Rc a/b testing & rollouts cli (#9213)
* CLI for Remote Config Experiments Co-authored-by: varun rathore <[email protected]> Co-authored-by: Varun Rathore <[email protected]> * Remote Config Rollouts CLI implementation (#9102) * Rc rollouts implementation Remote Config Rollouts CLI implementation --------- Authored-by: Varun Rathore <[email protected]> * fix comments by GCA * fix comments by GCA * fix comments by GCA * fix comments by GCA * fix comments by GCA * Update CHANGELOG.md * Add options to avoid casting * Add options to avoid casting * Add options to avoid casting * Add options to avoid casting * Add options to avoid casting * Update CHANGELOG.md * Update CHANGELOG.md --------- Co-authored-by: Namya LG <[email protected]> Co-authored-by: Varun Rathore <[email protected]> Co-authored-by: Joe Hanley <[email protected]>
1 parent 2b55597 commit b538138

24 files changed

+1667
-7
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
- Deprecated Java versions below 21. Support will be dropped in Firebase CLI v15. Please upgrade to Java version 21 or above to continue using the emulators.
22
- Fix Functions MCP log tool to normalize sort order and surface Cloud Logging error details (#9247)
33
- Fixed an issue where `firebase init` would require log in even when no project is selected. (#9251)
4+
- Added `remoteconfig:experiments:get`, `remoteconfig:experiments:list`, and `remoteconfig:experiments:delete` commands to manage Remote Config experiments.
5+
- Added `remoteconfig:rollouts:get`, `remoteconfig:rollouts:list`, and `remoteconfig:rollouts:delete` commands to manage Remote Config rollouts.

README.md

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -161,11 +161,17 @@ Detailed doc is [here](https://firebase.google.com/docs/cli/auth).
161161

162162
### Remote Config Commands
163163

164-
| Command | Description |
165-
| ------------------------------ | ---------------------------------------------------------------------------------------------------------- |
166-
| **remoteconfig:get** | Get a Firebase project's Remote Config template. |
167-
| **remoteconfig:versions:list** | Get a list of the most recent Firebase Remote Config template versions that have been published. |
168-
| **remoteconfig:rollback** | Roll back a project's published Remote Config template to the version provided by `--version_number` flag. |
164+
| Command | Description |
165+
| ----------------------------------- | ---------------------------------------------------------------------------------------------------------- |
166+
| **remoteconfig:get** | Get a Firebase project's Remote Config template. |
167+
| **remoteconfig:versions:list** | Get a list of the most recent Firebase Remote Config template versions that have been published. |
168+
| **remoteconfig:rollback** | Roll back a project's published Remote Config template to the version provided by `--version_number` flag. |
169+
| **remoteconfig:experiments:get** | Get a Remote Config experiment. |
170+
| **remoteconfig:experiments:list** | Get a list of Remote Config experiments |
171+
| **remoteconfig:experiments:delete** | Delete a Remote Config experiment. |
172+
| **remoteconfig:rollouts:get** | Get a Remote Config rollout. |
173+
| **remoteconfig:rollouts:list** | Get a list of Remote Config rollouts. |
174+
| **remoteconfig:rollouts:delete** | Delete a Remote Config rollout. |
169175

170176
Use `firebase:deploy --only remoteconfig` to update and publish a project's Firebase Remote Config template.
171177

src/commands/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,14 @@ export function load(client: any): any {
213213
client.remoteconfig.rollback = loadCommand("remoteconfig-rollback");
214214
client.remoteconfig.versions = {};
215215
client.remoteconfig.versions.list = loadCommand("remoteconfig-versions-list");
216+
client.remoteconfig.rollouts = {};
217+
client.remoteconfig.rollouts.get = loadCommand("remoteconfig-rollouts-get");
218+
client.remoteconfig.rollouts.list = loadCommand("remoteconfig-rollouts-list");
219+
client.remoteconfig.rollouts.delete = loadCommand("remoteconfig-rollouts-delete");
220+
client.remoteconfig.experiments = {};
221+
client.remoteconfig.experiments.get = loadCommand("remoteconfig-experiments-get");
222+
client.remoteconfig.experiments.list = loadCommand("remoteconfig-experiments-list");
223+
client.remoteconfig.experiments.delete = loadCommand("remoteconfig-experiments-delete");
216224
client.serve = loadCommand("serve");
217225
client.setup = {};
218226
client.setup.emulators = {};
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { Command } from "../command";
2+
import { requireAuth } from "../requireAuth";
3+
import { requirePermissions } from "../requirePermissions";
4+
import { logger } from "../logger";
5+
import { needProjectNumber } from "../projectUtils";
6+
import { NAMESPACE_FIREBASE } from "../remoteconfig/interfaces";
7+
import * as rcExperiment from "../remoteconfig/deleteExperiment";
8+
import { getExperiment, parseExperiment } from "../remoteconfig/getExperiment";
9+
import { confirm } from "../prompt";
10+
import { RemoteConfigOptions } from "../remoteconfig/options"; // 👈 Import the new interface
11+
12+
export const command = new Command("remoteconfig:experiments:delete <experimentId>")
13+
.description("delete a Remote Config experiment.")
14+
.before(requireAuth)
15+
.before(requirePermissions, [
16+
"firebaseabt.experiments.delete",
17+
"firebaseanalytics.resources.googleAnalyticsEdit",
18+
])
19+
.action(async (experimentId: string, options: RemoteConfigOptions) => {
20+
const projectNumber: string = await needProjectNumber(options);
21+
const experiment = await getExperiment(projectNumber, NAMESPACE_FIREBASE, experimentId);
22+
logger.info(parseExperiment(experiment));
23+
const confirmDeletion = await confirm({
24+
message: "Are you sure you want to delete this experiment? This cannot be undone.",
25+
default: false,
26+
});
27+
if (!confirmDeletion) {
28+
return;
29+
}
30+
logger.info(
31+
await rcExperiment.deleteExperiment(projectNumber, NAMESPACE_FIREBASE, experimentId),
32+
);
33+
});
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { Command } from "../command";
2+
import { requireAuth } from "../requireAuth";
3+
import { requirePermissions } from "../requirePermissions";
4+
import { logger } from "../logger";
5+
import { needProjectNumber } from "../projectUtils";
6+
import { GetExperimentResult, NAMESPACE_FIREBASE } from "../remoteconfig/interfaces";
7+
import * as rcExperiment from "../remoteconfig/getExperiment";
8+
import { RemoteConfigOptions } from "../remoteconfig/options"; // 👈 Import the new interface
9+
10+
export const command = new Command("remoteconfig:experiments:get <experimentId>")
11+
.description("get a Remote Config experiment.")
12+
.before(requireAuth)
13+
.before(requirePermissions, ["firebaseabt.experiments.get"])
14+
.action(async (experimentId: string, options: RemoteConfigOptions) => {
15+
const projectNumber: string = await needProjectNumber(options);
16+
const experiment: GetExperimentResult = await rcExperiment.getExperiment(
17+
projectNumber,
18+
NAMESPACE_FIREBASE,
19+
experimentId,
20+
);
21+
logger.info(rcExperiment.parseExperiment(experiment));
22+
return experiment;
23+
});
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import * as rcExperiment from "../remoteconfig/listExperiments";
2+
import {
3+
DEFAULT_PAGE_SIZE,
4+
ListExperimentOptions,
5+
ListExperimentsResult,
6+
NAMESPACE_FIREBASE,
7+
} from "../remoteconfig/interfaces";
8+
import { Command } from "../command";
9+
import { requireAuth } from "../requireAuth";
10+
import { requirePermissions } from "../requirePermissions";
11+
import { logger } from "../logger";
12+
import { needProjectNumber } from "../projectUtils";
13+
import { RemoteConfigOptions } from "../remoteconfig/options";
14+
15+
export const command = new Command("remoteconfig:experiments:list")
16+
.description("get a list of Remote Config experiments")
17+
.option(
18+
"--pageSize <pageSize>",
19+
"Maximum number of experiments to return per page. Defaults to 10. Pass '0' to fetch all experiments",
20+
)
21+
.option(
22+
"--pageToken <pageToken>",
23+
"Token from a previous list operation to retrieve the next page of results. Listing starts from the beginning if omitted.",
24+
)
25+
.option(
26+
"--filter <filter>",
27+
"Filters experiments by their full resource name. Format: `name:projects/{project_number}/namespaces/{namespace}/experiments/{experiment_id}`",
28+
)
29+
.before(requireAuth)
30+
.before(requirePermissions, [
31+
"firebaseabt.experiments.list",
32+
"firebaseanalytics.resources.googleAnalyticsReadAndAnalyze",
33+
])
34+
.action(async (options: RemoteConfigOptions) => {
35+
const projectNumber = await needProjectNumber(options);
36+
const listExperimentOptions: ListExperimentOptions = {
37+
pageSize: options.pageSize ?? DEFAULT_PAGE_SIZE,
38+
pageToken: options.pageToken,
39+
filter: options.filter,
40+
};
41+
const { experiments, nextPageToken }: ListExperimentsResult =
42+
await rcExperiment.listExperiments(projectNumber, NAMESPACE_FIREBASE, listExperimentOptions);
43+
logger.info(rcExperiment.parseExperimentList(experiments ?? []));
44+
if (nextPageToken) {
45+
logger.info(`\nNext Page Token: \x1b[32m${nextPageToken}\x1b[0m\n`);
46+
}
47+
return {
48+
experiments,
49+
nextPageToken,
50+
};
51+
});
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { Command } from "../command";
2+
import { requireAuth } from "../requireAuth";
3+
import { requirePermissions } from "../requirePermissions";
4+
import { logger } from "../logger";
5+
import { needProjectNumber } from "../projectUtils";
6+
import { NAMESPACE_FIREBASE } from "../remoteconfig/interfaces";
7+
import * as rcRollout from "../remoteconfig/deleteRollout";
8+
import { getRollout, parseRolloutIntoTable } from "../remoteconfig/getRollout";
9+
import { confirm } from "../prompt";
10+
import { RemoteConfigOptions } from "../remoteconfig/options";
11+
12+
export const command = new Command("remoteconfig:rollouts:delete <rolloutId>")
13+
.description("delete a Remote Config rollout.")
14+
.before(requireAuth)
15+
.before(requirePermissions, [
16+
"cloud.configs.update",
17+
"firebaseanalytics.resources.googleAnalyticsEdit",
18+
])
19+
.action(async (rolloutId: string, options: RemoteConfigOptions) => {
20+
const projectNumber: string = await needProjectNumber(options);
21+
const rollout = await getRollout(projectNumber, NAMESPACE_FIREBASE, rolloutId);
22+
logger.info(parseRolloutIntoTable(rollout));
23+
const confirmDeletion = await confirm({
24+
message: "Are you sure you want to delete this rollout? This cannot be undone.",
25+
default: false,
26+
});
27+
if (!confirmDeletion) {
28+
return;
29+
}
30+
logger.info(await rcRollout.deleteRollout(projectNumber, NAMESPACE_FIREBASE, rolloutId));
31+
});
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { Command } from "../command";
2+
import { requireAuth } from "../requireAuth";
3+
import { requirePermissions } from "../requirePermissions";
4+
import { logger } from "../logger";
5+
import { needProjectNumber } from "../projectUtils";
6+
import { RemoteConfigRollout, NAMESPACE_FIREBASE } from "../remoteconfig/interfaces";
7+
import * as rcRollout from "../remoteconfig/getRollout";
8+
import { RemoteConfigOptions } from "../remoteconfig/options";
9+
10+
export const command = new Command("remoteconfig:rollouts:get <rolloutId>")
11+
.description("get a Remote Config rollout")
12+
.before(requireAuth)
13+
.before(requirePermissions, ["cloud.configs.get"])
14+
.action(async (rolloutId: string, options: RemoteConfigOptions) => {
15+
const projectNumber: string = await needProjectNumber(options);
16+
const rollout: RemoteConfigRollout = await rcRollout.getRollout(
17+
projectNumber,
18+
NAMESPACE_FIREBASE,
19+
rolloutId,
20+
);
21+
logger.info(rcRollout.parseRolloutIntoTable(rollout));
22+
return rollout;
23+
});
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { Command } from "../command";
2+
import { requireAuth } from "../requireAuth";
3+
import { requirePermissions } from "../requirePermissions";
4+
import { logger } from "../logger";
5+
import { needProjectNumber } from "../projectUtils";
6+
import {
7+
ListRollouts,
8+
NAMESPACE_FIREBASE,
9+
ListRolloutOptions,
10+
DEFAULT_PAGE_SIZE,
11+
} from "../remoteconfig/interfaces";
12+
import * as rcRollout from "../remoteconfig/listRollouts";
13+
import { RemoteConfigOptions } from "../remoteconfig/options"; // 👈 Import the new interface
14+
15+
export const command = new Command("remoteconfig:rollouts:list")
16+
.description("get a list of Remote Config rollouts.")
17+
.option(
18+
"--pageSize <pageSize>",
19+
"Maximum number of rollouts to return per page. Defaults to 10. Pass '0' to fetch all rollouts",
20+
)
21+
.option(
22+
"--pageToken <pageToken>",
23+
"Token from a previous list operation to retrieve the next page of results. Listing starts from the beginning if omitted.",
24+
)
25+
.option(
26+
"--filter <filter>",
27+
"Filters rollouts by their full resource name. Format: `name:projects/{project_id}/namespaces/{namespace}/rollouts/{rollout_id}`",
28+
)
29+
.before(requireAuth)
30+
.before(requirePermissions, [
31+
"cloud.configs.get",
32+
"firebaseanalytics.resources.googleAnalyticsReadAndAnalyze",
33+
])
34+
.action(async (options: RemoteConfigOptions) => {
35+
const projectNumber = await needProjectNumber(options);
36+
const listRolloutOptions: ListRolloutOptions = {
37+
pageSize: options.pageSize ?? DEFAULT_PAGE_SIZE,
38+
pageToken: options.pageToken,
39+
filter: options.filter,
40+
};
41+
const { rollouts, nextPageToken }: ListRollouts = await rcRollout.listRollouts(
42+
projectNumber,
43+
NAMESPACE_FIREBASE,
44+
listRolloutOptions,
45+
);
46+
logger.info(rcRollout.parseRolloutList(rollouts ?? []));
47+
if (nextPageToken) {
48+
logger.info(`\nNext Page Token: \x1b[32m${nextPageToken}\x1b[0m\n`);
49+
}
50+
return {
51+
rollouts,
52+
nextPageToken,
53+
};
54+
});
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { expect } from "chai";
2+
import * as nock from "nock";
3+
import * as clc from "colorette";
4+
5+
import { remoteConfigApiOrigin } from "../api";
6+
import { FirebaseError } from "../error";
7+
import { deleteExperiment } from "./deleteExperiment";
8+
import { NAMESPACE_FIREBASE } from "./interfaces";
9+
10+
const PROJECT_ID = "12345679";
11+
const EXPERIMENT_ID = "1";
12+
13+
describe("Remote Config Experiment Delete", () => {
14+
afterEach(() => {
15+
expect(nock.isDone()).to.equal(true, "all nock stubs should have been called");
16+
nock.cleanAll();
17+
});
18+
19+
it("should delete an experiment successfully", async () => {
20+
nock(remoteConfigApiOrigin())
21+
.delete(
22+
`/v1/projects/${PROJECT_ID}/namespaces/${NAMESPACE_FIREBASE}/experiments/${EXPERIMENT_ID}`,
23+
)
24+
.reply(200);
25+
26+
await expect(
27+
deleteExperiment(PROJECT_ID, NAMESPACE_FIREBASE, EXPERIMENT_ID),
28+
).to.eventually.equal(clc.bold(`Successfully deleted experiment ${clc.yellow(EXPERIMENT_ID)}`));
29+
});
30+
31+
it("should throw FirebaseError if experiment is running", async () => {
32+
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}`;
33+
nock(remoteConfigApiOrigin())
34+
.delete(
35+
`/v1/projects/${PROJECT_ID}/namespaces/${NAMESPACE_FIREBASE}/experiments/${EXPERIMENT_ID}`,
36+
)
37+
.reply(400, {
38+
error: {
39+
message: errorMessage,
40+
},
41+
});
42+
43+
await expect(
44+
deleteExperiment(PROJECT_ID, NAMESPACE_FIREBASE, EXPERIMENT_ID),
45+
).to.be.rejectedWith(FirebaseError, errorMessage);
46+
});
47+
48+
it("should throw FirebaseError if an internal error occurred", async () => {
49+
nock(remoteConfigApiOrigin())
50+
.delete(
51+
`/v1/projects/${PROJECT_ID}/namespaces/${NAMESPACE_FIREBASE}/experiments/${EXPERIMENT_ID}`,
52+
)
53+
.reply(500, {
54+
error: {
55+
message: "Internal server error",
56+
},
57+
});
58+
59+
await expect(
60+
deleteExperiment(PROJECT_ID, NAMESPACE_FIREBASE, EXPERIMENT_ID),
61+
).to.be.rejectedWith(
62+
FirebaseError,
63+
`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`,
64+
);
65+
});
66+
});

0 commit comments

Comments
 (0)