Skip to content

Commit

Permalink
Improve function secrets ergonomics (#4130)
Browse files Browse the repository at this point in the history
* Extend `functions:secrets:set` to redeploy affected function triggers and delete now stale secret versions post redeploy.
* Extend `functions:secrets:destroy` to block if the secret version is in use.
* Extend function deploy to cleanup unused secret version post successful deploy.

Also sneaking in a change to improve `prune` logic by finding all secret versions that isn't destroyed (DISABLED secret versions still cost $).
  • Loading branch information
taeold committed Mar 31, 2022
1 parent 1bc5644 commit f2f8ea8
Show file tree
Hide file tree
Showing 9 changed files with 502 additions and 62 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@
- Fixes bug where quoted escape sequences in .env files were incompletely unescaped. (#4270)
- Fixes Storage Emulator ruleset file watcher (#4337).
- Fixes issue with importing Storage Emulator data exported prior to v10.3.0 (#4358).
- Adds ergonomic improvements to CF3 secret commands to automatically redeploy functions and delete unused secrets (#4130).
35 changes: 31 additions & 4 deletions src/commands/functions-secrets-destroy.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { Command } from "../command";
import { logger } from "../logger";
import { Options } from "../options";
import { needProjectId } from "../projectUtils";
import { needProjectId, needProjectNumber } from "../projectUtils";
import {
deleteSecret,
destroySecretVersion,
Expand All @@ -10,18 +9,46 @@ import {
listSecretVersions,
} from "../gcp/secretManager";
import { promptOnce } from "../prompt";
import { logBullet, logWarning } from "../utils";
import * as secrets from "../functions/secrets";
import * as backend from "../deploy/functions/backend";
import * as args from "../deploy/functions/args";

export default new Command("functions:secrets:destroy <KEY>[@version]")
.description("Destroy a secret. Defaults to destroying the latest version.")
.withForce("Destroys a secret without confirmation.")
.action(async (key: string, options: Options) => {
const projectId = needProjectId(options);
const projectNumber = await needProjectNumber(options);
const haveBackend = await backend.existingBackend({ projectId } as args.Context);

let [name, version] = key.split("@");
if (!version) {
version = "latest";
}
const sv = await getSecretVersion(projectId, name, version);

if (sv.state === "DESTROYED") {
logBullet(`Secret ${sv.secret.name}@${version} is already destroyed. Nothing to do.`);
return;
}

const boundEndpoints = backend
.allEndpoints(haveBackend)
.filter((e) => secrets.inUse({ projectId, projectNumber }, sv.secret, e));
if (boundEndpoints.length > 0) {
const endpointsMsg = boundEndpoints
.map((e) => `${e.id}[${e.platform}](${e.region})`)
.join("\t\n");
logWarning(
`Secret ${name}@${version} is currently in use by following functions:\n\t${endpointsMsg}`
);
if (!options.force) {
logWarning("Refusing to destroy secret in use. Use -f to destroy the secret anyway.");
return;
}
}

if (!options.force) {
const confirm = await promptOnce(
{
Expand All @@ -37,13 +64,13 @@ export default new Command("functions:secrets:destroy <KEY>[@version]")
}
}
await destroySecretVersion(projectId, name, version);
logger.info(`Destroyed secret version ${name}@${sv.versionId}`);
logBullet(`Destroyed secret version ${name}@${sv.versionId}`);

const secret = await getSecret(projectId, name);
if (secrets.isFirebaseManaged(secret)) {
const versions = await listSecretVersions(projectId, name);
if (versions.filter((v) => v.state === "ENABLED").length === 0) {
logger.info(`No active secret versions left. Destroying secret ${name}`);
logBullet(`No active secret versions left. Destroying secret ${name}`);
// No active secret version. Remove secret resource.
await deleteSecret(projectId, name);
}
Expand Down
38 changes: 19 additions & 19 deletions src/commands/functions-secrets-prune.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { promptOnce } from "../prompt";
import { destroySecretVersion } from "../gcp/secretManager";

export default new Command("functions:secrets:prune")
.withForce("Destroys unused secrets without prompt")
.description("Destroys unused secrets")
.before(requirePermissions, [
"cloudfunctions.functions.list",
Expand Down Expand Up @@ -42,27 +43,26 @@ export default new Command("functions:secrets:prune")
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")
if (!options.force) {
const confirm = await promptOnce(
{
name: "destroy",
type: "confirm",
default: true,
message: `Do you want to destroy unused secret versions?`,
},
options
);
return;
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!");
});
89 changes: 80 additions & 9 deletions src/commands/functions-secrets-set.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,21 @@ import * as fs from "fs";

import * as clc from "cli-color";

import { ensureValidKey, ensureSecret } from "../functions/secrets";
import { ensureValidKey, ensureSecret, pruneAndDestroySecrets } from "../functions/secrets";
import { Command } from "../command";
import { requirePermissions } from "../requirePermissions";
import { Options } from "../options";
import { promptOnce } from "../prompt";
import { logBullet, logSuccess } from "../utils";
import { needProjectId } from "../projectUtils";
import { logBullet, logSuccess, logWarning } from "../utils";
import { needProjectId, needProjectNumber } from "../projectUtils";
import { addVersion, toSecretVersionResourceName } from "../gcp/secretManager";
import * as secrets from "../functions/secrets";
import * as backend from "../deploy/functions/backend";
import * as args from "../deploy/functions/args";

export default new Command("functions:secrets:set <KEY>")
.description("Create or update a secret for use in Cloud Functions for Firebase")
.withForce(
"Does not ensure input keys are valid or upgrade existing secrets to have Firebase manage them."
)
.description("Create or update a secret for use in Cloud Functions for Firebase.")
.withForce("Automatically updates functions to use the new secret.")
.before(requirePermissions, [
"secretmanager.secrets.create",
"secretmanager.secrets.get",
Expand All @@ -29,6 +30,7 @@ export default new Command("functions:secrets:set <KEY>")
)
.action(async (unvalidatedKey: string, options: Options) => {
const projectId = needProjectId(options);
const projectNumber = await needProjectNumber(options);
const key = await ensureValidKey(unvalidatedKey, options);
const secret = await ensureSecret(projectId, key, options);
let secretValue;
Expand All @@ -49,8 +51,77 @@ export default new Command("functions:secrets:set <KEY>")

const secretVersion = await addVersion(projectId, key, secretValue);
logSuccess(`Created a new secret version ${toSecretVersionResourceName(secretVersion)}`);

if (!secrets.isFirebaseManaged(secret)) {
logBullet(
"Please deploy your functions for the change to take effect by running:\n\t" +
clc.bold("firebase deploy --only functions")
);
return;
}

const haveBackend = await backend.existingBackend({ projectId } as args.Context);
const endpointsToUpdate = backend
.allEndpoints(haveBackend)
.filter((e) => secrets.inUse({ projectId, projectNumber }, secret, e));

if (endpointsToUpdate.length === 0) {
return;
}

logBullet(
"Please deploy your functions for the change to take effect by running:\n\t" +
clc.bold("firebase deploy --only functions")
`${endpointsToUpdate.length} functions are using stale version of secret ${secret.name}:\n\t` +
endpointsToUpdate.map((e) => `${e.id}(${e.region})`).join("\n\t")
);

if (!options.force) {
const confirm = await promptOnce(
{
name: "redeploy",
type: "confirm",
default: true,
message: `Do you want to re-deploy the functions and destroy the stale version of secret ${secret.name}?`,
},
options
);
if (!confirm) {
logBullet(
"Please deploy your functions for the change to take effect by running:\n\t" +
clc.bold("firebase deploy --only functions")
);
return;
}
}

const updateOps = endpointsToUpdate.map(async (e) => {
logBullet(`Updating function ${e.id}(${e.region})...`);
const updated = await secrets.updateEndpointSecret(
{ projectId, projectNumber },
secretVersion,
e
);
logBullet(`Updated function ${e.id}(${e.region}).`);
return updated;
});
const updatedEndpoints = await Promise.all(updateOps);

logBullet(`Pruning stale secrets...`);
const prunedResult = await pruneAndDestroySecrets(
{ projectId, projectNumber },
updatedEndpoints
);
if (prunedResult.destroyed.length > 0) {
logBullet(
`Detroyed unused secret versions: ${prunedResult.destroyed
.map((s) => `${s.secret}@${s.version}`)
.join(", ")}`
);
}
if (prunedResult.erred.length > 0) {
logWarning(
`Failed to destroy unused secret versions:\n\t${prunedResult.erred
.map((err) => err.message)
.join("\n\t")}`
);
}
});
4 changes: 2 additions & 2 deletions src/deploy/functions/release/fabricator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,14 @@ import * as scheduler from "../../../gcp/cloudscheduler";
import * as utils from "../../../utils";

// TODO: Tune this for better performance.
const gcfV1PollerOptions = {
const gcfV1PollerOptions: Omit<poller.OperationPollerOptions, "operationResourceName"> = {
apiOrigin: functionsOrigin,
apiVersion: gcf.API_VERSION,
masterTimeout: 25 * 60 * 1_000, // 25 minutes is the maximum build time for a function
maxBackoff: 10_000,
};

const gcfV2PollerOptions = {
const gcfV2PollerOptions: Omit<poller.OperationPollerOptions, "operationResourceName"> = {
apiOrigin: functionsV2Origin,
apiVersion: gcfV2.API_VERSION,
masterTimeout: 25 * 60 * 1_000, // 25 minutes is the maximum build time for a function
Expand Down
30 changes: 30 additions & 0 deletions src/deploy/functions/release/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,12 @@ import * as fabricator from "./fabricator";
import * as reporter from "./reporter";
import * as executor from "./executor";
import * as prompts from "../prompts";
import * as secrets from "../../../functions/secrets";
import { getAppEngineLocation } from "../../../functionsConfig";
import { getFunctionLabel } from "../functionsDeployHelper";
import { FirebaseError } from "../../../error";
import { needProjectId, needProjectNumber } from "../../../projectUtils";
import { logLabeledBullet, logLabeledWarning } from "../../../utils";

/** Releases new versions of functions to prod. */
export async function release(
Expand Down Expand Up @@ -85,6 +88,33 @@ export async function release(
if (allErrors.length) {
const opts = allErrors.length === 1 ? { original: allErrors[0] } : { children: allErrors };
throw new FirebaseError("There was an error deploying functions", { ...opts, exit: 2 });
} else {
if (secrets.of(haveEndpoints).length > 0) {
const projectId = needProjectId(options);
const projectNumber = await needProjectNumber(options);
// Re-load backend with all endpoints, not just the ones deployed.
const reloadedBackend = await backend.existingBackend({ projectId } as args.Context);
const prunedResult = await secrets.pruneAndDestroySecrets(
{ projectId, projectNumber },
backend.allEndpoints(reloadedBackend)
);
if (prunedResult.destroyed.length > 0) {
logLabeledBullet(
"functions",
`Destroyed unused secret versions: ${prunedResult.destroyed
.map((s) => `${s.secret}@${s.version}`)
.join(", ")}`
);
}
if (prunedResult.erred.length > 0) {
logLabeledWarning(
"functions",
`Failed to destroy unused secret versions:\n\t${prunedResult.erred
.map((err) => err.message)
.join("\n\t")}`
);
}
}
}
}

Expand Down

0 comments on commit f2f8ea8

Please sign in to comment.