Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
- 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.
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
4 changes: 4 additions & 0 deletions src/commands/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,10 @@ 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");
Expand Down
2 changes: 1 addition & 1 deletion src/commands/remoteconfig-experiments-delete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ 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")
.description("delete a Remote Config experiment.")
.before(requireAuth)
.before(requirePermissions, [
"firebaseabt.experiments.delete",
Expand Down
2 changes: 1 addition & 1 deletion src/commands/remoteconfig-experiments-get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { GetExperimentResult, NAMESPACE_FIREBASE } from "../remoteconfig/interfa
import * as rcExperiment from "../remoteconfig/getExperiment";

export const command = new Command("remoteconfig:experiments:get [experimentId]")
.description("retrieve a Remote Config experiment")
.description("get a Remote Config experiment.")
.before(requireAuth)
.before(requirePermissions, ["firebaseabt.experiments.get"])
.action(async (experimentId: string, options: Options) => {
Expand Down
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,
};
});
60 changes: 60 additions & 0 deletions src/remoteconfig/deleteRollout.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { expect } from "chai";
import * as nock from "nock";
import { remoteConfigApiOrigin } from "../api";
import { FirebaseError } from "../error";
import { NAMESPACE_FIREBASE } from "./interfaces";
import * as clc from "colorette";
import { deleteRollout } from "./deleteRollout";

const PROJECT_ID = "12345679";
const ROLLOUT_ID = "rollout_1";

describe("Remote Config Rollout Delete", () => {
afterEach(() => {
expect(nock.isDone()).to.equal(true, "all nock stubs should have been called");
nock.cleanAll();
});

it("should delete an rollout successfully", async () => {
nock(remoteConfigApiOrigin())
.delete(`/v1/projects/${PROJECT_ID}/namespaces/${NAMESPACE_FIREBASE}/rollouts/${ROLLOUT_ID}`)
.reply(200);

await expect(deleteRollout(PROJECT_ID, NAMESPACE_FIREBASE, ROLLOUT_ID)).to.eventually.equal(
clc.bold(`Successfully deleted rollout ${clc.yellow(ROLLOUT_ID)}`),
);
});

it("should throw FirebaseError if rollout is running", async () => {
const errorMessage = `Rollout ${ROLLOUT_ID} is currently running and cannot be deleted. If you want to delete this rollout, stop it at https://console.firebase.google.com/project/${PROJECT_ID}/config/env/firebase/rollout/${ROLLOUT_ID}`;
nock(remoteConfigApiOrigin())
.delete(`/v1/projects/${PROJECT_ID}/namespaces/${NAMESPACE_FIREBASE}/rollouts/${ROLLOUT_ID}`)
.reply(400, {
error: {
message: errorMessage,
},
});

await expect(deleteRollout(PROJECT_ID, NAMESPACE_FIREBASE, ROLLOUT_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}/rollouts/${ROLLOUT_ID}`)
.reply(500, {
error: {
message: "Internal server error",
},
});

const expectedErrorMessage = `Failed to delete Remote Config rollout with ID '${ROLLOUT_ID}' for project ${PROJECT_ID}. Error: Request to https://firebaseremoteconfig.googleapis.com/v1/projects/12345679/namespaces/firebase/rollouts/${ROLLOUT_ID} had HTTP Error: 500, Internal server error`;

await expect(deleteRollout(PROJECT_ID, NAMESPACE_FIREBASE, ROLLOUT_ID)).to.be.rejectedWith(
FirebaseError,
expectedErrorMessage,
);
});
});
49 changes: 49 additions & 0 deletions src/remoteconfig/deleteRollout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { remoteConfigApiOrigin } from "../api";
import { Client } from "../apiv2";
import { FirebaseError, getErrMsg, getError } from "../error";
import { consoleUrl } from "../utils";
import * as clc from "colorette";

const TIMEOUT = 30000;

const apiClient = new Client({
urlPrefix: remoteConfigApiOrigin(),
apiVersion: "v1",
});

/**
* Deletes a Remote Config rollout.
* @param projectId The project ID.
* @param namespace The namespace of the rollout.
* @param rolloutId The ID of the rollout to delete.
* @return A promise that resolves when the deletion is complete.
*/
export async function deleteRollout(
projectId: string,
namespace: string,
rolloutId: string,
): Promise<string> {
try {
await apiClient.request<void, void>({
method: "DELETE",
path: `/projects/${projectId}/namespaces/${namespace}/rollouts/${rolloutId}`,
timeout: TIMEOUT,
});
return clc.bold(`Successfully deleted rollout ${clc.yellow(rolloutId)}`);
} catch (err: unknown) {
const originalError = getError(err);
const errorMessage = getErrMsg(err);

if (errorMessage.includes("is running and cannot be deleted")) {
const rcConsoleUrl = consoleUrl(projectId, `/config/env/firebase/rollout/${rolloutId}`);
throw new FirebaseError(
`Rollout '${rolloutId}' is currently running and cannot be deleted. If you want to delete this rollout, stop it at ${rcConsoleUrl}`,
{ original: originalError },
);
}
throw new FirebaseError(
`Failed to delete Remote Config rollout with ID '${rolloutId}' for project ${projectId}. Error: ${errorMessage}`,
{ original: originalError },
);
}
}
2 changes: 0 additions & 2 deletions src/remoteconfig/get.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import { FirebaseError } from "../error";

const PROJECT_ID = "the-remoteconfig-test-project";

// Test sample template
const expectedProjectInfo: RemoteConfigTemplate = {
conditions: [
{
Expand Down Expand Up @@ -47,7 +46,6 @@ const expectedProjectInfo: RemoteConfigTemplate = {
etag: "123",
};

// Test sample template with two parameters
const projectInfoWithTwoParameters: RemoteConfigTemplate = {
conditions: [
{
Expand Down
104 changes: 104 additions & 0 deletions src/remoteconfig/getRollout.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { expect } from "chai";
import { remoteConfigApiOrigin } from "../api";
import * as nock from "nock";
import * as Table from "cli-table3";
import * as util from "util";

import * as rcRollout from "./getRollout";
import { RemoteConfigRollout, NAMESPACE_FIREBASE } from "./interfaces";
import { FirebaseError } from "../error";

const PROJECT_ID = "1234567890";
const ROLLOUT_ID_1 = "rollout_1";
const ROLLOUT_ID_2 = "rollout_2";

const expectedRollout: RemoteConfigRollout = {
name: `projects/${PROJECT_ID}/namespaces/firebase/rollouts/${ROLLOUT_ID_1}`,
definition: {
displayName: "Rollout demo",
description: "rollouts are fun!",
service: "ROLLOUT_SERVICE_REMOTE_CONFIG",
controlVariant: {
name: "Control",
weight: 1,
},
enabledVariant: {
name: "Enabled",
weight: 1,
},
},
state: "DONE",
startTime: "2025-01-01T00:00:00Z",
endTime: "2025-01-31T23:59:59Z",
createTime: "2025-01-01T00:00:00Z",
lastUpdateTime: "2025-01-01T00:00:00Z",
etag: "e1",
};

describe("Remote Config Rollout Get", () => {
describe("getRollout", () => {
afterEach(() => {
expect(nock.isDone()).to.equal(true, "all nock stubs should have been called");
nock.cleanAll();
});

it("should successfully retrieve a Remote Config rollout by ID", async () => {
nock(remoteConfigApiOrigin())
.get(`/v1/projects/${PROJECT_ID}/namespaces/firebase/rollouts/${ROLLOUT_ID_1}`)
.reply(200, expectedRollout);

const rolloutOne = await rcRollout.getRollout(PROJECT_ID, NAMESPACE_FIREBASE, ROLLOUT_ID_1);

expect(rolloutOne).to.deep.equal(expectedRollout);
});

it("should reject with a FirebaseError if the API call fails", async () => {
nock(remoteConfigApiOrigin())
.get(`/v1/projects/${PROJECT_ID}/namespaces/firebase/rollouts/${ROLLOUT_ID_2}`)
.reply(404, {});
const expectedError = `Failed to get Remote Config Rollout with ID ${ROLLOUT_ID_2} for project ${PROJECT_ID}.`;

await expect(
rcRollout.getRollout(PROJECT_ID, NAMESPACE_FIREBASE, ROLLOUT_ID_2),
).to.eventually.be.rejectedWith(FirebaseError, expectedError);
});
});
describe("parseRollout", () => {
it("should correctly parse and format an rollout result into a tabular format", () => {
const resultTable = rcRollout.parseRolloutIntoTable(expectedRollout);
const expectedTable = [
["Name", expectedRollout.name],
["Display Name", expectedRollout.definition.displayName],
["Description", expectedRollout.definition.description],
["State", expectedRollout.state],
["Create Time", expectedRollout.createTime],
["Start Time", expectedRollout.startTime],
["End Time", expectedRollout.endTime],
["Last Update Time", expectedRollout.lastUpdateTime],
[
"Control Variant",
util.inspect(expectedRollout.definition.controlVariant, {
showHidden: false,
depth: null,
}),
],
[
"Enabled Variant",
util.inspect(expectedRollout.definition.enabledVariant, {
showHidden: false,
depth: null,
}),
],
["ETag", expectedRollout.etag],
];

const expectedTableString = new Table({
head: ["Entry Name", "Value"],
style: { head: ["green"] },
});

expectedTableString.push(...expectedTable);
expect(resultTable).to.equal(expectedTableString.toString());
});
});
});
Loading
Loading