Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
- Added `remoteconfig:experiments:get`, `remoteconfig:experiments:list`, and `remoteconfig:experiments:delete` commands to manage Remote Config experiments.
Copy link
Contributor

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?

- Added `remoteconfig:rollouts:get`, `remoteconfig:rollouts:list`, and `remoteconfig:rollouts:delete` commands to manage Remote Config rollouts.
16 changes: 11 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,11 +161,17 @@ Detailed doc is [here](https://firebase.google.com/docs/cli/auth).

### Remote Config Commands

| Command | Description |
| ------------------------------ | ---------------------------------------------------------------------------------------------------------- |
| **remoteconfig:get** | Get a Firebase project's Remote Config template. |
| **remoteconfig:versions:list** | Get a list of the most recent Firebase Remote Config template versions that have been published. |
| **remoteconfig:rollback** | Roll back a project's published Remote Config template to the version provided by `--version_number` flag. |
| Command | Description |
| ----------------------------------- | ---------------------------------------------------------------------------------------------------------- |
| **remoteconfig:get** | Get a Firebase project's Remote Config template. |
| **remoteconfig:versions:list** | Get a list of the most recent Firebase Remote Config template versions that have been published. |
| **remoteconfig:rollback** | Roll back a project's published Remote Config template to the version provided by `--version_number` flag. |
| **remoteconfig:experiments:get** | Get a Remote Config experiment. |
| **remoteconfig:experiments:list** | Get a list of Remote Config experiments |
| **remoteconfig:experiments:delete** | Delete a Remote Config experiment. |
| **remoteconfig:rollouts:get** | Get a Remote Config rollout. |
| **remoteconfig:rollouts:list** | Get a list of Remote Config rollouts. |
| **remoteconfig:rollouts:delete** | Delete a Remote Config rollout. |

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

Expand Down
8 changes: 8 additions & 0 deletions src/commands/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,14 @@ export function load(client: any): any {
client.remoteconfig.rollback = loadCommand("remoteconfig-rollback");
client.remoteconfig.versions = {};
client.remoteconfig.versions.list = loadCommand("remoteconfig-versions-list");
client.remoteconfig.rollouts = {};
client.remoteconfig.rollouts.get = loadCommand("remoteconfig-rollouts-get");
client.remoteconfig.rollouts.list = loadCommand("remoteconfig-rollouts-list");
client.remoteconfig.rollouts.delete = loadCommand("remoteconfig-rollouts-delete");
client.remoteconfig.experiments = {};
client.remoteconfig.experiments.get = loadCommand("remoteconfig-experiments-get");
client.remoteconfig.experiments.delete = loadCommand("remoteconfig-experiments-delete");
client.remoteconfig.experiments.list = loadCommand("remoteconfig-experiments-list");
client.serve = loadCommand("serve");
client.setup = {};
client.setup.emulators = {};
Expand Down
33 changes: 33 additions & 0 deletions src/commands/remoteconfig-experiments-delete.ts
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),
);
});
23 changes: 23 additions & 0 deletions src/commands/remoteconfig-experiments-get.ts
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;
});
51 changes: 51 additions & 0 deletions src/commands/remoteconfig-experiments-list.ts
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,
Copy link
Contributor

Choose a reason for hiding this comment

The 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,
};
});
31 changes: 31 additions & 0 deletions src/commands/remoteconfig-rollouts-delete.ts
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));
});
23 changes: 23 additions & 0 deletions src/commands/remoteconfig-rollouts-get.ts
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;
});
54 changes: 54 additions & 0 deletions src/commands/remoteconfig-rollouts-list.ts
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.listRollout(
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,
};
});
66 changes: 66 additions & 0 deletions src/remoteconfig/deleteExperiment.spec.ts
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`,
);
});
});
48 changes: 48 additions & 0 deletions src/remoteconfig/deleteExperiment.ts
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 },
);
}
}
Loading
Loading