-
Notifications
You must be signed in to change notification settings - Fork 1.1k
Rc a/b testing & rollouts cli #9213
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from 2 commits
77ea009
16b6ecd
9b3f5c0
70e6f99
91f4068
e7cbf30
1119305
e7bcb47
9ecddd4
5b8fa89
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
- Added `remoteconfig:experiments:get`, `remoteconfig:experiments:list`, and `remoteconfig:experiments:delete` commands to manage Remote Config experiments. | ||
- Added `remoteconfig:rollouts:get`, `remoteconfig:rollouts:list`, and `remoteconfig:rollouts:delete` commands to manage Remote Config rollouts. |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,33 @@ | ||
import { Command } from "../command"; | ||
import { Options } from "../options"; | ||
import { requireAuth } from "../requireAuth"; | ||
import { requirePermissions } from "../requirePermissions"; | ||
import { logger } from "../logger"; | ||
import { needProjectNumber } from "../projectUtils"; | ||
import { NAMESPACE_FIREBASE } from "../remoteconfig/interfaces"; | ||
import * as rcExperiment from "../remoteconfig/deleteExperiment"; | ||
import { getExperiment, parseExperiment } from "../remoteconfig/getExperiment"; | ||
import { confirm } from "../prompt"; | ||
|
||
export const command = new Command("remoteconfig:experiments:delete [experimentId]") | ||
rathovarun1032 marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
.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), | ||
); | ||
}); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,23 @@ | ||
import { Command } from "../command"; | ||
import { Options } from "../options"; | ||
import { requireAuth } from "../requireAuth"; | ||
import { requirePermissions } from "../requirePermissions"; | ||
import { logger } from "../logger"; | ||
import { needProjectNumber } from "../projectUtils"; | ||
import { GetExperimentResult, NAMESPACE_FIREBASE } from "../remoteconfig/interfaces"; | ||
import * as rcExperiment from "../remoteconfig/getExperiment"; | ||
|
||
export const command = new Command("remoteconfig:experiments:get [experimentId]") | ||
rathovarun1032 marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
.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; | ||
}); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,51 @@ | ||
import * as rcExperiment from "../remoteconfig/listExperiments"; | ||
import { | ||
DEFAULT_PAGE_SIZE, | ||
ListExperimentOptions, | ||
ListExperimentsResult, | ||
NAMESPACE_FIREBASE, | ||
} from "../remoteconfig/interfaces"; | ||
import { Command } from "../command"; | ||
import { Options } from "../options"; | ||
import { requireAuth } from "../requireAuth"; | ||
import { requirePermissions } from "../requirePermissions"; | ||
import { logger } from "../logger"; | ||
import { needProjectNumber } from "../projectUtils"; | ||
|
||
export const command = new Command("remoteconfig:experiments:list") | ||
.description("get a list of Remote Config experiments") | ||
.option( | ||
"--pageSize <pageSize>", | ||
"Maximum number of experiments to return per page. Defaults to 10. Pass '0' to fetch all experiments", | ||
) | ||
.option( | ||
"--pageToken <pageToken>", | ||
"Token from a previous list operation to retrieve the next page of results. Listing starts from the beginning if omitted.", | ||
) | ||
.option( | ||
"--filter <filter>", | ||
"Filters experiments by their full resource name. Format: `name:projects/{project_number}/namespaces/{namespace}/experiments/{experiment_id}`", | ||
) | ||
.before(requireAuth) | ||
.before(requirePermissions, [ | ||
"firebaseabt.experiments.list", | ||
"firebaseanalytics.resources.googleAnalyticsReadAndAnalyze", | ||
]) | ||
.action(async (options: Options) => { | ||
const projectNumber = await needProjectNumber(options); | ||
const listExperimentOptions: ListExperimentOptions = { | ||
pageSize: (options.pageSize as string) ?? DEFAULT_PAGE_SIZE, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I like to avoid this sort of casting by adding a specialized RemoteConfigOptions type that is fulfilled by Options. Here's an example from hosting, would love something similar here: https://github.com/firebase/firebase-tools/blob/master/src/hosting/options.ts Applies for all the commands. |
||
pageToken: options.pageToken as string, | ||
filter: options.filter as string, | ||
}; | ||
const { experiments, nextPageToken }: ListExperimentsResult = | ||
await rcExperiment.listExperiments(projectNumber, NAMESPACE_FIREBASE, listExperimentOptions); | ||
logger.info(rcExperiment.parseExperimentList(experiments ?? [])); | ||
if (nextPageToken) { | ||
logger.info(`\nNext Page Token: \x1b[32m${nextPageToken}\x1b[0m\n`); | ||
} | ||
return { | ||
experiments, | ||
nextPageToken, | ||
}; | ||
}); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,31 @@ | ||
import { Command } from "../command"; | ||
import { Options } from "../options"; | ||
import { requireAuth } from "../requireAuth"; | ||
import { requirePermissions } from "../requirePermissions"; | ||
import { logger } from "../logger"; | ||
import { needProjectNumber } from "../projectUtils"; | ||
import { NAMESPACE_FIREBASE } from "../remoteconfig/interfaces"; | ||
import * as rcRollout from "../remoteconfig/deleteRollout"; | ||
import { getRollout, parseRolloutIntoTable } from "../remoteconfig/getRollout"; | ||
import { confirm } from "../prompt"; | ||
|
||
export const command = new Command("remoteconfig:rollouts:delete [rolloutId]") | ||
rathovarun1032 marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
.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)); | ||
}); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,23 @@ | ||
import { Command } from "../command"; | ||
import { Options } from "../options"; | ||
import { requireAuth } from "../requireAuth"; | ||
import { requirePermissions } from "../requirePermissions"; | ||
import { logger } from "../logger"; | ||
import { needProjectNumber } from "../projectUtils"; | ||
import { RemoteConfigRollout, NAMESPACE_FIREBASE } from "../remoteconfig/interfaces"; | ||
import * as rcRollout from "../remoteconfig/getRollout"; | ||
|
||
export const command = new Command("remoteconfig:rollouts:get [rolloutId]") | ||
rathovarun1032 marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
.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; | ||
}); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,54 @@ | ||
import { Command } from "../command"; | ||
import { Options } from "../options"; | ||
import { requireAuth } from "../requireAuth"; | ||
import { requirePermissions } from "../requirePermissions"; | ||
import { logger } from "../logger"; | ||
import { needProjectNumber } from "../projectUtils"; | ||
import { | ||
ListRollouts, | ||
NAMESPACE_FIREBASE, | ||
ListRolloutOptions, | ||
DEFAULT_PAGE_SIZE, | ||
} from "../remoteconfig/interfaces"; | ||
import * as rcRollout from "../remoteconfig/listRollouts"; | ||
|
||
export const command = new Command("remoteconfig:rollouts:list") | ||
.description("get a list of Remote Config rollouts.") | ||
.option( | ||
"--pageSize <pageSize>", | ||
"Maximum number of rollouts to return per page. Defaults to 10. Pass '0' to fetch all rollouts", | ||
) | ||
.option( | ||
"--pageToken <pageToken>", | ||
"Token from a previous list operation to retrieve the next page of results. Listing starts from the beginning if omitted.", | ||
) | ||
.option( | ||
"--filter <filter>", | ||
"Filters rollouts by their full resource name. Format: `name:projects/{project_id}/namespaces/{namespace}/rollouts/{rollout_id}`", | ||
) | ||
.before(requireAuth) | ||
.before(requirePermissions, [ | ||
"cloud.configs.get", | ||
"firebaseanalytics.resources.googleAnalyticsReadAndAnalyze", | ||
]) | ||
.action(async (options: Options) => { | ||
const projectNumber = await needProjectNumber(options); | ||
const listRolloutOptions: ListRolloutOptions = { | ||
pageSize: (options.pageSize as string) ?? DEFAULT_PAGE_SIZE, | ||
pageToken: options.pageToken as string, | ||
filter: options.filter as string, | ||
}; | ||
const { rollouts, nextPageToken }: ListRollouts = await rcRollout.listRollout( | ||
projectNumber, | ||
NAMESPACE_FIREBASE, | ||
listRolloutOptions, | ||
); | ||
logger.info(rcRollout.parseRolloutList(rollouts ?? [])); | ||
if (nextPageToken) { | ||
logger.info(`\nNext Page Token: \x1b[32m${nextPageToken}\x1b[0m\n`); | ||
} | ||
return { | ||
rollouts, | ||
nextPageToken, | ||
}; | ||
}); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,66 @@ | ||
import { expect } from "chai"; | ||
import * as nock from "nock"; | ||
import * as clc from "colorette"; | ||
|
||
import { remoteConfigApiOrigin } from "../api"; | ||
import { FirebaseError } from "../error"; | ||
import { deleteExperiment } from "./deleteExperiment"; | ||
import { NAMESPACE_FIREBASE } from "./interfaces"; | ||
|
||
const PROJECT_ID = "12345679"; | ||
const EXPERIMENT_ID = "1"; | ||
|
||
describe("Remote Config Experiment Delete", () => { | ||
afterEach(() => { | ||
expect(nock.isDone()).to.equal(true, "all nock stubs should have been called"); | ||
nock.cleanAll(); | ||
}); | ||
|
||
it("should delete an experiment successfully", async () => { | ||
nock(remoteConfigApiOrigin()) | ||
.delete( | ||
`/v1/projects/${PROJECT_ID}/namespaces/${NAMESPACE_FIREBASE}/experiments/${EXPERIMENT_ID}`, | ||
) | ||
.reply(200); | ||
|
||
await expect( | ||
deleteExperiment(PROJECT_ID, NAMESPACE_FIREBASE, EXPERIMENT_ID), | ||
).to.eventually.equal(clc.bold(`Successfully deleted experiment ${clc.yellow(EXPERIMENT_ID)}`)); | ||
}); | ||
|
||
it("should throw FirebaseError if experiment is running", async () => { | ||
const errorMessage = `Experiment ${EXPERIMENT_ID} is currently running and cannot be deleted. If you want to delete this experiment, stop it at https://console.firebase.google.com/project/${PROJECT_ID}/config/experiment/results/${EXPERIMENT_ID}`; | ||
nock(remoteConfigApiOrigin()) | ||
.delete( | ||
`/v1/projects/${PROJECT_ID}/namespaces/${NAMESPACE_FIREBASE}/experiments/${EXPERIMENT_ID}`, | ||
) | ||
.reply(400, { | ||
error: { | ||
message: errorMessage, | ||
}, | ||
}); | ||
|
||
await expect( | ||
deleteExperiment(PROJECT_ID, NAMESPACE_FIREBASE, EXPERIMENT_ID), | ||
).to.be.rejectedWith(FirebaseError, errorMessage); | ||
}); | ||
|
||
it("should throw FirebaseError if an internal error occurred", async () => { | ||
nock(remoteConfigApiOrigin()) | ||
.delete( | ||
`/v1/projects/${PROJECT_ID}/namespaces/${NAMESPACE_FIREBASE}/experiments/${EXPERIMENT_ID}`, | ||
) | ||
.reply(500, { | ||
error: { | ||
message: "Internal server error", | ||
}, | ||
}); | ||
|
||
await expect( | ||
deleteExperiment(PROJECT_ID, NAMESPACE_FIREBASE, EXPERIMENT_ID), | ||
).to.be.rejectedWith( | ||
FirebaseError, | ||
`Failed to delete Remote Config experiment with ID ${EXPERIMENT_ID} for project ${PROJECT_ID}. Error: Request to https://firebaseremoteconfig.googleapis.com/v1/projects/12345679/namespaces/firebase/experiments/1 had HTTP Error: 500, Internal server error`, | ||
); | ||
}); | ||
}); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,48 @@ | ||
import * as clc from "colorette"; | ||
|
||
import { remoteConfigApiOrigin } from "../api"; | ||
import { Client } from "../apiv2"; | ||
import { FirebaseError, getErrMsg, getError } from "../error"; | ||
import { consoleUrl } from "../utils"; | ||
|
||
const TIMEOUT = 30000; | ||
|
||
const apiClient = new Client({ | ||
urlPrefix: remoteConfigApiOrigin(), | ||
apiVersion: "v1", | ||
}); | ||
|
||
/** | ||
* Deletes a Remote Config experiment. | ||
* @param projectId The ID of the project. | ||
* @param namespace The namespace under which the experiment is created. | ||
* @param experimentId The ID of the experiment to retrieve. | ||
* @return A promise that resolves to the experiment object. | ||
*/ | ||
export async function deleteExperiment( | ||
projectId: string, | ||
namespace: string, | ||
experimentId: string, | ||
): Promise<string> { | ||
try { | ||
await apiClient.request<void, void>({ | ||
method: "DELETE", | ||
path: `projects/${projectId}/namespaces/${namespace}/experiments/${experimentId}`, | ||
timeout: TIMEOUT, | ||
}); | ||
return clc.bold(`Successfully deleted experiment ${clc.yellow(experimentId)}`); | ||
} catch (err: unknown) { | ||
const error: Error = getError(err); | ||
if (error.message.includes("is running and cannot be deleted")) { | ||
const rcConsoleUrl = consoleUrl(projectId, `/config/experiment/results/${experimentId}`); | ||
throw new FirebaseError( | ||
`Experiment ${experimentId} is currently running and cannot be deleted. If you want to delete this experiment, stop it at ${rcConsoleUrl}`, | ||
{ original: error }, | ||
); | ||
} | ||
throw new FirebaseError( | ||
`Failed to delete Remote Config experiment with ID ${experimentId} for project ${projectId}. Error: ${getErrMsg(err)}`, | ||
{ original: error }, | ||
); | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks like this CHANGELOG is out of date and is pulling in things that went out in the last release. Can you change this to only include lines 9, 10, and 11?