Skip to content
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

Release CF3's support for environment variables and secrets #4149

Merged
merged 14 commits into from
Feb 10, 2022
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
Prev Previous commit
Next Next commit
Add command to prune unused secrets (#4108)
Each active secret version cost money. To help save cost on using Secret Manager, we add `functions:secrets:prune` command which:

1) Looks up all secret versions from secrets marked with label "firebase-managed". All secrets created using the Firebase CLI will have this label.

2) Look up all secret bindings for CF3 function instance.

3) Figure out which secret version isn't currently being used.

Since destroying a secret version is irrevocable and immediately breaking for clients that depend on it, we will always ask for a confirmation for the destroy operations (and not support -f flag).

Note that we now query `v1` of Secret Manager since `v1beta` does not offer filtering by labels.
  • Loading branch information
taeold committed Feb 4, 2022
commit 3310fdc21a5f6988d1b1d28765b3e74877bbd1b9
68 changes: 68 additions & 0 deletions src/commands/functions-secrets-prune.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import * as args from "../deploy/functions/args";
import * as backend from "../deploy/functions/backend";
import { Command } from "../command";
import { Options } from "../options";
import { needProjectId, needProjectNumber } from "../projectUtils";
import { pruneSecrets } from "../functions/secrets";
import { requirePermissions } from "../requirePermissions";
import { isFirebaseManaged } from "../deploymentTool";
import { logBullet, logSuccess } from "../utils";
import { promptOnce } from "../prompt";
import { destroySecretVersion } from "../gcp/secretManager";

export default new Command("functions:secrets:prune")
.description("Destroys unused secrets")
.before(requirePermissions, [
"cloudfunctions.functions.list",
"secretmanager.secrets.list",
"secretmanager.versions.list",
"secretmanager.versions.destroy",
])
.action(async (options: Options) => {
const projectNumber = await needProjectNumber(options);
const projectId = needProjectId(options);

logBullet("Loading secrets...");

const haveBackend = await backend.existingBackend({ projectId } as args.Context);
const haveEndpoints = backend
.allEndpoints(haveBackend)
.filter((e) => isFirebaseManaged(e.labels || []));

const pruned = await pruneSecrets({ projectNumber, projectId }, haveEndpoints);

if (pruned.length === 0) {
logBullet("All secrets are in use. Nothing to prune today.");
return;
}

// prompt to get them all deleted
logBullet(
`Found ${pruned.length} unused active secret versions:\n\t` +
pruned.map((sv) => `${sv.secret}@${sv.version}`).join("\n\t")
);

const confirm = await promptOnce(
{
name: "destroy",
type: "confirm",
default: true,
message: `Do you want to destroy unused secret versions?`,
},
options
);

if (!confirm) {
logBullet(
"Run the following commands to destroy each unused secret version:\n\t" +
pruned
.map((sv) => `firebase functions:secrets:destroy ${sv.secret}@${sv.version}`)
.join("\n\t")
);
return;
}

await Promise.all(pruned.map((sv) => destroySecretVersion(projectId, sv.secret, sv.version)));

logSuccess("Destroyed all unused secrets!");
});
1 change: 1 addition & 0 deletions src/commands/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ module.exports = function (client) {
client.functions.secrets.access = loadCommand("functions-secrets-access");
client.functions.secrets.destroy = loadCommand("functions-secrets-destroy");
client.functions.secrets.get = loadCommand("functions-secrets-get");
client.functions.secrets.prune = loadCommand("functions-secrets-prune");
client.functions.secrets.set = loadCommand("functions-secrets-set");
client.help = loadCommand("help");
client.hosting = {};
Expand Down
1 change: 1 addition & 0 deletions src/deploy/functions/backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ export interface TargetIds {
export interface SecretEnvVar {
key: string;
secret: string;
projectId: string;

// Internal use only. Users cannot pin secret to a specific version.
version?: string;
Expand Down
1 change: 1 addition & 0 deletions src/deploy/functions/runtimes/node/parseTriggers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,7 @@ export function addResourcesToBackend(
for (const secret of annotation.secrets) {
const secretEnv: backend.SecretEnvVar = {
secret,
projectId,
key: secret,
};
secretEnvs.push(secretEnv);
Expand Down
21 changes: 9 additions & 12 deletions src/deploy/functions/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { logger } from "../../logger";
import * as fsutils from "../../fsutils";
import * as backend from "./backend";
import * as utils from "../../utils";
import * as secrets from "../../functions/secrets";

/** Validate that the configuration for endpoints are valid. */
export function endpointsAreValid(wantBackend: backend.Backend): void {
Expand Down Expand Up @@ -126,10 +127,8 @@ function validatePlatformTargets(endpoints: backend.Endpoint[]) {
*/
async function validateSecretVersions(projectId: string, endpoints: backend.Endpoint[]) {
const toResolve: Set<string> = new Set();
for (const e of endpoints) {
for (const s of e.secretEnvironmentVariables! || []) {
toResolve.add(s.secret);
}
for (const s of secrets.of(endpoints)) {
toResolve.add(s.secret);
}

const results = await utils.allSettled(
Expand Down Expand Up @@ -164,14 +163,12 @@ async function validateSecretVersions(projectId: string, endpoints: backend.Endp
}

// Fill in versions.
for (const e of endpoints) {
for (const s of e.secretEnvironmentVariables! || []) {
s.version = secretVersions[s.secret].versionId;
if (!s.version) {
throw new FirebaseError(
"Secret version is unexpectedly undefined. This should never happen."
);
}
for (const s of secrets.of(endpoints)) {
s.version = secretVersions[s.secret].versionId;
if (!s.version) {
throw new FirebaseError(
"Secret version is unexpectedly undefined. This should never happen."
);
}
}
}
68 changes: 67 additions & 1 deletion src/functions/secrets.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,14 @@
import { createSecret, getSecret, patchSecret, Secret } from "../gcp/secretManager";
import * as backend from "../deploy/functions/backend";
import {
createSecret,
getSecret,
getSecretVersion,
listSecrets,
listSecretVersions,
parseSecretResourceName,
patchSecret,
Secret,
} from "../gcp/secretManager";
import { Options } from "../options";
import { FirebaseError } from "../error";
import { logWarning } from "../utils";
Expand Down Expand Up @@ -102,3 +112,59 @@ export async function ensureSecret(
}
return await createSecret(projectId, name, labels());
}

/**
* Collects all secret environment variables of endpoints.
*/
export function of(endpoints: backend.Endpoint[]): backend.SecretEnvVar[] {
return endpoints.reduce(
(envs, endpoint) => [...envs, ...(endpoint.secretEnvironmentVariables || [])],
[] as backend.SecretEnvVar[]
);
}

/**
* Returns all secret versions from Firebase managed secrets unused in the given list of endpoints.
*/
export async function pruneSecrets(
projectInfo: { projectNumber: string; projectId: string },
endpoints: backend.Endpoint[]
): Promise<Required<backend.SecretEnvVar>[]> {
const { projectId, projectNumber } = projectInfo;
const pruneKey = (name: string, version: string) => `${name}@${version}`;
const prunedSecrets: Set<string> = new Set();

// Collect all Firebase managed secret versions
const haveSecrets = await listSecrets(projectId, `labels.${FIREBASE_MANGED}=true`);
for (const secret of haveSecrets) {
const versions = await listSecretVersions(projectId, secret.name, `state: ENABLED`);
for (const version of versions) {
prunedSecrets.add(pruneKey(secret.name, version.versionId));
}
}

// Prune all project-scoped secrets in use.
const sevs = of(endpoints).filter(
(sev) => sev.projectId === projectId || sev.projectId === projectNumber
);
for (const sev of sevs) {
let name = sev.secret;
if (name.includes("/")) {
const secret = parseSecretResourceName(name);
name = secret.name;
}

let version = sev.version;
if (version === "latest") {
// We need to figure out what "latest" resolves to.
const resolved = await getSecretVersion(projectId, name, version);
version = resolved.versionId;
}

prunedSecrets.delete(pruneKey(name, version!));
}

return Array.from(prunedSecrets)
.map((key) => key.split("@"))
.map(([secret, version]) => ({ projectId, version, secret, key: secret }));
}
1 change: 1 addition & 0 deletions src/gcp/cloudfunctions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -517,6 +517,7 @@ export function endpointFromFunction(gcfFunction: CloudFunction): backend.Endpoi
"ingressSettings",
"labels",
"environmentVariables",
"secretEnvironmentVariables",
"sourceUploadUrl"
);

Expand Down
53 changes: 41 additions & 12 deletions src/gcp/secretManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,18 +60,10 @@ interface AccessSecretVersionResponse {
};
}

const API_VERSION = "v1beta1";
const API_VERSION = "v1";

const client = new Client({ urlPrefix: secretManagerOrigin, apiVersion: API_VERSION });

/**
* Returns all secret resources of given project.
*/
export async function listSecrets(projectId: string): Promise<Secret[]> {
const listRes = await client.get<{ secrets: Secret[] }>(`projects/${projectId}/secrets`);
return listRes.body.secrets.map((s: any) => parseSecretResourceName(s.name));
}

/**
* Returns secret resource of given name in the project.
*/
Expand All @@ -82,23 +74,60 @@ export async function getSecret(projectId: string, name: string): Promise<Secret
return secret;
}

/**
* Lists all secret resources associated with a project.
*/
export async function listSecrets(projectId: string, filter?: string): Promise<Secret[]> {
type Response = { secrets: Secret[]; nextPageToken?: string };
const secrets: Secret[] = [];
const path = `projects/${projectId}/secrets`;
const baseOpts = filter ? { queryParams: { filter } } : {};

let pageToken = "";
while (true) {
const opts =
pageToken === ""
? baseOpts
: { ...baseOpts, queryParams: { ...baseOpts?.queryParams, pageToken } };
const res = await client.get<Response>(path, opts);

for (const s of res.body.secrets) {
secrets.push({
...parseSecretResourceName(s.name),
labels: s.labels ?? {},
});
}

if (!res.body.nextPageToken) {
break;
}
pageToken = res.body.nextPageToken;
}
return secrets;
}

/**
* List all secret versions associated with a secret.
*/
export async function listSecretVersions(
projectId: string,
name: string
name: string,
filter?: string
): Promise<Required<SecretVersion[]>> {
type Response = { versions: SecretVersionResponse[]; nextPageToken?: string };
const secrets: Required<SecretVersion[]> = [];
const path = `projects/${projectId}/secrets/${name}/versions`;
const baseOpts = filter ? { queryParams: { filter } } : {};

let pageToken = "";
while (true) {
const opts = pageToken == "" ? {} : { queryParams: { pageToken } };
const opts =
pageToken === ""
? baseOpts
: { ...baseOpts, queryParams: { ...baseOpts?.queryParams, pageToken } };
const res = await client.get<Response>(path, opts);

for (const s of res.body.versions) {
for (const s of res.body.versions || []) {
secrets.push({
...parseSecretVersionResourceName(s.name),
state: s.state,
Expand Down
2 changes: 2 additions & 0 deletions src/test/deploy/functions/ensure.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,11 +152,13 @@ describe("ensureSecretAccess", () => {

const projectId = "project-0";
const secret0: backend.SecretEnvVar = {
projectId: "project",
key: "MY_SECRET_0",
secret: "MY_SECRET_0",
version: "2",
};
const secret1: backend.SecretEnvVar = {
projectId: "project",
key: "ANOTHER_SECRET",
secret: "ANOTHER_SECRET",
version: "1",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,7 @@ describe("addResourcesToBackend", () => {
httpsTrigger: {},
secretEnvironmentVariables: [
{
projectId: "project",
secret: "MY_SECRET",
key: "MY_SECRET",
},
Expand Down
4 changes: 4 additions & 0 deletions src/test/deploy/functions/validate.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,7 @@ describe("validate", () => {
platform: "gcfv2",
secretEnvironmentVariables: [
{
projectId: project,
secret: "MY_SECRET",
key: "MY_SECRET",
},
Expand All @@ -263,6 +264,7 @@ describe("validate", () => {
platform: "gcfv1",
secretEnvironmentVariables: [
{
projectId: project,
secret: "MY_SECRET",
key: "MY_SECRET",
},
Expand All @@ -283,6 +285,7 @@ describe("validate", () => {
platform: "gcfv1",
secretEnvironmentVariables: [
{
projectId: project,
secret: "MY_SECRET",
key: "MY_SECRET",
},
Expand All @@ -303,6 +306,7 @@ describe("validate", () => {
platform: "gcfv1",
secretEnvironmentVariables: [
{
projectId: project,
secret: "MY_SECRET",
key: "MY_SECRET",
},
Expand Down
4 changes: 2 additions & 2 deletions src/test/extensions/secretUtils.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,13 +60,13 @@ describe("secretsUtils", () => {
describe("getManagedSecrets", () => {
it("only returns secrets that have labels set", async () => {
nock(api.secretManagerOrigin)
.get(`/v1beta1/projects/${PROJECT_ID}/secrets/secret1`)
.get(`/v1/projects/${PROJECT_ID}/secrets/secret1`)
.reply(200, {
name: `projects/${PROJECT_ID}/secrets/secret1`,
labels: { "firebase-extensions-managed": "true" },
});
nock(api.secretManagerOrigin)
.get(`/v1beta1/projects/${PROJECT_ID}/secrets/secret2`)
.get(`/v1/projects/${PROJECT_ID}/secrets/secret2`)
.reply(200, {
name: `projects/${PROJECT_ID}/secrets/secret2`,
}); // no labels
Expand Down