diff --git a/CHANGELOG.md b/CHANGELOG.md index 45060095ac8..dfe8063299a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) 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 7e346426623..9db34645d6e 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -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.list = loadCommand("remoteconfig-experiments-list"); + client.remoteconfig.experiments.delete = loadCommand("remoteconfig-experiments-delete"); client.serve = loadCommand("serve"); client.setup = {}; client.setup.emulators = {}; diff --git a/src/commands/remoteconfig-experiments-delete.ts b/src/commands/remoteconfig-experiments-delete.ts new file mode 100644 index 00000000000..bef535b66a9 --- /dev/null +++ b/src/commands/remoteconfig-experiments-delete.ts @@ -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 ") + .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), + ); + }); diff --git a/src/commands/remoteconfig-experiments-get.ts b/src/commands/remoteconfig-experiments-get.ts new file mode 100644 index 00000000000..0d114c7df19 --- /dev/null +++ b/src/commands/remoteconfig-experiments-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 { GetExperimentResult, NAMESPACE_FIREBASE } from "../remoteconfig/interfaces"; +import * as rcExperiment from "../remoteconfig/getExperiment"; + +export const command = new Command("remoteconfig:experiments:get ") + .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; + }); diff --git a/src/commands/remoteconfig-experiments-list.ts b/src/commands/remoteconfig-experiments-list.ts new file mode 100644 index 00000000000..c9e6d8929aa --- /dev/null +++ b/src/commands/remoteconfig-experiments-list.ts @@ -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 ", + "Maximum number of experiments to return per page. Defaults to 10. Pass '0' to fetch all experiments", + ) + .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 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, + 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, + }; + }); diff --git a/src/commands/remoteconfig-rollouts-delete.ts b/src/commands/remoteconfig-rollouts-delete.ts new file mode 100644 index 00000000000..4dbc9dffa4d --- /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 ") + .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..32a5808dfca --- /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 ") + .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..891022e9663 --- /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.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, + }; + }); diff --git a/src/remoteconfig/deleteExperiment.spec.ts b/src/remoteconfig/deleteExperiment.spec.ts new file mode 100644 index 00000000000..663963f22e7 --- /dev/null +++ b/src/remoteconfig/deleteExperiment.spec.ts @@ -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`, + ); + }); +}); diff --git a/src/remoteconfig/deleteExperiment.ts b/src/remoteconfig/deleteExperiment.ts new file mode 100644 index 00000000000..075ed9e016d --- /dev/null +++ b/src/remoteconfig/deleteExperiment.ts @@ -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 { + try { + await apiClient.request({ + 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 }, + ); + } +} diff --git a/src/remoteconfig/deleteRollout.spec.ts b/src/remoteconfig/deleteRollout.spec.ts new file mode 100644 index 00000000000..43ee7fff28e --- /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..8497b1d44e4 --- /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/getExperiment.spec.ts b/src/remoteconfig/getExperiment.spec.ts new file mode 100644 index 00000000000..06bbf9578a3 --- /dev/null +++ b/src/remoteconfig/getExperiment.spec.ts @@ -0,0 +1,131 @@ +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 rcExperiment from "./getExperiment"; +import { GetExperimentResult, NAMESPACE_FIREBASE } from "./interfaces"; +import { FirebaseError } from "../error"; + +const PROJECT_ID = "1234567890"; +const EXPERIMENT_ID_1 = "1"; +const EXPERIMENT_ID_2 = "2"; + +const expectedExperimentResult: GetExperimentResult = { + name: "projects/1234567890/namespaces/firebase/experiments/1", + definition: { + displayName: "param_one", + service: "EXPERIMENT_SERVICE_REMOTE_CONFIG", + objectives: { + activationEvent: {}, + eventObjectives: [ + { + isPrimary: true, + systemObjectiveDetails: { + objective: "total_revenue", + }, + }, + { + systemObjectiveDetails: { + objective: "retention_7", + }, + }, + { + customObjectiveDetails: { + event: "app_exception", + countType: "NO_EVENT_USERS", + }, + }, + ], + }, + variants: [ + { + name: "Baseline", + weight: 1, + }, + { + name: "Variant A", + weight: 1, + }, + ], + }, + state: "PENDING", + startTime: "1970-01-01T00:00:00Z", + endTime: "1970-01-01T00:00:00Z", + lastUpdateTime: "2025-07-25T08:24:30.682Z", + etag: "e1", +}; + +describe("Remote Config Experiment Get", () => { + describe("getExperiment", () => { + afterEach(() => { + expect(nock.isDone()).to.equal(true, "all nock stubs should have been called"); + nock.cleanAll(); + }); + + it("should successfully retrieve a Remote Config experiment by ID", async () => { + nock(remoteConfigApiOrigin()) + .get(`/v1/projects/${PROJECT_ID}/namespaces/firebase/experiments/${EXPERIMENT_ID_1}`) + .reply(200, expectedExperimentResult); + + const experimentOne = await rcExperiment.getExperiment( + PROJECT_ID, + NAMESPACE_FIREBASE, + EXPERIMENT_ID_1, + ); + expect(experimentOne).to.deep.equal(expectedExperimentResult); + }); + + it("should reject with a FirebaseError if the API call fails", async () => { + nock(remoteConfigApiOrigin()) + .get(`/v1/projects/${PROJECT_ID}/namespaces/firebase/experiments/${EXPERIMENT_ID_2}`) + .reply(404, {}); + + await expect( + rcExperiment.getExperiment(PROJECT_ID, NAMESPACE_FIREBASE, EXPERIMENT_ID_2), + ).to.eventually.be.rejectedWith( + FirebaseError, + `Failed to get Remote Config experiment with ID 2 for project 1234567890.`, + ); + }); + }); + + describe("parseExperiment", () => { + it("should correctly parse and format an experiment result into a tabular format", () => { + const resultTable = rcExperiment.parseExperiment(expectedExperimentResult); + const expectedTable = [ + ["Name", expectedExperimentResult.name], + ["Display Name", expectedExperimentResult.definition.displayName], + ["Service", expectedExperimentResult.definition.service], + [ + "Objectives", + util.inspect(expectedExperimentResult.definition.objectives, { + showHidden: false, + depth: null, + }), + ], + [ + "Variants", + util.inspect(expectedExperimentResult.definition.variants, { + showHidden: false, + depth: null, + }), + ], + ["State", expectedExperimentResult.state], + ["Start Time", expectedExperimentResult.startTime], + ["End Time", expectedExperimentResult.endTime], + ["Last Update Time", expectedExperimentResult.lastUpdateTime], + ["etag", expectedExperimentResult.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/getExperiment.ts b/src/remoteconfig/getExperiment.ts new file mode 100644 index 00000000000..62b56e6656f --- /dev/null +++ b/src/remoteconfig/getExperiment.ts @@ -0,0 +1,71 @@ +import * as Table from "cli-table3"; +import * as util from "util"; + +import { remoteConfigApiOrigin } from "../api"; +import { Client } from "../apiv2"; +import { logger } from "../logger"; +import { FirebaseError, getError } from "../error"; +import { GetExperimentResult } from "./interfaces"; + +const TIMEOUT = 30000; +const TABLE_HEAD = ["Entry Name", "Value"]; + +const apiClient = new Client({ + urlPrefix: remoteConfigApiOrigin(), + apiVersion: "v1", +}); + +/** + * Parses a Remote Config experiment object and formats it into a table. + * @param experiment The Remote Config experiment. + * @return A tabular representation of the experiment. + */ +export const parseExperiment = (experiment: GetExperimentResult): string => { + const table = new Table({ head: TABLE_HEAD, style: { head: ["green"] } }); + table.push(["Name", experiment.name]); + table.push(["Display Name", experiment.definition.displayName]); + table.push(["Service", experiment.definition.service]); + table.push([ + "Objectives", + util.inspect(experiment.definition.objectives, { showHidden: false, depth: null }), + ]); + table.push([ + "Variants", + util.inspect(experiment.definition.variants, { showHidden: false, depth: null }), + ]); + table.push(["State", experiment.state]); + table.push(["Start Time", experiment.startTime]); + table.push(["End Time", experiment.endTime]); + table.push(["Last Update Time", experiment.lastUpdateTime]); + table.push(["etag", experiment.etag]); + return table.toString(); +}; + +/** + * Returns 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 getExperiment( + projectId: string, + namespace: string, + experimentId: string, +): Promise { + try { + const res = await apiClient.request({ + method: "GET", + path: `projects/${projectId}/namespaces/${namespace}/experiments/${experimentId}`, + 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 experiment with ID ${experimentId} for project ${projectId}. Error: ${error.message}`, + { original: error }, + ); + } +} 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 c128345fb11..63c74345742 100644 --- a/src/remoteconfig/interfaces.ts +++ b/src/remoteconfig/interfaces.ts @@ -1,3 +1,6 @@ +export const NAMESPACE_FIREBASE = "firebase"; +export const DEFAULT_PAGE_SIZE = "10"; + export enum TagColor { BLUE = "Blue", BROWN = "Brown", @@ -94,3 +97,114 @@ export interface RemoteConfigUser { name?: string; imageUrl?: string; } + +/** Interface representing a Remote Config experiment. */ +export interface RemoteConfigExperiment { + name: string; + definition: ExperimentDefinition; + state: string; + startTime: string; + endTime: string; + lastUpdateTime: string; + etag: string; +} + +/** Interface representing the definition of a Remote Config experiment. */ +interface ExperimentDefinition { + displayName: string; + service: string; + description?: string; +} + +/** + * Interface representing the result of fetching a Remote Config experiment. + */ +export interface GetExperimentResult extends RemoteConfigExperiment { + definition: GetExperimentDefinition; +} + +/** + * Interface representing a detailed definition of a Remote Config experiment. + */ +interface GetExperimentDefinition extends ExperimentDefinition { + objectives: ExperimentObjectives; + variants: ExperimentVariant[]; +} + +/** Interface representing all objectives of a Remote Config experiment. */ +interface ExperimentObjectives { + activationEvent: { event?: string }; + eventObjectives: ExperimentEventObjectives[]; +} + +/** Type representing the event objectives of a Remote Config experiment. */ +type ExperimentEventObjectives = { + isPrimary?: boolean; +} & ( + | { systemObjectiveDetails: ExperimentSystemObjectiveDetails; customObjectiveDetails?: never } + | { customObjectiveDetails: ExperimentCustomObjectiveDetails; systemObjectiveDetails?: never } +); + +/** Interface representing system objectives of a Remote Config experiment. */ +interface ExperimentSystemObjectiveDetails { + objective: string; +} + +/** Interface representing custom objectives of a Remote Config experiment. */ +interface ExperimentCustomObjectiveDetails { + event: string; + countType: string; +} + +/** Interface representing an experiment variant. */ +interface ExperimentVariant { + name: string; + weight: number; +} + +/** Interface representing a list of Remote Config experiments. */ +export interface ListExperimentsResult { + experiments?: RemoteConfigExperiment[]; + nextPageToken?: string; +} + +/** Interface representing a Remote Config list experiment options. */ +export interface ListExperimentOptions { + pageSize: string; + 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 { + 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/listExperiments.spec.ts b/src/remoteconfig/listExperiments.spec.ts new file mode 100644 index 00000000000..2c87e0185c4 --- /dev/null +++ b/src/remoteconfig/listExperiments.spec.ts @@ -0,0 +1,257 @@ +import { expect } from "chai"; +import * as nock from "nock"; +import * as Table from "cli-table3"; + +import { remoteConfigApiOrigin } from "../api"; +import { FirebaseError } from "../error"; +import { + DEFAULT_PAGE_SIZE, + ListExperimentOptions, + ListExperimentsResult, + NAMESPACE_FIREBASE, + RemoteConfigExperiment, +} from "./interfaces"; +import { listExperiments, parseExperimentList } from "./listExperiments"; + +const PROJECT_ID = "1234567890"; + +const experiment1: RemoteConfigExperiment = { + name: `projects/${PROJECT_ID}/namespaces/firebase/experiments/78`, + definition: { + displayName: "Experiment One", + service: "EXPERIMENT_SERVICE_REMOTE_CONFIG", + description: "Description for Experiment One", + }, + state: "RUNNING", + startTime: "2025-01-01T00:00:00Z", + endTime: "2025-01-31T23:59:59Z", + lastUpdateTime: "2025-01-01T00:00:00Z", + etag: "e1", +}; + +const experiment2: RemoteConfigExperiment = { + name: `projects/${PROJECT_ID}/namespaces/firebase/experiments/22`, + definition: { + displayName: "Experiment Two", + service: "EXPERIMENT_SERVICE_REMOTE_CONFIG", + description: "Description for Experiment Two", + }, + state: "DRAFT", + startTime: "2025-02-01T00:00:00Z", + endTime: "2025-02-28T23:59:59Z", + lastUpdateTime: "2025-02-01T00:00:00Z", + etag: "e2", +}; + +const experiment3: RemoteConfigExperiment = { + name: `projects/1234${PROJECT_ID}567890/namespaces/firebase/experiments/43`, + definition: { + displayName: "Experiment Three", + service: "EXPERIMENT_SERVICE_REMOTE_CONFIG", + description: "Description for Experiment Three", + }, + state: "STOPPED", + startTime: "2025-03-01T00:00:00Z", + endTime: "2025-03-31T23:59:59Z", + lastUpdateTime: "2025-03-01T00:00:00Z", + etag: "e3", +}; + +const experiment4: RemoteConfigExperiment = { + name: `projects/${PROJECT_ID}/namespaces/firebase/experiments/109`, + definition: { + displayName: "Experiment Four", + service: "EXPERIMENT_SERVICE_REMOTE_CONFIG", + description: "Description for Experiment Four", + }, + state: "STOPPED", + startTime: "2025-03-01T00:00:00Z", + endTime: "2025-03-31T23:59:59Z", + lastUpdateTime: "2025-03-01T00:00:00Z", + etag: "e4", +}; + +describe("Remote Config Experiment List", () => { + afterEach(() => { + expect(nock.isDone()).to.equal(true, "all nock stubs should have been called"); + nock.cleanAll(); + }); + + describe("listExperiments", () => { + it("should list all experiments with default page size", async () => { + const listExperimentOptions: ListExperimentOptions = { + pageSize: DEFAULT_PAGE_SIZE, + }; + const expectedResultWithAllExperiments: ListExperimentsResult = { + experiments: [experiment2, experiment3, experiment1, experiment4], + }; + nock(remoteConfigApiOrigin()) + .get(`/v1/projects/${PROJECT_ID}/namespaces/${NAMESPACE_FIREBASE}/experiments`) + .query({ page_size: DEFAULT_PAGE_SIZE }) + .reply(200, expectedResultWithAllExperiments); + + const result = await listExperiments(PROJECT_ID, NAMESPACE_FIREBASE, listExperimentOptions); + + expect(result.experiments).to.deep.equal(expectedResultWithAllExperiments.experiments); + expect(result.nextPageToken).to.equal(expectedResultWithAllExperiments.nextPageToken); + }); + + it("should return paginated experiments when page size and page token are specified", async () => { + const pageSize = "2"; + const pageToken = "NDM="; + const listExperimentOptions: ListExperimentOptions = { + pageSize, + pageToken, + }; + const expectedResultWithPageTokenAndPageSize: ListExperimentsResult = { + experiments: [experiment3, experiment1], + nextPageToken: "MTA5", + }; + nock(remoteConfigApiOrigin()) + .get(`/v1/projects/${PROJECT_ID}/namespaces/${NAMESPACE_FIREBASE}/experiments`) + .query({ page_size: pageSize, page_token: pageToken }) + .reply(200, expectedResultWithPageTokenAndPageSize); + + const result = await listExperiments(PROJECT_ID, NAMESPACE_FIREBASE, listExperimentOptions); + + expect(result.experiments).to.deep.equal(expectedResultWithPageTokenAndPageSize.experiments); + expect(result.nextPageToken).to.equal(expectedResultWithPageTokenAndPageSize.nextPageToken); + }); + + it("should filter and return an experiment from the list", async () => { + const listExperimentOptions: ListExperimentOptions = { + pageSize: DEFAULT_PAGE_SIZE, + filter: `projects/${PROJECT_ID}/namespaces/${NAMESPACE_FIREBASE}/experiments/43`, + }; + const expectedResultWithFilter: ListExperimentsResult = { + experiments: [experiment1], + }; + nock(remoteConfigApiOrigin()) + .get(`/v1/projects/${PROJECT_ID}/namespaces/${NAMESPACE_FIREBASE}/experiments`) + .query({ + filter: `projects/${PROJECT_ID}/namespaces/${NAMESPACE_FIREBASE}/experiments/43`, + page_size: DEFAULT_PAGE_SIZE, + }) + .reply(200, expectedResultWithFilter); + + const result = await listExperiments(PROJECT_ID, NAMESPACE_FIREBASE, listExperimentOptions); + + expect(result.experiments).to.deep.equal(expectedResultWithFilter.experiments); + expect(result.nextPageToken).to.equal(expectedResultWithFilter.nextPageToken); + }); + + it("should return an empty object if filter is invalid", async () => { + const listExperimentOptions: ListExperimentOptions = { + pageSize: DEFAULT_PAGE_SIZE, + filter: `projects/${PROJECT_ID}/namespaces/${NAMESPACE_FIREBASE}/experiments/43`, + }; + nock(remoteConfigApiOrigin()) + .get(`/v1/projects/${PROJECT_ID}/namespaces/${NAMESPACE_FIREBASE}/experiments`) + .query({ filter: `invalid-filter`, page_size: DEFAULT_PAGE_SIZE }) + .reply(200, {}); + + const result = await listExperiments(PROJECT_ID, NAMESPACE_FIREBASE, { + ...listExperimentOptions, + filter: "invalid-filter", + }); + + expect(result.experiments).to.deep.equal(undefined); + }); + + it("should reject with a FirebaseError if the API call fails", async () => { + const listExperimentOptions: ListExperimentOptions = { + pageSize: DEFAULT_PAGE_SIZE, + }; + nock(remoteConfigApiOrigin()) + .get(`/v1/projects/${PROJECT_ID}/namespaces/${NAMESPACE_FIREBASE}/experiments`) + .query({ page_size: DEFAULT_PAGE_SIZE }) + .reply(400, {}); + + await expect( + listExperiments(PROJECT_ID, NAMESPACE_FIREBASE, listExperimentOptions), + ).to.eventually.be.rejectedWith( + FirebaseError, + `Failed to get Remote Config experiments for project ${PROJECT_ID}.`, + ); + }); + }); + + describe("parseExperimentList", () => { + it("should correctly parse and format a list of experiments into a tabular format.", () => { + const allExperiments: RemoteConfigExperiment[] = [ + experiment2, + experiment3, + experiment1, + experiment4, + ]; + const resultTable = parseExperimentList(allExperiments); + const expectedTable = new Table({ + head: [ + "Experiment ID", + "Display Name", + "Service", + "Description", + "State", + "Start Time", + "End Time", + "Last Update Time", + "etag", + ], + style: { head: ["green"] }, + }); + expectedTable.push( + [ + experiment2.name.split("/").pop(), + experiment2.definition.displayName, + experiment2.definition.service, + experiment2.definition.description, + experiment2.state, + experiment2.startTime, + experiment2.endTime, + experiment2.lastUpdateTime, + experiment2.etag, + ], + [ + experiment3.name.split("/").pop(), + experiment3.definition.displayName, + experiment3.definition.service, + experiment3.definition.description, + experiment3.state, + experiment3.startTime, + experiment3.endTime, + experiment3.lastUpdateTime, + experiment3.etag, + ], + [ + experiment1.name.split("/").pop(), + experiment1.definition.displayName, + experiment1.definition.service, + experiment1.definition.description, + experiment1.state, + experiment1.startTime, + experiment1.endTime, + experiment1.lastUpdateTime, + experiment1.etag, + ], + [ + experiment4.name.split("/").pop(), + experiment4.definition.displayName, + experiment4.definition.service, + experiment4.definition.description, + experiment4.state, + experiment4.startTime, + experiment4.endTime, + experiment4.lastUpdateTime, + experiment4.etag, + ], + ); + + expect(resultTable).to.equal(expectedTable.toString()); + }); + + it("should return a message if no experiments are found.", () => { + const result = parseExperimentList([]); + expect(result).to.equal("\x1b[33mNo experiments found\x1b[0m"); + }); + }); +}); diff --git a/src/remoteconfig/listExperiments.ts b/src/remoteconfig/listExperiments.ts new file mode 100644 index 00000000000..ea95f3d826b --- /dev/null +++ b/src/remoteconfig/listExperiments.ts @@ -0,0 +1,91 @@ +import * as Table from "cli-table3"; + +import { remoteConfigApiOrigin } from "../api"; +import { Client } from "../apiv2"; +import { logger } from "../logger"; +import { FirebaseError, getError } from "../error"; +import { ListExperimentOptions, ListExperimentsResult, RemoteConfigExperiment } from "./interfaces"; + +const TIMEOUT = 30000; +const TABLE_HEAD = [ + "Experiment ID", + "Display Name", + "Service", + "Description", + "State", + "Start Time", + "End Time", + "Last Update Time", + "etag", +]; + +const apiClient = new Client({ + urlPrefix: remoteConfigApiOrigin(), + apiVersion: "v1", +}); + +/** + * Parses a list of Remote Config experiments and formats it into a table. + * @param experiments A list of Remote Config experiments. + * @return A tabular representation of the experiments. + */ +export const parseExperimentList = (experiments: RemoteConfigExperiment[]): string => { + if (experiments.length === 0) return "\x1b[33mNo experiments found\x1b[0m"; + + const table = new Table({ head: TABLE_HEAD, style: { head: ["green"] } }); + for (const experiment of experiments) { + table.push([ + experiment.name.split("/").pop(), // Extract the experiment number + experiment.definition.displayName, + experiment.definition.service, + experiment.definition.description, + experiment.state, + experiment.startTime, + experiment.endTime, + experiment.lastUpdateTime, + experiment.etag, + ]); + } + return table.toString(); +}; + +/** + * Returns a list of Remote Config experiments. + * @param projectId The ID of the project. + * @param namespace The namespace under which the experiment is created. + * @param listExperimentOptions Options for listing experiments (e.g., page size, filter, page token). + * @return A promise that resolves to a list of experiment. + */ +export async function listExperiments( + projectId: string, + namespace: string, + listExperimentOptions: ListExperimentOptions, +): Promise { + try { + const params = new URLSearchParams(); + if (listExperimentOptions.pageSize) { + params.set("page_size", listExperimentOptions.pageSize); + } + if (listExperimentOptions.filter) { + params.set("filter", listExperimentOptions.filter); + } + if (listExperimentOptions.pageToken) { + params.set("page_token", listExperimentOptions.pageToken); + } + logger.debug(`Query parameters for listExperiments: ${params.toString()}`); + const res = await apiClient.request({ + method: "GET", + path: `projects/${projectId}/namespaces/${namespace}/experiments`, + 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 experiments for project ${projectId}. Error: ${error.message}`, + { original: error }, + ); + } +} diff --git a/src/remoteconfig/listRollouts.spec.ts b/src/remoteconfig/listRollouts.spec.ts new file mode 100644 index 00000000000..80f052ae649 --- /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 { listRollouts, 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("listRollouts", () => { + 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 listRollouts(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 listRollouts(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 listRollouts(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 listRollouts(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( + listRollouts(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[33mNo rollouts found.\x1b[0m"); + }); + }); +}); diff --git a/src/remoteconfig/listRollouts.ts b/src/remoteconfig/listRollouts.ts new file mode 100644 index 00000000000..1cc871356e8 --- /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[33mNo 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 listRollouts( + 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 }, + ); + } +}