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
);

‎src/gcp/secretManager.ts

+100-12
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import { logLabeledSuccess } from "../utils";
44
import { FirebaseError } from "../error";
55
import { Client } from "../apiv2";
66
import { secretManagerOrigin } from "../api";
7+
import * as ensureApiEnabled from "../ensureApiEnabled";
8+
import { needProjectId } from "../projectUtils";
79

810
// Matches projects/{PROJECT}/secrets/{SECRET}
911
const SECRET_NAME_REGEX = new RegExp(
@@ -25,11 +27,30 @@ export interface Secret {
2527
name: string;
2628
// This is either projectID or number
2729
projectId: string;
28-
labels?: Record<string, string>;
30+
labels: Record<string, string>;
31+
replication: Replication;
32+
}
33+
34+
export interface WireSecret {
35+
name: string;
36+
labels: Record<string, string>;
37+
replication: Replication;
2938
}
3039

3140
type SecretVersionState = "STATE_UNSPECIFIED" | "ENABLED" | "DISABLED" | "DESTROYED";
3241

42+
export interface Replication {
43+
automatic?: {};
44+
userManaged?: {
45+
replicas: Array<{
46+
location: string;
47+
customerManagedEncryption?: {
48+
kmsKeyName: string;
49+
};
50+
}>;
51+
};
52+
}
53+
3354
export interface SecretVersion {
3455
secret: Secret;
3556
versionId: string;
@@ -40,7 +61,7 @@ export interface SecretVersion {
4061

4162
interface CreateSecretRequest {
4263
name: string;
43-
replication: { automatic: {} };
64+
replication: Replication;
4465
labels: Record<string, string>;
4566
}
4667

@@ -68,17 +89,18 @@ const client = new Client({ urlPrefix: secretManagerOrigin(), apiVersion: API_VE
6889
* Returns secret resource of given name in the project.
6990
*/
7091
export async function getSecret(projectId: string, name: string): Promise<Secret> {
71-
const getRes = await client.get<Secret>(`projects/${projectId}/secrets/${name}`);
92+
const getRes = await client.get<WireSecret>(`projects/${projectId}/secrets/${name}`);
7293
const secret = parseSecretResourceName(getRes.body.name);
7394
secret.labels = getRes.body.labels ?? {};
95+
secret.replication = getRes.body.replication ?? {};
7496
return secret;
7597
}
7698

7799
/**
78100
* Lists all secret resources associated with a project.
79101
*/
80102
export async function listSecrets(projectId: string, filter?: string): Promise<Secret[]> {
81-
type Response = { secrets: Secret[]; nextPageToken?: string };
103+
type Response = { secrets: WireSecret[]; nextPageToken?: string };
82104
const secrets: Secret[] = [];
83105
const path = `projects/${projectId}/secrets`;
84106
const baseOpts = filter ? { queryParams: { filter } } : {};
@@ -95,6 +117,7 @@ export async function listSecrets(projectId: string, filter?: string): Promise<S
95117
secrets.push({
96118
...parseSecretResourceName(s.name),
97119
labels: s.labels ?? {},
120+
replication: s.replication ?? {},
98121
});
99122
}
100123

@@ -238,6 +261,8 @@ export function parseSecretResourceName(resourceName: string): Secret {
238261
return {
239262
projectId: match.groups.project,
240263
name: match.groups.secret,
264+
labels: {},
265+
replication: {},
241266
};
242267
}
243268

@@ -253,6 +278,8 @@ export function parseSecretVersionResourceName(resourceName: string): SecretVers
253278
secret: {
254279
projectId: match.groups.project,
255280
name: match.groups.secret,
281+
labels: {},
282+
replication: {},
256283
},
257284
versionId: match.groups.version,
258285
};
@@ -272,21 +299,36 @@ export async function createSecret(
272299
projectId: string,
273300
name: string,
274301
labels: Record<string, string>,
302+
location?: string,
275303
): Promise<Secret> {
304+
let replication: CreateSecretRequest["replication"];
305+
if (location) {
306+
replication = {
307+
userManaged: {
308+
replicas: [
309+
{
310+
location,
311+
},
312+
],
313+
},
314+
};
315+
} else {
316+
replication = { automatic: {} };
317+
}
318+
276319
const createRes = await client.post<CreateSecretRequest, Secret>(
277320
`projects/${projectId}/secrets`,
278321
{
279322
name,
280-
replication: {
281-
automatic: {},
282-
},
323+
replication,
283324
labels,
284325
},
285326
{ queryParams: { secretId: name } },
286327
);
287328
return {
288329
...parseSecretResourceName(createRes.body.name),
289330
labels,
331+
replication,
290332
};
291333
}
292334

@@ -299,12 +341,16 @@ export async function patchSecret(
299341
labels: Record<string, string>,
300342
): Promise<Secret> {
301343
const fullName = `projects/${projectId}/secrets/${name}`;
302-
const res = await client.patch<Omit<Secret, "projectId">, Secret>(
344+
const res = await client.patch<Omit<WireSecret, "replication">, WireSecret>(
303345
fullName,
304346
{ name: fullName, labels },
305347
{ queryParams: { updateMask: "labels" } }, // Only allow patching labels for now.
306348
);
307-
return parseSecretResourceName(res.body.name);
349+
return {
350+
...parseSecretResourceName(res.body.name),
351+
labels: res.body.labels,
352+
replication: res.body.replication,
353+
};
308354
}
309355

310356
/**
@@ -340,7 +386,9 @@ export async function addVersion(
340386
/**
341387
* Returns IAM policy of a secret resource.
342388
*/
343-
export async function getIamPolicy(secret: Secret): Promise<iam.Policy> {
389+
export async function getIamPolicy(
390+
secret: Pick<Secret, "projectId" | "name">,
391+
): Promise<iam.Policy> {
344392
const res = await client.get<iam.Policy>(
345393
`projects/${secret.projectId}/secrets/${secret.name}:getIamPolicy`,
346394
);
@@ -350,7 +398,10 @@ export async function getIamPolicy(secret: Secret): Promise<iam.Policy> {
350398
/**
351399
* Sets IAM policy on a secret resource.
352400
*/
353-
export async function setIamPolicy(secret: Secret, bindings: iam.Binding[]): Promise<void> {
401+
export async function setIamPolicy(
402+
secret: Pick<Secret, "projectId" | "name">,
403+
bindings: iam.Binding[],
404+
): Promise<void> {
354405
await client.post<{ policy: Partial<iam.Policy>; updateMask: string }, iam.Policy>(
355406
`projects/${secret.projectId}/secrets/${secret.name}:setIamPolicy`,
356407
{
@@ -366,7 +417,7 @@ export async function setIamPolicy(secret: Secret, bindings: iam.Binding[]): Pro
366417
* Ensure that given service agents have the given IAM role on the secret resource.
367418
*/
368419
export async function ensureServiceAgentRole(
369-
secret: Secret,
420+
secret: Pick<Secret, "projectId" | "name">,
370421
serviceAccountEmails: string[],
371422
role: string,
372423
): Promise<void> {
@@ -399,3 +450,40 @@ export async function ensureServiceAgentRole(
399450
} to ${serviceAccountEmails.join(", ")}`,
400451
);
401452
}
453+
454+
export const FIREBASE_MANAGED = "firebase-managed";
455+
456+
/**
457+
* Returns true if secret is managed by Cloud Functions for Firebase.
458+
* This used to be firebase-managed: true, but was later changed to firebase-managed: functions to
459+
* improve readability.
460+
*/
461+
export function isFunctionsManaged(secret: Secret): boolean {
462+
return (
463+
secret.labels[FIREBASE_MANAGED] === "true" || secret.labels[FIREBASE_MANAGED] === "functions"
464+
);
465+
}
466+
467+
/**
468+
* Returns true if secret is managed by Firebase App Hosting.
469+
*/
470+
export function isAppHostingManaged(secret: Secret): boolean {
471+
return secret.labels[FIREBASE_MANAGED] === "apphosting";
472+
}
473+
474+
/**
475+
* Utility used in the "before" command annotation to enable the API.
476+
*/
477+
478+
export function ensureApi(options: any): Promise<void> {
479+
const projectId = needProjectId(options);
480+
return ensureApiEnabled.ensure(projectId, secretManagerOrigin(), "secretmanager", true);
481+
}
482+
/**
483+
* Return labels to mark secret as managed by Firebase.
484+
* @internal
485+
*/
486+
487+
export function labels(product: "functions" | "apphosting" = "functions"): Record<string, string> {
488+
return { [FIREBASE_MANAGED]: product };
489+
}

‎src/init/features/apphosting/secrets.ts

+4-1
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ export async function grantSecretAccess(
4343
);
4444
}
4545

46-
const secret: secretManager.Secret = {
46+
const secret = {
4747
projectId: projectId,
4848
name: secretName,
4949
};
@@ -56,6 +56,8 @@ export async function grantSecretAccess(
5656
`serviceAccount:${serviceAccounts.runServiceAccount}`,
5757
],
5858
},
59+
// Cloud Build needs the viewer role so that it can list secret versions and pin the Build to the
60+
// latest version.
5961
{
6062
role: "roles/secretmanager.viewer",
6163
members: [`serviceAccount:${serviceAccounts.buildServiceAccount}`],
@@ -73,6 +75,7 @@ export async function grantSecretAccess(
7375
}
7476

7577
try {
78+
// TODO: Merge with existing bindings with the same role
7679
const updatedBindings = existingBindings.concat(newBindings);
7780
await secretManager.setIamPolicy(secret, updatedBindings);
7881
} catch (err: any) {
+162
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import { expect } from "chai";
2+
import * as sinon from "sinon";
3+
4+
import * as secrets from "../../../apphosting/secrets";
5+
import * as gcsmImport from "../../../gcp/secretManager";
6+
import * as utilsImport from "../../../utils";
7+
import * as promptImport from "../../../prompt";
8+
9+
describe("secrets", () => {
10+
describe("upsertSecret", () => {
11+
let gcsm: sinon.SinonStubbedInstance<typeof gcsmImport>;
12+
let utils: sinon.SinonStubbedInstance<typeof utilsImport>;
13+
let prompt: sinon.SinonStubbedInstance<typeof promptImport>;
14+
15+
beforeEach(() => {
16+
gcsm = sinon.stub(gcsmImport);
17+
utils = sinon.stub(utilsImport);
18+
prompt = sinon.stub(promptImport);
19+
gcsm.isFunctionsManaged.restore();
20+
gcsm.labels.restore();
21+
});
22+
23+
afterEach(() => {
24+
sinon.verifyAndRestore();
25+
});
26+
27+
it("errors if a user tries to change replication policies (was global)", async () => {
28+
gcsm.getSecret.withArgs("project", "secret").resolves({
29+
name: "secret",
30+
projectId: "project",
31+
labels: gcsm.labels("apphosting"),
32+
replication: {
33+
automatic: {},
34+
},
35+
});
36+
await expect(secrets.upsertSecret("project", "secret", "us-central1")).to.eventually.equal(
37+
null,
38+
);
39+
expect(utils.logLabeledError).to.have.been.calledWith(
40+
"apphosting",
41+
"Secret replication policies cannot be changed after creation",
42+
);
43+
});
44+
45+
it("errors if a user tries to change replication policies (was another region)", async () => {
46+
gcsm.getSecret.withArgs("project", "secret").resolves({
47+
name: "secret",
48+
projectId: "project",
49+
labels: gcsm.labels("apphosting"),
50+
replication: {
51+
userManaged: {
52+
replicas: [
53+
{
54+
location: "us-west1",
55+
},
56+
],
57+
},
58+
},
59+
});
60+
await expect(secrets.upsertSecret("project", "secret", "us-central1")).to.eventually.equal(
61+
null,
62+
);
63+
expect(utils.logLabeledError).to.have.been.calledWith(
64+
"apphosting",
65+
"Secret replication policies cannot be changed after creation",
66+
);
67+
});
68+
69+
it("noops if a secret already exists (location set)", async () => {
70+
gcsm.getSecret.withArgs("project", "secret").resolves({
71+
name: "secret",
72+
projectId: "project",
73+
labels: gcsm.labels("apphosting"),
74+
replication: {
75+
userManaged: {
76+
replicas: [
77+
{
78+
location: "us-central1",
79+
},
80+
],
81+
},
82+
},
83+
});
84+
await expect(secrets.upsertSecret("project", "secret", "us-central1")).to.eventually.equal(
85+
false,
86+
);
87+
expect(utils.logLabeledError).to.not.have.been.called;
88+
});
89+
90+
it("noops if a secret already exists (automatic replication)", async () => {
91+
gcsm.getSecret.withArgs("project", "secret").resolves({
92+
name: "secret",
93+
projectId: "project",
94+
labels: gcsm.labels("apphosting"),
95+
replication: {
96+
automatic: {},
97+
},
98+
});
99+
await expect(secrets.upsertSecret("project", "secret")).to.eventually.equal(false);
100+
expect(utils.logLabeledError).to.not.have.been.called;
101+
});
102+
103+
it("confirms before erasing functions garbage collection (choose yes)", async () => {
104+
gcsm.getSecret.withArgs("project", "secret").resolves({
105+
name: "secret",
106+
projectId: "project",
107+
labels: gcsm.labels("functions"),
108+
replication: {
109+
automatic: {},
110+
},
111+
});
112+
prompt.confirm.resolves(true);
113+
await expect(secrets.upsertSecret("project", "secret")).to.eventually.equal(false);
114+
expect(utils.logLabeledWarning).to.have.been.calledWith(
115+
"apphosting",
116+
"Cloud Functions for Firebase currently manages versions of secret. " +
117+
"Continuing will disable automatic deletion of old versions.",
118+
);
119+
expect(prompt.confirm).to.have.been.calledWithMatch({
120+
message: "Do you wish to continue?",
121+
default: false,
122+
});
123+
expect(gcsm.patchSecret).to.have.been.calledWithMatch("project", "secret", {});
124+
});
125+
126+
it("confirms before erasing functions garbage collection (choose no)", async () => {
127+
gcsm.getSecret.withArgs("project", "secret").resolves({
128+
name: "secret",
129+
projectId: "project",
130+
labels: gcsm.labels("functions"),
131+
replication: {
132+
automatic: {},
133+
},
134+
});
135+
prompt.confirm.resolves(false);
136+
await expect(secrets.upsertSecret("project", "secret")).to.eventually.equal(null);
137+
expect(utils.logLabeledWarning).to.have.been.calledWith(
138+
"apphosting",
139+
"Cloud Functions for Firebase currently manages versions of secret. " +
140+
"Continuing will disable automatic deletion of old versions.",
141+
);
142+
expect(prompt.confirm).to.have.been.calledWithMatch({
143+
message: "Do you wish to continue?",
144+
default: false,
145+
});
146+
expect(gcsm.patchSecret).to.not.have.been.called;
147+
});
148+
149+
it("Creates a secret if none exists", async () => {
150+
gcsm.getSecret.withArgs("project", "secret").rejects({ status: 404 });
151+
152+
await expect(secrets.upsertSecret("project", "secret")).to.eventually.equal(true);
153+
154+
expect(gcsm.createSecret).to.have.been.calledWithMatch(
155+
"project",
156+
"secret",
157+
gcsm.labels("apphosting"),
158+
undefined,
159+
);
160+
});
161+
});
162+
});

‎src/test/deploy/functions/validate.spec.ts

+6-1
Original file line numberDiff line numberDiff line change
@@ -544,7 +544,12 @@ describe("validate", () => {
544544
httpsTrigger: {},
545545
};
546546

547-
const secret: secretManager.Secret = { projectId: project, name: "MY_SECRET" };
547+
const secret: secretManager.Secret = {
548+
projectId: project,
549+
name: "MY_SECRET",
550+
labels: {},
551+
replication: {},
552+
};
548553

549554
let secretVersionStub: sinon.SinonStub;
550555

‎src/test/functions/secrets.spec.ts

+13-2
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,8 @@ describe("functions/secret", () => {
8181
const secret: secretManager.Secret = {
8282
projectId: "project-id",
8383
name: "MY_SECRET",
84-
labels: secrets.labels(),
84+
labels: secretManager.labels("functions"),
85+
replication: {},
8586
};
8687

8788
let sandbox: sinon.SinonSandbox;
@@ -127,7 +128,7 @@ describe("functions/secret", () => {
127128
});
128129

129130
it("does not prompt user to have Firebase manage the secret if already managed by Firebase", async () => {
130-
getStub.resolves({ ...secret, labels: secrets.labels() });
131+
getStub.resolves({ ...secret, labels: secretManager.labels() });
131132
patchStub.resolves(secret);
132133

133134
await expect(
@@ -228,6 +229,8 @@ describe("functions/secret", () => {
228229
const secret1: secretManager.Secret = {
229230
projectId: "project",
230231
name: "MY_SECRET1",
232+
labels: {},
233+
replication: {},
231234
};
232235
const secretVersion11: secretManager.SecretVersion = {
233236
secret: secret1,
@@ -241,6 +244,8 @@ describe("functions/secret", () => {
241244
const secret2: secretManager.Secret = {
242245
projectId: "project",
243246
name: "MY_SECRET2",
247+
labels: {},
248+
replication: {},
244249
};
245250
const secretVersion21: secretManager.SecretVersion = {
246251
secret: secret2,
@@ -333,6 +338,8 @@ describe("functions/secret", () => {
333338
const secret: secretManager.Secret = {
334339
projectId,
335340
name: "MY_SECRET",
341+
labels: {},
342+
replication: {},
336343
};
337344

338345
it("returns true if secret is in use", () => {
@@ -381,6 +388,8 @@ describe("functions/secret", () => {
381388
secret: {
382389
projectId,
383390
name: "MY_SECRET",
391+
labels: {},
392+
replication: {},
384393
},
385394
};
386395

@@ -498,6 +507,8 @@ describe("functions/secret", () => {
498507
secret: {
499508
projectId,
500509
name: "MY_SECRET",
510+
labels: {},
511+
replication: {},
501512
},
502513
versionId: "2",
503514
};

‎src/test/gcp/secretManager.spec.ts

+7-4
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ describe("secretManager", () => {
1111
it("parses valid secret resource name", () => {
1212
expect(
1313
secretManager.parseSecretResourceName("projects/my-project/secrets/my-secret"),
14-
).to.deep.equal({ projectId: "my-project", name: "my-secret" });
14+
).to.deep.equal({ projectId: "my-project", name: "my-secret", labels: {}, replication: {} });
1515
});
1616

1717
it("throws given invalid resource name", () => {
@@ -27,7 +27,7 @@ describe("secretManager", () => {
2727
it("parse secret version resource name", () => {
2828
expect(
2929
secretManager.parseSecretResourceName("projects/my-project/secrets/my-secret/versions/8"),
30-
).to.deep.equal({ projectId: "my-project", name: "my-secret" });
30+
).to.deep.equal({ projectId: "my-project", name: "my-secret", labels: {}, replication: {} });
3131
});
3232
});
3333

@@ -37,7 +37,10 @@ describe("secretManager", () => {
3737
secretManager.parseSecretVersionResourceName(
3838
"projects/my-project/secrets/my-secret/versions/7",
3939
),
40-
).to.deep.equal({ secret: { projectId: "my-project", name: "my-secret" }, versionId: "7" });
40+
).to.deep.equal({
41+
secret: { projectId: "my-project", name: "my-secret", labels: {}, replication: {} },
42+
versionId: "7",
43+
});
4144
});
4245

4346
it("throws given invalid resource name", () => {
@@ -59,7 +62,7 @@ describe("secretManager", () => {
5962

6063
describe("ensureServiceAgentRole", () => {
6164
const projectId = "my-project";
62-
const secret: secretManager.Secret = { projectId, name: "my-secret" };
65+
const secret = { projectId, name: "my-secret" };
6366
const role = "test-role";
6467

6568
let getIamPolicyStub: sinon.SinonStub;

‎src/test/init/apphosting/secrets.spec.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ describe("manageSecrets", () => {
5555

5656
await grantSecretAccess(secretName, location, backendId, projectId, projectNumber);
5757

58-
const secret: secretManager.Secret = {
58+
const secret = {
5959
projectId: projectId,
6060
name: secretName,
6161
};

0 commit comments

Comments
 (0)
Please sign in to comment.