diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e7582597d7..0b7aa0b921b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index 054df08a200..06e65e51f2e 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/src/commands/index.ts b/src/commands/index.ts index ba5fe6b28f9..15273a9ab4c 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -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"); diff --git a/src/commands/remoteconfig-experiments-delete.ts b/src/commands/remoteconfig-experiments-delete.ts index 2f783e50593..64ec5e0775d 100644 --- a/src/commands/remoteconfig-experiments-delete.ts +++ b/src/commands/remoteconfig-experiments-delete.ts @@ -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", diff --git a/src/commands/remoteconfig-experiments-get.ts b/src/commands/remoteconfig-experiments-get.ts index 26f522fe0c2..74d2842d269 100644 --- a/src/commands/remoteconfig-experiments-get.ts +++ b/src/commands/remoteconfig-experiments-get.ts @@ -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) => { diff --git a/src/commands/remoteconfig-rollouts-delete.ts b/src/commands/remoteconfig-rollouts-delete.ts new file mode 100644 index 00000000000..6d0dd76c23d --- /dev/null +++ b/src/commands/remoteconfig-rollouts-delete.ts @@ -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)); + }); diff --git a/src/commands/remoteconfig-rollouts-get.ts b/src/commands/remoteconfig-rollouts-get.ts new file mode 100644 index 00000000000..bff335a8e08 --- /dev/null +++ b/src/commands/remoteconfig-rollouts-get.ts @@ -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; + }); diff --git a/src/commands/remoteconfig-rollouts-list.ts b/src/commands/remoteconfig-rollouts-list.ts new file mode 100644 index 00000000000..47b761b88c9 --- /dev/null +++ b/src/commands/remoteconfig-rollouts-list.ts @@ -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 ", + "Maximum number of rollouts to return per page. Defaults to 10. Pass '0' to fetch all rollouts", + ) + .option( + "--pageToken ", + "Token from a previous list operation to retrieve the next page of results. Listing starts from the beginning if omitted.", + ) + .option( + "--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, + }; + }); diff --git a/src/remoteconfig/deleteRollout.spec.ts b/src/remoteconfig/deleteRollout.spec.ts new file mode 100644 index 00000000000..c2d4e036991 --- /dev/null +++ b/src/remoteconfig/deleteRollout.spec.ts @@ -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, + ); + }); +}); diff --git a/src/remoteconfig/deleteRollout.ts b/src/remoteconfig/deleteRollout.ts new file mode 100644 index 00000000000..946e6a66610 --- /dev/null +++ b/src/remoteconfig/deleteRollout.ts @@ -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 { + try { + await apiClient.request({ + 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 }, + ); + } +} diff --git a/src/remoteconfig/get.spec.ts b/src/remoteconfig/get.spec.ts index f1364acc12a..b7056fb4b37 100644 --- a/src/remoteconfig/get.spec.ts +++ b/src/remoteconfig/get.spec.ts @@ -8,7 +8,6 @@ import { FirebaseError } from "../error"; const PROJECT_ID = "the-remoteconfig-test-project"; -// Test sample template const expectedProjectInfo: RemoteConfigTemplate = { conditions: [ { @@ -47,7 +46,6 @@ const expectedProjectInfo: RemoteConfigTemplate = { etag: "123", }; -// Test sample template with two parameters const projectInfoWithTwoParameters: RemoteConfigTemplate = { conditions: [ { diff --git a/src/remoteconfig/getRollout.spec.ts b/src/remoteconfig/getRollout.spec.ts new file mode 100644 index 00000000000..62fb91b7f76 --- /dev/null +++ b/src/remoteconfig/getRollout.spec.ts @@ -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()); + }); + }); +}); diff --git a/src/remoteconfig/getRollout.ts b/src/remoteconfig/getRollout.ts new file mode 100644 index 00000000000..764442fc45a --- /dev/null +++ b/src/remoteconfig/getRollout.ts @@ -0,0 +1,73 @@ +import { remoteConfigApiOrigin } from "../api"; +import { Client } from "../apiv2"; +import { logger } from "../logger"; +import { FirebaseError, getError } from "../error"; +import { RemoteConfigRollout } from "./interfaces"; +import * as Table from "cli-table3"; +import * as util from "util"; + +const TIMEOUT = 30000; +const TABLE_HEAD = ["Entry Name", "Value"]; + +const apiClient = new Client({ + urlPrefix: remoteConfigApiOrigin(), + apiVersion: "v1", +}); + +/** + * Parses a single rollout object into a CLI table string. + * @param rollout The rollout object. + * @return A string formatted as a table. + */ +export const parseRolloutIntoTable = (rollout: RemoteConfigRollout): string => { + const table = new Table({ head: TABLE_HEAD, style: { head: ["green"] } }); + table.push( + ["Name", rollout.name], + ["Display Name", rollout.definition.displayName], + ["Description", rollout.definition.description], + ["State", rollout.state], + ["Create Time", rollout.createTime], + ["Start Time", rollout.startTime], + ["End Time", rollout.endTime], + ["Last Update Time", rollout.lastUpdateTime], + [ + "Control Variant", + util.inspect(rollout.definition.controlVariant, { showHidden: false, depth: null }), + ], + [ + "Enabled Variant", + util.inspect(rollout.definition.enabledVariant, { showHidden: false, depth: null }), + ], + ["ETag", rollout.etag], + ); + return table.toString(); +}; + +/** + * Retrieves a specific rollout by its ID. + * @param projectId The project ID. + * @param namespace The namespace of the rollout. + * @param rolloutId The ID of the rollout to retrieve. + * @return A promise that resolves to the requested Remote Config rollout. + */ +export async function getRollout( + projectId: string, + namespace: string, + rolloutId: string, +): Promise { + try { + const res = await apiClient.request({ + method: "GET", + path: `/projects/${projectId}/namespaces/${namespace}/rollouts/${rolloutId}`, + timeout: TIMEOUT, + }); + return res.body; + } catch (err: unknown) { + const error: Error = getError(err); + logger.debug(error.message); + throw new FirebaseError( + `Failed to get Remote Config Rollout with ID ${rolloutId} for project ${projectId}. Error: ${error.message}`, + { original: error }, + ); + } +} diff --git a/src/remoteconfig/interfaces.ts b/src/remoteconfig/interfaces.ts index 973ff1feddd..34dd9774b68 100644 --- a/src/remoteconfig/interfaces.ts +++ b/src/remoteconfig/interfaces.ts @@ -174,3 +174,38 @@ export interface ListExperimentOptions { pageToken?: string; filter?: string; } + +/** Interface representing the definition of a Remote Config rollout. */ +export interface RolloutDefinition { + displayName: string; + description: string; + service: string; + controlVariant: ExperimentVariant; + enabledVariant: ExperimentVariant; +} + +/** Interface representing a Remote Config rollout. */ +export interface RemoteConfigRollout { + name: string; + definition: RolloutDefinition; + state: string; + createTime: string; + startTime: string; + endTime: string; + lastUpdateTime: string; + etag: string; +} + +/** Interface representing a list of Remote Config rollouts with pagination. */ +export interface ListRollouts { + // FIXED: Made 'rollouts' optional to handle API responses with no rollouts. + rollouts?: RemoteConfigRollout[]; + nextPageToken?: string; +} + +/** Interface representing a Remote Config list rollout options. */ +export interface ListRolloutOptions { + pageSize: string; + pageToken?: string; + filter?: string; +} diff --git a/src/remoteconfig/listRollouts.spec.ts b/src/remoteconfig/listRollouts.spec.ts new file mode 100644 index 00000000000..7714cd1af4a --- /dev/null +++ b/src/remoteconfig/listRollouts.spec.ts @@ -0,0 +1,260 @@ +import { expect } from "chai"; +import { remoteConfigApiOrigin } from "../api"; +import * as nock from "nock"; +import * as Table from "cli-table3"; + +import { listRollout, parseRolloutList } from "./listRollouts"; +import { + DEFAULT_PAGE_SIZE, + ListRolloutOptions, + ListRollouts, + NAMESPACE_FIREBASE, + RemoteConfigRollout, +} from "./interfaces"; +import { FirebaseError } from "../error"; + +const PROJECT_ID = "1234567890"; +const rollout1: RemoteConfigRollout = { + name: `projects/${PROJECT_ID}/namespaces/firebase/rollouts/rollout_78`, + definition: { + displayName: "Rollout One", + description: "Description for Rollout One", + service: "ROLLOUT_SERVICE_REMOTE_CONFIG", + controlVariant: { name: "Control", weight: 1 }, + enabledVariant: { name: "Enabled", weight: 1 }, + }, + state: "RUNNING", + 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", +}; + +const rollout2: RemoteConfigRollout = { + name: `projects/${PROJECT_ID}/namespaces/firebase/rollouts/rollout_22`, + definition: { + displayName: "Rollout Two", + description: "Description for Rollout Two", + service: "ROLLOUT_SERVICE_REMOTE_CONFIG", + controlVariant: { name: "Control", weight: 1 }, + enabledVariant: { name: "Enabled", weight: 1 }, + }, + state: "DRAFT", + startTime: "2025-02-01T00:00:00Z", + endTime: "2025-02-28T23:59:59Z", + createTime: "2025-02-01T00:00:00Z", + lastUpdateTime: "2025-02-01T00:00:00Z", + etag: "e2", +}; + +const rollout3: RemoteConfigRollout = { + name: `projects/${PROJECT_ID}/namespaces/firebase/rollouts/rollout_43`, + definition: { + displayName: "Rollout Three", + description: "Description for Rollout Three", + service: "ROLLOUT_SERVICE_REMOTE_CONFIG", + controlVariant: { name: "Control", weight: 1 }, + enabledVariant: { name: "Enabled", weight: 1 }, + }, + state: "STOPPED", + startTime: "2025-03-01T00:00:00Z", + endTime: "2025-03-31T23:59:59Z", + createTime: "2025-03-01T00:00:00Z", + lastUpdateTime: "2025-03-01T00:00:00Z", + etag: "e3", +}; + +const rollout4: RemoteConfigRollout = { + name: `projects/${PROJECT_ID}/namespaces/firebase/rollouts/rollout_109`, + definition: { + displayName: "Rollout Four", + description: "Description for Rollout Four", + service: "ROLLOUT_SERVICE_REMOTE_CONFIG", + controlVariant: { name: "Control", weight: 1 }, + enabledVariant: { name: "Enabled", weight: 1 }, + }, + state: "STOPPED", + startTime: "2025-03-01T00:00:00Z", + endTime: "2025-03-31T23:59:59Z", + createTime: "2025-03-01T00:00:00Z", + lastUpdateTime: "2025-03-01T00:00:00Z", + etag: "e3", +}; + +describe("Remote Config Rollout List", () => { + afterEach(() => { + expect(nock.isDone()).to.equal(true, "all nock stubs should have been called"); + nock.cleanAll(); + }); + + describe("listRollout", () => { + it("should list all rollouts with default page size", async () => { + const listRolloutOptions: ListRolloutOptions = { + pageSize: DEFAULT_PAGE_SIZE, + }; + const expectedResultWithAllRollouts: ListRollouts = { + rollouts: [rollout1, rollout2, rollout3, rollout4], + }; + nock(remoteConfigApiOrigin()) + .get(`/v1/projects/${PROJECT_ID}/namespaces/${NAMESPACE_FIREBASE}/rollouts`) + .query({ page_size: DEFAULT_PAGE_SIZE }) + .reply(200, expectedResultWithAllRollouts); + + const result = await listRollout(PROJECT_ID, NAMESPACE_FIREBASE, listRolloutOptions); + + expect(result.rollouts).to.deep.equal(expectedResultWithAllRollouts.rollouts); + expect(result.nextPageToken).to.equal(expectedResultWithAllRollouts.nextPageToken); + }); + + it("should return paginated rollouts when page size and page token are specified", async () => { + const pageSize = "2"; + const pageToken = "NDM="; + const listRolloutOptions: ListRolloutOptions = { + pageSize, + pageToken, + }; + const expectedResultWithPageTokenAndPageSize: ListRollouts = { + rollouts: [rollout3, rollout1], + nextPageToken: "MTA5", + }; + nock(remoteConfigApiOrigin()) + .get(`/v1/projects/${PROJECT_ID}/namespaces/${NAMESPACE_FIREBASE}/rollouts`) + .query({ page_size: pageSize, page_token: pageToken }) + .reply(200, expectedResultWithPageTokenAndPageSize); + + const result = await listRollout(PROJECT_ID, NAMESPACE_FIREBASE, listRolloutOptions); + + expect(result.rollouts).to.deep.equal(expectedResultWithPageTokenAndPageSize.rollouts); + expect(result.nextPageToken).to.equal(expectedResultWithPageTokenAndPageSize.nextPageToken); + }); + + it("should filter and return a rollout from the list", async () => { + const listRolloutOptions: ListRolloutOptions = { + pageSize: DEFAULT_PAGE_SIZE, + filter: `projects/${PROJECT_ID}/namespaces/${NAMESPACE_FIREBASE}/rollouts/rollout_43`, + }; + const expectedResultWithFilter: ListRollouts = { + rollouts: [rollout3], + }; + nock(remoteConfigApiOrigin()) + .get(`/v1/projects/${PROJECT_ID}/namespaces/${NAMESPACE_FIREBASE}/rollouts`) + .query({ + filter: `projects/${PROJECT_ID}/namespaces/${NAMESPACE_FIREBASE}/rollouts/rollout_43`, + page_size: DEFAULT_PAGE_SIZE, + }) + .reply(200, expectedResultWithFilter); + + const result = await listRollout(PROJECT_ID, NAMESPACE_FIREBASE, listRolloutOptions); + + expect(result.rollouts).to.deep.equal(expectedResultWithFilter.rollouts); + expect(result.nextPageToken).to.equal(expectedResultWithFilter.nextPageToken); + }); + + it("should return an empty object if filter is invalid", async () => { + const listRolloutOptions: ListRolloutOptions = { + pageSize: DEFAULT_PAGE_SIZE, + filter: `invalid-filter`, + }; + nock(remoteConfigApiOrigin()) + .get(`/v1/projects/${PROJECT_ID}/namespaces/${NAMESPACE_FIREBASE}/rollouts`) + .query({ filter: `invalid-filter`, page_size: DEFAULT_PAGE_SIZE }) + .reply(200, {}); + + const result = await listRollout(PROJECT_ID, NAMESPACE_FIREBASE, listRolloutOptions); + + expect(result.rollouts).to.deep.equal(undefined); + }); + + it("should reject with a FirebaseError if the API call fails", async () => { + const listRolloutOptions: ListRolloutOptions = { + pageSize: DEFAULT_PAGE_SIZE, + }; + nock(remoteConfigApiOrigin()) + .get(`/v1/projects/${PROJECT_ID}/namespaces/${NAMESPACE_FIREBASE}/rollouts`) + .query({ page_size: DEFAULT_PAGE_SIZE }) + .reply(400, {}); + + await expect( + listRollout(PROJECT_ID, NAMESPACE_FIREBASE, listRolloutOptions), + ).to.eventually.be.rejectedWith( + FirebaseError, + `Failed to get Remote Config rollouts for project ${PROJECT_ID}.`, + ); + }); + }); + + describe("parseRolloutList", () => { + it("should correctly parse and format a list of rollouts into a tabular format.", () => { + const allRollouts: RemoteConfigRollout[] = [rollout2, rollout3, rollout1, rollout4]; + const resultTable = parseRolloutList(allRollouts); + const expectedTable = new Table({ + head: [ + "Rollout ID", + "Display Name", + "Service", + "Description", + "State", + "Start Time", + "End Time", + "Last Update Time", + "ETag", + ], + style: { head: ["green"] }, + }); + expectedTable.push( + [ + rollout2.name.split("/").pop(), + rollout2.definition.displayName, + rollout2.definition.service, + rollout2.definition.description, + rollout2.state, + rollout2.startTime, + rollout2.endTime, + rollout2.lastUpdateTime, + rollout2.etag, + ], + [ + rollout3.name.split("/").pop(), + rollout3.definition.displayName, + rollout3.definition.service, + rollout3.definition.description, + rollout3.state, + rollout3.startTime, + rollout3.endTime, + rollout3.lastUpdateTime, + rollout3.etag, + ], + [ + rollout1.name.split("/").pop(), + rollout1.definition.displayName, + rollout1.definition.service, + rollout1.definition.description, + rollout1.state, + rollout1.startTime, + rollout1.endTime, + rollout1.lastUpdateTime, + rollout1.etag, + ], + [ + rollout4.name.split("/").pop(), + rollout4.definition.displayName, + rollout4.definition.service, + rollout4.definition.description, + rollout4.state, + rollout4.startTime, + rollout4.endTime, + rollout4.lastUpdateTime, + rollout4.etag, + ], + ); + + expect(resultTable).to.equal(expectedTable.toString()); + }); + + it("should return a message if no rollouts are found.", () => { + const result = parseRolloutList([]); + expect(result).to.equal("\x1b[31mNo rollouts found.\x1b[0m"); + }); + }); +}); diff --git a/src/remoteconfig/listRollouts.ts b/src/remoteconfig/listRollouts.ts new file mode 100644 index 00000000000..9f34ab696a3 --- /dev/null +++ b/src/remoteconfig/listRollouts.ts @@ -0,0 +1,89 @@ +import { remoteConfigApiOrigin } from "../api"; +import { Client } from "../apiv2"; +import { logger } from "../logger"; +import { FirebaseError, getError } from "../error"; +import { ListRolloutOptions, ListRollouts, RemoteConfigRollout } from "./interfaces"; +import * as Table from "cli-table3"; + +const TIMEOUT = 30000; + +const apiClient = new Client({ + urlPrefix: remoteConfigApiOrigin(), + apiVersion: "v1", +}); + +const TABLE_HEAD = [ + "Rollout ID", + "Display Name", + "Service", + "Description", + "State", + "Start Time", + "End Time", + "Last Update Time", + "ETag", +]; + +export const parseRolloutList = (rollouts: RemoteConfigRollout[]): string => { + if (rollouts.length === 0) { + return "\x1b[31mNo rollouts found.\x1b[0m"; + } + + const table = new Table({ head: TABLE_HEAD, style: { head: ["green"] } }); + + for (const rollout of rollouts) { + table.push([ + rollout.name.split("/").pop() || rollout.name, + rollout.definition.displayName, + rollout.definition.service, + rollout.definition.description, + rollout.state, + rollout.startTime, + rollout.endTime, + rollout.lastUpdateTime, + rollout.etag, + ]); + } + return table.toString(); +}; + +/** + * Retrieves a list of rollouts for a given project and namespace. + * @param projectId The project ID. + * @param namespace The namespace of the rollout. + * (Options are passed in listRolloutOptions object) + * @return A promise that resolves to a list of Remote Config rollouts. + */ +export async function listRollout( + projectId: string, + namespace: string, + listRolloutOptions: ListRolloutOptions, +): Promise { + try { + const params = new URLSearchParams(); + if (listRolloutOptions.pageSize) { + params.set("page_size", listRolloutOptions.pageSize); + } + if (listRolloutOptions.filter) { + params.set("filter", listRolloutOptions.filter); + } + if (listRolloutOptions.pageToken) { + params.set("page_token", listRolloutOptions.pageToken); + } + + const res = await apiClient.request({ + method: "GET", + path: `/projects/${projectId}/namespaces/${namespace}/rollouts`, + queryParams: params, + timeout: TIMEOUT, + }); + return res.body; + } catch (err: unknown) { + const error: Error = getError(err); + logger.debug(error.message); + throw new FirebaseError( + `Failed to get Remote Config rollouts for project ${projectId}. Error: ${error.message}`, + { original: error }, + ); + } +}