Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions src/deploy/functions/build.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -728,8 +728,12 @@ describe("applyPrefix", () => {
expect(Object.keys(testBuild.endpoints).sort()).to.deep.equal(["func1", "func2"]);
});

it("should prefix secret names in secretEnvironmentVariables", () => {
it("should prefix secret names in secretEnvironmentVariables and update param references to that secret", () => {
const testBuild: build.Build = {
params: [
{ type: "secret", name: "API_KEY" },
{ type: "secret", name: "DB_PASSWORD" },
],
endpoints: {
func1: {
region: "us-central1",
Expand All @@ -755,7 +759,6 @@ describe("applyPrefix", () => {
],
},
},
params: [],
requiredAPIs: [],
};

Expand All @@ -772,6 +775,10 @@ describe("applyPrefix", () => {
expect(testBuild.endpoints["staging-func2"].secretEnvironmentVariables).to.deep.equal([
{ key: "SERVICE_TOKEN", secret: "staging-service-secret", projectId: "test-project" },
]);
expect(testBuild.params).to.deep.equal([
{ type: "secret", name: "API_KEY", resourceId: "STAGING_API_KEY" },
{ type: "secret", name: "DB_PASSWORD", resourceId: "STAGING_DB_PASSWORD" },
]);
Comment thread
Berlioz marked this conversation as resolved.
});

it("throws if combined function id exceeds 63 characters", () => {
Expand Down
38 changes: 27 additions & 11 deletions src/deploy/functions/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import { Runtime } from "./runtimes/supported";
import { ExprParseError } from "./cel";
import { defineSecret } from "firebase-functions/params";
import { toUpperSnakeCase } from "../../functions/secrets";
Comment thread
Berlioz marked this conversation as resolved.

export const REGION_TBD = "REGION_TBD";
export const SECRET_REF_PREFIX = "FIREBASE_SECRET_REF_";
Expand Down Expand Up @@ -354,15 +355,15 @@
envs: Record<string, params.ParamValue>;
secretRefs: Record<string, string>;
}> {
const { paramValues: paramValues, secretRefs: secretRefs } = await params.resolveParams(
opts.build.params,
opts.firebaseConfig,
envWithTypes(opts.build.params, opts.userEnvs),
opts.codebase,
opts.nonInteractive,
opts.force,
opts.isEmulator,
);
const { paramValues: paramValues, secretRefs: secretRefs } = await params.resolveParams({
params: opts.build.params,
firebaseConfig: opts.firebaseConfig,
userEnvs: envWithTypes(opts.build.params, opts.userEnvs),
codebase: opts.codebase,
nonInteractive: opts.nonInteractive,
force: opts.force,
isEmulator: opts.isEmulator,
});

return { backend: toBackend(opts.build, paramValues), envs: paramValues, secretRefs: secretRefs };
}
Expand Down Expand Up @@ -516,7 +517,7 @@
// List param, we try resolving a String param instead.
try {
regions = params.resolveList(bdEndpoint.region, paramValues);
} catch (err: any) {

Check warning on line 520 in src/deploy/functions/build.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unexpected any. Specify a different type
if (err instanceof ExprParseError) {
regions = [params.resolveString(bdEndpoint.region, paramValues)];
} else {
Expand Down Expand Up @@ -740,6 +741,14 @@
* Prefixes all endpoint IDs and secret names in a build with a given prefix.
* This ensures that functions and their associated secrets from different codebases
* remain isolated and don't conflict when deployed to the same project.
*
* Secret params in a build are rewritten to point to resource names respecting
* the same prefixing, so that the interactive secret creation flow still works
* and the non-interactive message prints the correct secret to create.
*
* When deploying a function which already has secret bindings in its .env files,
* applyEnvSecretBindings will run after this and overwrite both the updated
* secret params and SecretEnvVars to reflect deployed reality.
*/
export function applyPrefix(build: Build, prefix: string): void {
if (!prefix) {
Expand Down Expand Up @@ -767,12 +776,19 @@
if (endpoint.secretEnvironmentVariables) {
endpoint.secretEnvironmentVariables = endpoint.secretEnvironmentVariables.map((secret) => ({
...secret,
secret: `${prefix}-${secret.secret}`,
secret: toUpperSnakeCase(`${prefix}-${secret.secret}`),
}));
}
}
build.endpoints = newEndpoints;

for (const param of build.params) {
if (param.type !== "secret") {
continue;
}
param.resourceId = toUpperSnakeCase(`${prefix}-${param.resourceId || param.name}`);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

Using toUpperSnakeCase here causes a discrepancy between the parameter's resourceId (which becomes uppercase with underscores, e.g., STAGING_API_KEY) and the endpoint's secretEnvironmentVariables (which are prefixed directly without case normalization, e.g., staging-API_KEY or staging-api-secret). This mismatch will cause the deployed Cloud Function to look for a different secret name at runtime than the one prompted/created by the CLI, leading to immediate runtime failures on greenfield deploys. We should use the same prefixing logic without toUpperSnakeCase to keep them consistent.

Suggested change
param.resourceId = toUpperSnakeCase(`${prefix}-${param.resourceId || param.name}`);
param.resourceId = `${prefix}-${param.resourceId || param.name}`;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should actually do it in both places, because functions:secrets:set rejects any secret names that aren't in upper snake case

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fwiw lowercase and hyphens are allowed in gcp secret names so we could also just change functions:secrets:set if people think that would be neater

}

if (build.lifecycleHooks) {
for (const hook of Object.values(build.lifecycleHooks)) {
if ("task" in hook) {
Expand Down Expand Up @@ -806,7 +822,7 @@
* /version can be omitted and will cause the secret to resolve to whatever the latest version was at time of deploy.
*
* For each binding imported from the .env file,
* 1) TODO: Check if a conflicting SecretParam with the same name exists. If so, override the param so that the prompting flow will look in the right place when deciding whether or not to create a new Secret.
* 1) Check if a conflicting SecretParam with the same name exists. If so, override the param so that the prompting flow will look in the right place when deciding whether or not to create a new Secret.
* 2) Upsert the binding directly into the Build's SecretEnvVars, which will cause it to be actually available in process.ENV
*/
export function applyEnvSecretBindings(
Expand Down
123 changes: 101 additions & 22 deletions src/deploy/functions/params.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,14 @@
const paramsToResolve: params.Param[] = [];
const userEnv: Record<string, params.ParamValue> = {};
expect(
(await params.resolveParams(paramsToResolve, fakeConfig, userEnv, "default")).paramValues,
(
await params.resolveParams({
params: paramsToResolve,
firebaseConfig: fakeConfig,
userEnvs: userEnv,
codebase: "default",
})
).paramValues,
).to.deep.equal(expectedInternalParams);
});

Expand All @@ -121,7 +128,14 @@
baz: new params.ParamValue("true", false, { string: false, number: false, boolean: true }),
};
expect(
(await params.resolveParams(paramsToResolve, fakeConfig, userEnv, "default")).paramValues,
(
await params.resolveParams({
params: paramsToResolve,
firebaseConfig: fakeConfig,
userEnvs: userEnv,
codebase: "default",
})
).paramValues,
).to.deep.equal(
Object.assign(
{
Expand All @@ -148,7 +162,14 @@
}),
};
expect(
(await params.resolveParams(paramsToResolve, fakeConfig, userEnv, "default")).paramValues,
(
await params.resolveParams({
params: paramsToResolve,
firebaseConfig: fakeConfig,
userEnvs: userEnv,
codebase: "default",
})
).paramValues,
).to.deep.equal({
DATABASE_URL: new params.ParamValue(fakeConfig.databaseURL, true, {
string: true,
Expand Down Expand Up @@ -178,12 +199,12 @@
const userEnv: Record<string, params.ParamValue> = {};
expect(
(
await params.resolveParams(
paramsToResolve,
{ locationId: "", projectId: "foo", storageBucket: "", databaseURL: "" },
userEnv,
"default",
)
await params.resolveParams({
params: paramsToResolve,
firebaseConfig: { locationId: "", projectId: "foo", storageBucket: "", databaseURL: "" },
userEnvs: userEnv,
codebase: "default",
})
).paramValues,
).to.deep.equal({
GCLOUD_PROJECT: expectedInternalParams.GCLOUD_PROJECT,
Expand All @@ -202,7 +223,14 @@
];
input.resolves("bar");
expect(
(await params.resolveParams(paramsToResolve, fakeConfig, {}, "default")).paramValues,
(
await params.resolveParams({
params: paramsToResolve,
firebaseConfig: fakeConfig,
userEnvs: {},
codebase: "default",
})
).paramValues,
).to.deep.equal(
Object.assign(
{
Expand All @@ -229,8 +257,13 @@
},
];
input.resolves("baz");
await params.resolveParams(paramsToResolve, fakeConfig, {}, "default");
await params.resolveParams({
params: paramsToResolve,
firebaseConfig: fakeConfig,
userEnvs: {},
codebase: "default",
});
expect(input.getCall(1).args[0].default).to.eq("baz");

Check warning on line 266 in src/deploy/functions/params.spec.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe member access .default on an `any` value
});

it("can resolve a CEL expression containing only identities", async () => {
Expand All @@ -249,8 +282,13 @@
},
];
input.resolves("baz");
await params.resolveParams(paramsToResolve, fakeConfig, {}, "default");
await params.resolveParams({
params: paramsToResolve,
firebaseConfig: fakeConfig,
userEnvs: {},
codebase: "default",
});
expect(input.getCall(1).args[0].default).to.eq("baz/quox");

Check warning on line 291 in src/deploy/functions/params.spec.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe member access .default on an `any` value
});

it("can resolve a CEL expression depending on the internal params", async () => {
Expand All @@ -275,10 +313,15 @@
},
];
input.resolves("baz");
await params.resolveParams(paramsToResolve, fakeConfig, {}, "default");
await params.resolveParams({
params: paramsToResolve,
firebaseConfig: fakeConfig,
userEnvs: {},
codebase: "default",
});
expect(input.getCall(0).args[0].default).to.eq("https://foo.firebaseio.com/quox");

Check warning on line 322 in src/deploy/functions/params.spec.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe member access .default on an `any` value
expect(input.getCall(1).args[0].default).to.eq("projectID: foo");

Check warning on line 323 in src/deploy/functions/params.spec.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe member access .default on an `any` value
expect(input.getCall(2).args[0].default).to.eq(

Check warning on line 324 in src/deploy/functions/params.spec.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe member access .default on an `any` value
"http://foo.appspot.com.storage.googleapis.com/",
);
});
Expand All @@ -293,8 +336,14 @@
},
];
input.resolves("");
await expect(params.resolveParams(paramsToResolve, fakeConfig, {}, "default")).to.eventually.be
.rejected;
await expect(
params.resolveParams({
params: paramsToResolve,
firebaseConfig: fakeConfig,
userEnvs: {},
codebase: "default",
}),
).to.eventually.be.rejected;
});

it("errors when the default is a CEL expression that resolves to the wrong type", async () => {
Expand All @@ -313,19 +362,32 @@
},
];
input.resolves("22");
await expect(params.resolveParams(paramsToResolve, fakeConfig, {}, "default")).to.eventually.be
.rejected;
await expect(
params.resolveParams({
params: paramsToResolve,
firebaseConfig: fakeConfig,
userEnvs: {},
codebase: "default",
}),
).to.eventually.be.rejected;
});

it("does not throw in non-interactive mode if secret exists in cloud", async () => {
const paramsToResolve: params.Param[] = [{ name: "MY_SECRET", type: "secret" }];
const getSecretMetadataStub = sinon.stub(secretManager, "getSecretMetadata").resolves({
secret: { name: "MY_SECRET", projectId: "foo", labels: {}, replication: {} },
secretVersion: { versionId: "1", state: "ENABLED", secret: {} as any },

Check warning on line 379 in src/deploy/functions/params.spec.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unexpected any. Specify a different type

Check warning on line 379 in src/deploy/functions/params.spec.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe assignment of an `any` value
});

await expect(params.resolveParams(paramsToResolve, fakeConfig, {}, "default", true)).to.be
.fulfilled;
await expect(
params.resolveParams({
params: paramsToResolve,
firebaseConfig: fakeConfig,
userEnvs: {},
codebase: "default",
nonInteractive: true,
}),
).to.be.fulfilled;

getSecretMetadataStub.restore();
});
Expand All @@ -337,7 +399,13 @@
});

await expect(
params.resolveParams(paramsToResolve, fakeConfig, {}, "default", true),
params.resolveParams({
params: paramsToResolve,
firebaseConfig: fakeConfig,
userEnvs: {},
codebase: "default",
nonInteractive: true,
}),
).to.be.rejectedWith(FirebaseError, /In non-interactive mode but have no value for the secret/);

getSecretMetadataStub.restore();
Expand All @@ -347,7 +415,13 @@
const paramsToResolve: params.Param[] = [{ name: "MY_SECRET", type: "secret" }];
const getSecretMetadataSpy = sinon.spy(secretManager, "getSecretMetadata");

await params.resolveParams(paramsToResolve, fakeConfig, {}, "default", false, false, true);
await params.resolveParams({
params: paramsToResolve,
firebaseConfig: fakeConfig,
userEnvs: {},
codebase: "default",
isEmulator: true,
});
expect(getSecretMetadataSpy.called).to.be.false;

getSecretMetadataSpy.restore();
Expand All @@ -362,7 +436,12 @@
},
];
input.resolves("bar");
await params.resolveParams(paramsToResolve, fakeConfig, {}, "my-codebase");
await params.resolveParams({
params: paramsToResolve,
firebaseConfig: fakeConfig,
userEnvs: {},
codebase: "my-codebase",
});
expect(
loggerInfoStub.calledWith(sinon.match(/Prompting for parameters for codebase.*my-codebase/)),
).to.be.true;
Expand Down
27 changes: 20 additions & 7 deletions src/deploy/functions/params.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,7 +290,7 @@
return pv;
}

setDelimiter(delimiter: string) {

Check warning on line 293 in src/deploy/functions/params.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Missing return type on function
this.delimiter = delimiter;
}

Expand All @@ -317,7 +317,7 @@
if (this.rawValue.includes("[")) {
// Convert quotes to apostrophes
const unquoted = this.rawValue.replace(/'/g, '"');
return JSON.parse(unquoted);

Check warning on line 320 in src/deploy/functions/params.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe return of an `any` typed value
}

// Continue to handle something like "a,b,c"
Expand Down Expand Up @@ -383,6 +383,16 @@
assertExhaustive(param);
}

interface ResolveParamOpts {
params: Param[];
firebaseConfig: FirebaseConfig;
userEnvs: Record<string, ParamValue>;
codebase: string;
nonInteractive?: boolean;
force?: boolean;
isEmulator?: boolean;
}

/**
* A param defined by the SDK may resolve to:
* - a reference to a secret in Cloud Secret Manager, which we validate the existence of and prompt for if missing
Expand All @@ -395,14 +405,17 @@
* - after prompting, the resolved value of the param is written to the most specific .env file available
*/
export async function resolveParams(
params: Param[],
firebaseConfig: FirebaseConfig,
userEnvs: Record<string, ParamValue>,
codebase: string,
nonInteractive?: boolean,
force?: boolean,
isEmulator = false,
opts: ResolveParamOpts,
): Promise<{ paramValues: Record<string, ParamValue>; secretRefs: Record<string, string> }> {
const {
params,
firebaseConfig,
userEnvs,
codebase,
nonInteractive = false,
force = false,
isEmulator = false,
} = opts;
const paramValues: Record<string, ParamValue> = populateDefaultParams(firebaseConfig);
const secretRefs: Record<string, string> = {};

Expand Down
Loading
Loading