Skip to content

Commit 25673ea

Browse files
authoredApr 1, 2024··
FAH ensure secret (#6940)
* Chose service accounts dialog * upsert secret * Run formatter * Fix field rename * Formatter * Fix refactoring bug * PR feedback * PR feedback * Fix tests
1 parent 9a5534f commit 25673ea

16 files changed

+399
-64
lines changed
 

‎src/apphosting/secrets/index.ts

+60
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { FirebaseError } from "../../error";
2+
import * as gcsm from "../../gcp/secretManager";
3+
import { FIREBASE_MANAGED } from "../../gcp/secretManager";
4+
import { isFunctionsManaged } from "../../gcp/secretManager";
5+
import * as utils from "../../utils";
6+
import * as prompt from "../../prompt";
7+
8+
/**
9+
* Ensures a secret exists for use with app hosting, optionally locked to a region.
10+
* If a secret exists, we verify the user is not trying to change the region and verifies a secret
11+
* is not being used for both functions and app hosting as their garbage collection is incompatible
12+
* (client vs server-side).
13+
* @returns true if a secret was created, false if a secret already existed, and null if a user aborts.
14+
*/
15+
export async function upsertSecret(
16+
project: string,
17+
secret: string,
18+
location?: string,
19+
): Promise<boolean | null> {
20+
let existing: gcsm.Secret;
21+
try {
22+
existing = await gcsm.getSecret(project, secret);
23+
} catch (err: any) {
24+
if (err.status !== 404) {
25+
throw new FirebaseError("Unexpected error loading secret", { original: err });
26+
}
27+
await gcsm.createSecret(project, secret, gcsm.labels("apphosting"), location);
28+
return true;
29+
}
30+
const replication = existing.replication?.userManaged;
31+
if (
32+
location &&
33+
(replication?.replicas?.length !== 1 || replication?.replicas?.[0]?.location !== location)
34+
) {
35+
utils.logLabeledError(
36+
"apphosting",
37+
"Secret replication policies cannot be changed after creation",
38+
);
39+
return null;
40+
}
41+
if (isFunctionsManaged(existing)) {
42+
utils.logLabeledWarning(
43+
"apphosting",
44+
`Cloud Functions for Firebase currently manages versions of ${secret}. Continuing will disable ` +
45+
"automatic deletion of old versions.",
46+
);
47+
const stopTracking = await prompt.confirm({
48+
message: "Do you wish to continue?",
49+
default: false,
50+
});
51+
if (!stopTracking) {
52+
return null;
53+
}
54+
delete existing.labels[FIREBASE_MANAGED];
55+
await gcsm.patchSecret(project, secret, existing.labels);
56+
}
57+
// TODO: consider whether we should prompt a user who has an unmanaged secret to enroll in version control.
58+
// This may not be a great idea until version control is actually implemented.
59+
return false;
60+
}

‎src/commands/apphosting-secrets-grantaccess.ts

+3-2
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { Options } from "../options";
33
import { needProjectId, needProjectNumber } from "../projectUtils";
44
import { FirebaseError } from "../error";
55
import { requireAuth } from "../requireAuth";
6-
import * as secrets from "../functions/secrets";
6+
import * as secretManager from "../gcp/secretManager";
77
import { requirePermissions } from "../requirePermissions";
88
import * as apphosting from "../gcp/apphosting";
99
import { grantSecretAccess } from "../init/features/apphosting/secrets";
@@ -13,7 +13,7 @@ export const command = new Command("apphosting:secrets:grantaccess <secretName>"
1313
.option("-l, --location <location>", "app backend location")
1414
.option("-b, --backend <backend>", "app backend name")
1515
.before(requireAuth)
16-
.before(secrets.ensureApi)
16+
.before(secretManager.ensureApi)
1717
.before(apphosting.ensureApiEnabled)
1818
.before(requirePermissions, [
1919
"secretmanager.secrets.create",
@@ -27,6 +27,7 @@ export const command = new Command("apphosting:secrets:grantaccess <secretName>"
2727
const projectId = needProjectId(options);
2828
const projectNumber = await needProjectNumber(options);
2929

30+
// TODO: Consider reusing dialog in apphosting/secrets/dialogs.ts if backend (and location) is not set.
3031
if (!options.location) {
3132
throw new FirebaseError(
3233
"Missing required flag --location. See firebase apphosting:secrets:grantaccess --help for more info",

‎src/commands/functions-secrets-access.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,14 @@ import { Options } from "../options";
44
import { needProjectId } from "../projectUtils";
55
import { accessSecretVersion } from "../gcp/secretManager";
66
import { requireAuth } from "../requireAuth";
7-
import * as secrets from "../functions/secrets";
7+
import * as secretManager from "../gcp/secretManager";
88

99
export const command = new Command("functions:secrets:access <KEY>[@version]")
1010
.description(
1111
"Access secret value given secret and its version. Defaults to accessing the latest version.",
1212
)
1313
.before(requireAuth)
14-
.before(secrets.ensureApi)
14+
.before(secretManager.ensureApi)
1515
.action(async (key: string, options: Options) => {
1616
const projectId = needProjectId(options);
1717
let [name, version] = key.split("@");

‎src/commands/functions-secrets-destroy.ts

+4-2
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import {
77
getSecret,
88
getSecretVersion,
99
listSecretVersions,
10+
ensureApi,
11+
isFunctionsManaged,
1012
} from "../gcp/secretManager";
1113
import { promptOnce } from "../prompt";
1214
import { logBullet, logWarning } from "../utils";
@@ -19,7 +21,7 @@ export const command = new Command("functions:secrets:destroy <KEY>[@version]")
1921
.description("Destroy a secret. Defaults to destroying the latest version.")
2022
.withForce("Destroys a secret without confirmation.")
2123
.before(requireAuth)
22-
.before(secrets.ensureApi)
24+
.before(ensureApi)
2325
.action(async (key: string, options: Options) => {
2426
const projectId = needProjectId(options);
2527
const projectNumber = await needProjectNumber(options);
@@ -70,7 +72,7 @@ export const command = new Command("functions:secrets:destroy <KEY>[@version]")
7072
logBullet(`Destroyed secret version ${name}@${sv.versionId}`);
7173

7274
const secret = await getSecret(projectId, name);
73-
if (secrets.isFirebaseManaged(secret)) {
75+
if (isFunctionsManaged(secret)) {
7476
const versions = await listSecretVersions(projectId, name);
7577
if (versions.filter((v) => v.state === "ENABLED").length === 0) {
7678
logBullet(`No active secret versions left. Destroying secret ${name}`);

‎src/commands/functions-secrets-get.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,12 @@ import { Options } from "../options";
77
import { needProjectId } from "../projectUtils";
88
import { listSecretVersions } from "../gcp/secretManager";
99
import { requirePermissions } from "../requirePermissions";
10-
import * as secrets from "../functions/secrets";
10+
import * as secretManager from "../gcp/secretManager";
1111

1212
export const command = new Command("functions:secrets:get <KEY>")
1313
.description("Get metadata for secret and its versions")
1414
.before(requireAuth)
15-
.before(secrets.ensureApi)
15+
.before(secretManager.ensureApi)
1616
.before(requirePermissions, ["secretmanager.secrets.get"])
1717
.action(async (key: string, options: Options) => {
1818
const projectId = needProjectId(options);

‎src/commands/functions-secrets-prune.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import * as args from "../deploy/functions/args";
22
import * as backend from "../deploy/functions/backend";
33
import * as secrets from "../functions/secrets";
4+
import * as secretManager from "../gcp/secretManager";
45

56
import { Command } from "../command";
67
import { Options } from "../options";
@@ -16,7 +17,7 @@ export const command = new Command("functions:secrets:prune")
1617
.withForce("Destroys unused secrets without prompt")
1718
.description("Destroys unused secrets")
1819
.before(requireAuth)
19-
.before(secrets.ensureApi)
20+
.before(secretManager.ensureApi)
2021
.before(requirePermissions, [
2122
"cloudfunctions.functions.list",
2223
"secretmanager.secrets.list",

‎src/commands/functions-secrets-set.ts

+4-2
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ import {
1515
addVersion,
1616
destroySecretVersion,
1717
toSecretVersionResourceName,
18+
isFunctionsManaged,
19+
ensureApi,
1820
} from "../gcp/secretManager";
1921
import { check } from "../ensureApiEnabled";
2022
import { requireAuth } from "../requireAuth";
@@ -26,7 +28,7 @@ export const command = new Command("functions:secrets:set <KEY>")
2628
.description("Create or update a secret for use in Cloud Functions for Firebase.")
2729
.withForce("Automatically updates functions to use the new secret.")
2830
.before(requireAuth)
29-
.before(secrets.ensureApi)
31+
.before(ensureApi)
3032
.before(requirePermissions, [
3133
"secretmanager.secrets.create",
3234
"secretmanager.secrets.get",
@@ -61,7 +63,7 @@ export const command = new Command("functions:secrets:set <KEY>")
6163
const secretVersion = await addVersion(projectId, key, secretValue);
6264
logSuccess(`Created a new secret version ${toSecretVersionResourceName(secretVersion)}`);
6365

64-
if (!secrets.isFirebaseManaged(secret)) {
66+
if (!isFunctionsManaged(secret)) {
6567
logBullet(
6668
"Please deploy your functions for the change to take effect by running:\n\t" +
6769
clc.bold("firebase deploy --only functions"),

‎src/deploy/functions/params.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import * as secretManager from "../../gcp/secretManager";
77
import { listBuckets } from "../../gcp/storage";
88
import { isCelExpression, resolveExpression } from "./cel";
99
import { FirebaseConfig } from "./args";
10-
import { labels as secretLabels } from "../../functions/secrets";
10+
import { labels as secretLabels } from "../../gcp/secretManager";
1111

1212
// A convenience type containing options for Prompt's select
1313
interface ListItem {

‎src/functions/secrets.ts

+28-31
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,13 @@ import * as poller from "../operation-poller";
33
import * as gcfV1 from "../gcp/cloudfunctions";
44
import * as gcfV2 from "../gcp/cloudfunctionsv2";
55
import * as backend from "../deploy/functions/backend";
6-
import * as ensureApiEnabled from "../ensureApiEnabled";
7-
import { functionsOrigin, functionsV2Origin, secretManagerOrigin } from "../api";
6+
import { functionsOrigin, functionsV2Origin } from "../api";
87
import {
98
createSecret,
109
destroySecretVersion,
1110
getSecret,
1211
getSecretVersion,
12+
isAppHostingManaged,
1313
listSecrets,
1414
listSecretVersions,
1515
parseSecretResourceName,
@@ -24,9 +24,8 @@ import { promptOnce } from "../prompt";
2424
import { validateKey } from "./env";
2525
import { logger } from "../logger";
2626
import { assertExhaustive } from "../functional";
27-
import { needProjectId } from "../projectUtils";
28-
29-
const FIREBASE_MANAGED = "firebase-managed";
27+
import { isFunctionsManaged, FIREBASE_MANAGED } from "../gcp/secretManager";
28+
import { labels } from "../gcp/secretManager";
3029

3130
// For mysterious reasons, importing the poller option in fabricator.ts leads to some
3231
// value of the poller option to be undefined at runtime. I can't figure out what's going on,
@@ -51,36 +50,13 @@ type ProjectInfo = {
5150
projectNumber: string;
5251
};
5352

54-
/**
55-
* Returns true if secret is managed by Firebase.
56-
*/
57-
export function isFirebaseManaged(secret: Secret): boolean {
58-
return Object.keys(secret.labels || []).includes(FIREBASE_MANAGED);
59-
}
60-
61-
/**
62-
* Return labels to mark secret as managed by Firebase.
63-
* @internal
64-
*/
65-
export function labels(): Record<string, string> {
66-
return { [FIREBASE_MANAGED]: "true" };
67-
}
68-
6953
function toUpperSnakeCase(key: string): string {
7054
return key
7155
.replace(/[.-]/g, "_")
7256
.replace(/([a-z])([A-Z])/g, "$1_$2")
7357
.toUpperCase();
7458
}
7559

76-
/**
77-
* Utility used in the "before" command annotation to enable the API.
78-
*/
79-
export function ensureApi(options: any): Promise<void> {
80-
const projectId = needProjectId(options);
81-
return ensureApiEnabled.ensure(projectId, secretManagerOrigin(), "secretmanager", true);
82-
}
83-
8460
/**
8561
* Validate and transform keys to match the convention recommended by Firebase.
8662
*/
@@ -122,18 +98,39 @@ export async function ensureSecret(
12298
): Promise<Secret> {
12399
try {
124100
const secret = await getSecret(projectId, name);
125-
if (!isFirebaseManaged(secret)) {
101+
if (isAppHostingManaged(secret)) {
102+
logWarning(
103+
"Your secret is managed by Firebase App Hosting. Continuing will disable automatic deletion of old versions.",
104+
);
105+
const stopTracking = await promptOnce(
106+
{
107+
name: "doNotTrack",
108+
type: "confirm",
109+
default: false,
110+
message: "Do you wish to continue?",
111+
},
112+
options,
113+
);
114+
if (stopTracking) {
115+
delete secret.labels[FIREBASE_MANAGED];
116+
await patchSecret(secret.projectId, secret.name, secret.labels);
117+
} else {
118+
throw new Error(
119+
"A secret cannot be managed by both Firebase App Hosting and Cloud Functions for Firebase",
120+
);
121+
}
122+
} else if (!isFunctionsManaged(secret)) {
126123
if (!options.force) {
127124
logWarning(
128-
"Your secret is not managed by Firebase. " +
125+
"Your secret is not managed by Cloud Functions for Firebase. " +
129126
"Firebase managed secrets are automatically pruned to reduce your monthly cost for using Secret Manager. ",
130127
);
131128
const confirm = await promptOnce(
132129
{
133130
name: "updateLabels",
134131
type: "confirm",
135132
default: true,
136-
message: `Would you like to have your secret ${secret.name} managed by Firebase?`,
133+
message: `Would you like to have your secret ${secret.name} managed by Cloud Functions for Firebase?`,
137134
},
138135
options,
139136
);

0 commit comments

Comments
 (0)
Please sign in to comment.