Skip to content

Commit 13c1962

Browse files
authored
Secrets set now handles test vs production secrets (#8359)
apphosting:secrets:set now asks if the secret is test-only and can update apphosting.emulator.yaml instead for these cases (as well as granting individuals or groups access). 1. When adding a new secret, prompts if this secret is for local development only. 2. If only local development, asks for a list of users or groups to grant access. 3. If local only, tries to add the secret to apphosting.emulator.yaml instead of apphosting.yaml 4. If local only, tries stripping any "test-" prefix off of suggested key names to help with user flows where people will want something like stripe-key and test-stripe-key to both point to STRIPE_KEY in apphosting.yaml and apphosting.emulator.yaml respectively 5. Fixes a slight bug where we found a project root with X file and try to insert the variable in Y file (e.g. apphosting.local.yaml exists, but we're adding to apphosting.yaml) 6. Prints guidance on how to grant access to a secret in further commands
1 parent 2481ece commit 13c1962

File tree

6 files changed

+105
-35
lines changed

6 files changed

+105
-35
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
- Fix bug in Auth emulator where accounts:lookup is case-sensitive for emails (#8344)
22
- firebase apphosting:secrets:grantAccess can now grant access to emails and can grant multiple secrets at once (#8357)
3+
- firebase apphosting:secrets:set now has flows to help with test secrets (#8359)

src/apphosting/config.spec.ts

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -22,37 +22,37 @@ describe("config", () => {
2222
});
2323

2424
it("finds apphosting.yaml at cwd", () => {
25-
fs.fileExistsSync.withArgs("/cwd/apphosting.yaml").returns(true);
26-
expect(config.discoverBackendRoot("/cwd")).equals("/cwd");
25+
fs.listFiles.withArgs("/parent/cwd").returns(["apphosting.yaml"]);
26+
expect(config.discoverBackendRoot("/parent/cwd")).equals("/parent/cwd");
2727
});
2828

2929
it("finds apphosting.yaml in a parent directory", () => {
30-
fs.fileExistsSync.withArgs("/parent/cwd/apphosting.yaml").returns(false);
31-
fs.fileExistsSync.withArgs("/parent/cwd/firebase.json").returns(false);
32-
fs.fileExistsSync.withArgs("/parent/apphosting.yaml").returns(true);
30+
fs.listFiles.withArgs("/parent/cwd").returns(["random_file.txt"]);
31+
fs.listFiles.withArgs("/parent").returns(["apphosting.yaml"]);
3332

3433
expect(config.discoverBackendRoot("/parent/cwd")).equals("/parent");
3534
});
3635

3736
it("returns null if it finds firebase.json without finding apphosting.yaml", () => {
38-
fs.fileExistsSync.withArgs("/parent/cwd/apphosting.yaml").returns(false);
39-
fs.fileExistsSync.withArgs("/parent/cwd/firebase.json").returns(false);
40-
fs.fileExistsSync.withArgs("/parent/apphosting.yaml").returns(false);
41-
fs.fileExistsSync.withArgs("/parent/firebase.json").returns(true);
37+
fs.listFiles.withArgs("/parent/cwd").returns([]);
38+
fs.listFiles.withArgs("/parent").returns(["firebase.json"]);
4239

4340
expect(config.discoverBackendRoot("/parent/cwd")).equals(null);
4441
});
4542

4643
it("returns if it reaches the fs root", () => {
47-
fs.fileExistsSync.withArgs("/parent/cwd/apphosting.yaml").returns(false);
48-
fs.fileExistsSync.withArgs("/parent/cwd/firebase.json").returns(false);
49-
fs.fileExistsSync.withArgs("/parent/apphosting.yaml").returns(false);
50-
fs.fileExistsSync.withArgs("/parent/firebase.json").returns(false);
51-
fs.fileExistsSync.withArgs("/apphosting.yaml").returns(false);
52-
fs.fileExistsSync.withArgs("/firebase.json").returns(false);
44+
fs.listFiles.withArgs("/parent/cwd").returns([]);
45+
fs.listFiles.withArgs("/parent").returns(["random_file.txt"]);
46+
fs.listFiles.withArgs("/").returns([]);
5347

5448
expect(config.discoverBackendRoot("/parent/cwd")).equals(null);
5549
});
50+
51+
it("discovers backend root from any apphosting yaml file", () => {
52+
fs.listFiles.withArgs("/parent/cwd").returns(["apphosting.staging.yaml"]);
53+
54+
expect(config.discoverBackendRoot("/parent/cwd")).equals("/parent/cwd");
55+
});
5656
});
5757

5858
describe("get/setEnv", () => {

src/apphosting/config.ts

Lines changed: 39 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { NodeType } from "yaml/dist/nodes/Node";
77
import * as prompt from "../prompt";
88
import * as dialogs from "./secrets/dialogs";
99
import { AppHostingYamlConfig } from "./yaml";
10-
import { FirebaseError } from "../error";
10+
import { FirebaseError, getError } from "../error";
1111
import { promptForAppHostingYaml } from "./utils";
1212
import { fetchSecrets } from "./secrets";
1313
import { logger } from "../logger";
@@ -66,9 +66,14 @@ const EXPORTABLE_CONFIG = [SECRET_CONFIG];
6666
export function discoverBackendRoot(cwd: string): string | null {
6767
let dir = cwd;
6868

69-
while (!fs.fileExistsSync(resolve(dir, APPHOSTING_BASE_YAML_FILE))) {
69+
while (true) {
70+
const files = fs.listFiles(dir);
71+
if (files.some((file) => APPHOSTING_YAML_FILE_REGEX.test(file))) {
72+
return dir;
73+
}
74+
7075
// We've hit project root
71-
if (fs.fileExistsSync(resolve(dir, "firebase.json"))) {
76+
if (files.includes("firebase.json")) {
7277
return null;
7378
}
7479

@@ -79,8 +84,6 @@ export function discoverBackendRoot(cwd: string): string | null {
7984
}
8085
dir = parent;
8186
}
82-
83-
return dir;
8487
}
8588

8689
/**
@@ -93,9 +96,23 @@ export function listAppHostingFilesInPath(path: string): string[] {
9396
.map((file) => join(path, file));
9497
}
9598

96-
/** Load apphosting.yaml */
99+
/**
100+
* Load an apphosting yaml file if it exists.
101+
* Throws if the file exists but is malformed.
102+
* Returns an empty document if the file does not exist.
103+
*/
97104
export function load(yamlPath: string): yaml.Document {
98-
const raw = fs.readFile(yamlPath);
105+
let raw: string;
106+
try {
107+
raw = fs.readFile(yamlPath);
108+
} catch (err: any) {
109+
if (err.code !== "ENOENT") {
110+
throw new FirebaseError(`Unexpected error trying to load ${yamlPath}`, {
111+
original: getError(err),
112+
});
113+
}
114+
return new yaml.Document();
115+
}
99116
return yaml.parseDocument(raw);
100117
}
101118

@@ -140,13 +157,16 @@ export function upsertEnv(document: yaml.Document, env: Env): void {
140157
}
141158

142159
/**
143-
* Given a secret name, guides the user whether they want to add that secret to apphosting.yaml.
144-
* If an apphosting.yaml exists and includes the secret already is used as a variable name, exist early.
145-
* If apphosting.yaml does not exist, offers to create it.
160+
* Given a secret name, guides the user whether they want to add that secret to the specified apphosting yaml file.
161+
* If an the file exists and includes the secret already is used as a variable name, exist early.
162+
* If the file does not exist, offers to create it.
146163
* If env does not exist, offers to add it.
147164
* If secretName is not a valid env var name, prompts for an env var name.
148165
*/
149-
export async function maybeAddSecretToYaml(secretName: string): Promise<void> {
166+
export async function maybeAddSecretToYaml(
167+
secretName: string,
168+
fileName: string = APPHOSTING_BASE_YAML_FILE,
169+
): Promise<void> {
150170
// We must go through the exports object for stubbing to work in tests.
151171
const dynamicDispatch = exports as {
152172
discoverBackendRoot: typeof discoverBackendRoot;
@@ -160,7 +180,7 @@ export async function maybeAddSecretToYaml(secretName: string): Promise<void> {
160180
let path: string | undefined;
161181
let projectYaml: yaml.Document;
162182
if (backendRoot) {
163-
path = join(backendRoot, APPHOSTING_BASE_YAML_FILE);
183+
path = join(backendRoot, fileName);
164184
projectYaml = dynamicDispatch.load(path);
165185
} else {
166186
projectYaml = new yaml.Document();
@@ -170,21 +190,23 @@ export async function maybeAddSecretToYaml(secretName: string): Promise<void> {
170190
return;
171191
}
172192
const addToYaml = await prompt.confirm({
173-
message: "Would you like to add this secret to apphosting.yaml?",
193+
message: `Would you like to add this secret to ${fileName}?`,
174194
default: true,
175195
});
176196
if (!addToYaml) {
177197
return;
178198
}
179199
if (!path) {
180200
path = await prompt.promptOnce({
181-
message:
182-
"It looks like you don't have an apphosting.yaml yet. Where would you like to store it?",
201+
message: `It looks like you don't have an ${fileName} yet. Where would you like to store it?`,
183202
default: process.cwd(),
184203
});
185-
path = join(path, APPHOSTING_BASE_YAML_FILE);
204+
path = join(path, fileName);
186205
}
187-
const envName = await dialogs.envVarForSecret(secretName);
206+
const envName = await dialogs.envVarForSecret(
207+
secretName,
208+
/* trimTestPrefix= */ fileName === APPHOSTING_EMULATORS_YAML_FILE,
209+
);
188210
dynamicDispatch.upsertEnv(projectYaml, {
189211
variable: envName,
190212
secret: secretName,

src/apphosting/secrets/dialogs.spec.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -465,5 +465,17 @@ describe("dialogs", () => {
465465
/Key X_GOOGLE_SECRET starts with a reserved prefix/,
466466
);
467467
});
468+
469+
it("can trim test prefixes", async () => {
470+
prompt.promptOnce.resolves("SECRET");
471+
472+
await expect(
473+
dialogs.envVarForSecret("test-secret", /* trimTestPrefix=*/ true),
474+
).to.eventually.equal("SECRET");
475+
expect(prompt.promptOnce).to.have.been.calledWithMatch({
476+
message: "What environment variable name would you like to use?",
477+
default: "SECRET",
478+
});
479+
});
468480
});
469481
});

src/apphosting/secrets/dialogs.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -207,8 +207,14 @@ function toUpperSnakeCase(key: string): string {
207207
.toUpperCase();
208208
}
209209

210-
export async function envVarForSecret(secret: string): Promise<string> {
211-
const upper = toUpperSnakeCase(secret);
210+
export async function envVarForSecret(
211+
secret: string,
212+
trimTestPrefix: boolean = false,
213+
): Promise<string> {
214+
let upper = toUpperSnakeCase(secret);
215+
if (trimTestPrefix && upper.startsWith("TEST_")) {
216+
upper = upper.substring("TEST_".length);
217+
}
212218
if (upper === secret) {
213219
try {
214220
env.validateKey(secret);

src/commands/apphosting-secrets-set.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import * as secrets from "../apphosting/secrets";
1111
import * as dialogs from "../apphosting/secrets/dialogs";
1212
import * as config from "../apphosting/config";
1313
import * as utils from "../utils";
14+
import * as prompt from "../prompt";
1415

1516
export const command = new Command("apphosting:secrets:set <secretName>")
1617
.description("create or update a secret for use in Firebase App Hosting")
@@ -61,6 +62,28 @@ export const command = new Command("apphosting:secrets:set <secretName>")
6162
return;
6263
}
6364

65+
const type = await prompt.promptOnce({
66+
type: "list",
67+
name: "type",
68+
message: "Is this secret for production or only local testing?",
69+
choices: [
70+
{ name: "Production", value: "production" },
71+
{ name: "Local testing only", value: "local" },
72+
],
73+
});
74+
75+
if (type === "local") {
76+
const emailList = await prompt.promptOnce({
77+
type: "input",
78+
name: "emails",
79+
message:
80+
"Please enter a comma separated list of user or groups who should have access to this secret:",
81+
});
82+
await secrets.grantEmailsSecretAccess(projectId, [secretName], emailList.split(","));
83+
await config.maybeAddSecretToYaml(secretName, config.APPHOSTING_EMULATORS_YAML_FILE);
84+
return;
85+
}
86+
6487
const accounts = await dialogs.selectBackendServiceAccounts(projectNumber, projectId, options);
6588

6689
// If we're not granting permissions, there's no point in adding to YAML either.
@@ -74,5 +97,11 @@ export const command = new Command("apphosting:secrets:set <secretName>")
7497
await secrets.grantSecretAccess(projectId, projectNumber, secretName, accounts);
7598
}
7699

77-
await config.maybeAddSecretToYaml(secretName);
100+
await config.maybeAddSecretToYaml(secretName, config.APPHOSTING_BASE_YAML_FILE);
101+
utils.logBullet(
102+
"To grant additional users access to this secret run " +
103+
clc.bold(`firebase apphosting:secrets:grantaccess ${secretName} --email [email list]`) +
104+
".\nTo grant additional backends access to this secret run " +
105+
clc.bold(`firebase apphosting:secrets:grantaccess ${secretName} --backend [backend ID]`),
106+
);
78107
});

0 commit comments

Comments
 (0)