Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add new command (functions:secrets:set) for creating secrets to be used for CF3. #4021

Merged
merged 94 commits into from
Feb 3, 2022
Merged
Show file tree
Hide file tree
Changes from 85 commits
Commits
Show all changes
94 commits
Select commit Hold shift + click to select a range
1f19857
WIP - broken
taeold Dec 15, 2021
efe6d57
WIP 2.
taeold Dec 16, 2021
95bfc0e
Deploy secrets on function.
taeold Dec 16, 2021
c5b75ed
Resolve secret version at deploy time.
taeold Dec 16, 2021
3a2879d
Try to grant runtime SA with accessor role.
taeold Dec 16, 2021
e2f4c4a
Fix parallel setIamPolicy issue.
taeold Dec 17, 2021
05e9c70
Nits.
taeold Dec 17, 2021
47437ff
Use regex to test for secret resource names.
taeold Dec 21, 2021
1bbe17a
Add more comments.
taeold Dec 21, 2021
28a9df0
Add basic unit tests.
taeold Dec 21, 2021
2d18072
Refactor secret manager to use apiv2.
taeold Dec 21, 2021
2d2d653
Small fixups.
taeold Dec 21, 2021
0618ada
Nit.
taeold Dec 21, 2021
fe09f62
Add unit test for prepare functions.
taeold Dec 21, 2021
052fded
Prettier.
taeold Dec 21, 2021
2cbb0ab
Correct API_VERSION for secret manager api.
taeold Dec 21, 2021
fadef0c
Add more tests.
taeold Dec 23, 2021
5c5a89a
Support version tag, add tests, nits.
taeold Dec 23, 2021
b3a924f
Various nits.
taeold Dec 28, 2021
dcbe260
Prettier
taeold Dec 28, 2021
166fb3d
Refactor logic for generating default service account for GCF.
taeold Dec 28, 2021
300152e
Rename versionId to version to unify type.
taeold Dec 30, 2021
9298f78
Fix implementation of addSecretVersion.
taeold Dec 30, 2021
cd7ae85
Fix tests.
taeold Dec 30, 2021
b064d22
Add comments, some nits.
taeold Dec 30, 2021
58ae481
Refactor
taeold Dec 31, 2021
35f4749
Merge branch 'master' of https://github.com/firebase/firebase-tools i…
taeold Jan 1, 2022
2c44547
Add more comments.
taeold Jan 1, 2022
4a2aee4
Merge branch 'master' into dl-cf3-secrets
taeold Jan 4, 2022
55cbf77
Merge branch 'master' into dl-cf3-secrets
taeold Jan 4, 2022
3c766d6
Support parsing secret resource name.
taeold Jan 4, 2022
c602b12
Merge branch 'dl-cf3-secrets' of https://github.com/firebase/firebase…
taeold Jan 5, 2022
cdc4bb1
Merge branch 'master' into dl-cf3-secrets
taeold Jan 5, 2022
92efaee
Move functions around for better organizations.
taeold Jan 5, 2022
63cf42e
Eslint.
taeold Jan 5, 2022
468ffc6
Merge branch 'dl-cf3-secrets' of https://github.com/firebase/firebase…
taeold Jan 5, 2022
d4157e6
Merge branch 'master' into dl-cf3-secrets
taeold Jan 5, 2022
d9f0959
Merge branch 'master' into dl-cf3-secrets
taeold Jan 13, 2022
71c25ad
Merge branch 'master' into dl-cf3-secrets
taeold Jan 13, 2022
04b630f
Merge branch 'dl-cf3-secrets' of https://github.com/firebase/firebase…
taeold Jan 13, 2022
1c9d5af
Cleanup imports.
taeold Jan 13, 2022
96de58b
Cleanup typing a bit more.
taeold Jan 13, 2022
c28a7f1
WIP.
taeold Jan 13, 2022
e571956
Cleanup typing a bit more.
taeold Jan 13, 2022
4469005
Implementation done, pending refactoring.
taeold Jan 13, 2022
4ea6de3
Add missing trailing comma.
taeold Jan 14, 2022
eeb153d
Assume secrets are configured w/o version or w/ full resource name.
taeold Jan 14, 2022
3701719
Strongly assume that version info will not be filled in by the user.
taeold Jan 14, 2022
7f16c96
Remove unnecessary tests.
taeold Jan 14, 2022
d71cb26
Merge branch 'master' into dl-cf3-secrets
taeold Jan 14, 2022
127cb8d
Refactor to have helper functions live in its own file.
taeold Jan 14, 2022
4e025d5
Add test and fix implementation issues while running tests.
taeold Jan 15, 2022
fbc7d44
Fix prettier.
taeold Jan 18, 2022
1953194
Rename command.
taeold Jan 18, 2022
fe0de86
Wording.
taeold Jan 18, 2022
66c29ef
Merge branch 'dl-cf3-secrets' into dl-cf3-secrets-cmds
taeold Jan 19, 2022
d91bd3e
Better throw on invalid secret keys.
taeold Jan 21, 2022
217d0cd
Merge remote-tracking branch 'origin/master' into dl-cf3-secrets-cmds
taeold Jan 21, 2022
f799246
Merge branch 'dl-cf3-secrets-cmds' of https://github.com/firebase/fir…
taeold Jan 21, 2022
58e3cb6
Cut support for cross-project secrets.
taeold Jan 21, 2022
b7ba847
Skip calling IAM if SA is already bound to a secret.
taeold Jan 22, 2022
de93fda
Prefer module.function over named imports.
taeold Jan 22, 2022
6e650d6
Cleanup regex.
taeold Jan 22, 2022
54c10ea
Rename setIamPolicyBinding to just setIamPolicy. It's cleaner.
taeold Jan 24, 2022
c3df734
Fix test.
taeold Jan 25, 2022
b24bc3d
Reduce number of alls to Secret Manager to resolve versions.
taeold Jan 25, 2022
3087236
Prettier.
taeold Jan 25, 2022
937a0df
Complete renaming.
taeold Jan 25, 2022
b44f195
Merge branch 'master' into dl-cf3-secrets
taeold Jan 26, 2022
1305b78
Use isatty to determine interactice sessions.
taeold Jan 31, 2022
08c928c
Merge branch 'cf3-secrets' of https://github.com/firebase/firebase-to…
taeold Jan 31, 2022
596a5e3
Correctly generate default service account for all supported platforms.
taeold Feb 1, 2022
94e4f13
Merge branch 'dl-cf3-secrets' into dl-cf3-secrets-cmds
taeold Feb 1, 2022
fcbd3bc
Rename fn names for clarity.
taeold Feb 1, 2022
a391ac4
Fix merge gone wrong.
taeold Feb 1, 2022
01b7867
Collect feedbacks from another PR.
taeold Feb 1, 2022
3403bb9
Find better home for defaultServiceAccount fn.
taeold Feb 1, 2022
ef000ac
Fix refactor gone wrong.
taeold Feb 1, 2022
c85794b
Add comment to clarify that version is used internally.
taeold Feb 1, 2022
1a5fe0e
Dont regress on a fixed bug.
taeold Feb 1, 2022
3ad2cd2
Whoops this shouldn't be renamed.
taeold Feb 1, 2022
ea86738
Prettier.
taeold Feb 1, 2022
4266e33
Merge branch 'dl-cf3-secrets' into dl-cf3-secrets-cmds
taeold Feb 1, 2022
922ebf6
Fix broken tests.
taeold Feb 1, 2022
dd45182
Merge branch 'dl-cf3-secrets' into dl-cf3-secrets-cmds
taeold Feb 1, 2022
19f8516
Merge branch 'dl-cf3-secrets' into dl-cf3-secrets-cmds
taeold Feb 2, 2022
aaa2d6b
Merge branch 'dl-cf3-secrets-cmds' of https://github.com/firebase/fir…
taeold Feb 2, 2022
185b890
Add missing docstring + fix API deviation.
taeold Feb 2, 2022
3588ca9
Merge branch 'dl-cf3-secrets' into dl-cf3-secrets-cmds
taeold Feb 2, 2022
e2ba5c2
Add missing docstring
taeold Feb 2, 2022
d4b8004
Fix refactor gone wrong.
taeold Feb 2, 2022
6d1881a
Merge branch 'dl-cf3-secrets' into dl-cf3-secrets-cmds
taeold Feb 2, 2022
2d330a5
Fix refactor gone wrong.
taeold Feb 2, 2022
4f02489
Merge branch 'cf3-secrets' of https://github.com/firebase/firebase-to…
taeold Feb 3, 2022
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
56 changes: 56 additions & 0 deletions src/commands/functions-secrets-set.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import * as tty from "tty";
import * as fs from "fs";

import * as clc from "cli-color";

import { ensureValidKey, ensureSecret } 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 { addVersion, toSecretVersionResourceName } from "../gcp/secretManager";

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."
)
.before(requirePermissions, [
"secretmanager.secrets.create",
"secretmanager.secrets.get",
"secretmanager.secrets.update",
"secretmanager.versions.add",
])
.option(
"--data-file <dataFile>",
'File path from which to read secret data. Set to "-" to read the secret data from stdin.'
taeold marked this conversation as resolved.
Show resolved Hide resolved
)
.action(async (unvalidatedKey: string, options: Options) => {
const projectId = needProjectId(options);
const key = await ensureValidKey(unvalidatedKey, options);
const secret = await ensureSecret(projectId, key, options);
let secretValue;

if ((!options.dataFile || options.dataFile === "-") && tty.isatty(0)) {
secretValue = await promptOnce({
name: key,
type: "password",
message: `Enter a value for ${key}`,
});
} else {
let dataFile: string | number = 0;
if (options.dataFile && options.dataFile !== "-") {
dataFile = options.dataFile as string;
}
secretValue = fs.readFileSync(dataFile, "utf-8");
}

const secretVersion = await addVersion(secret, secretValue);
logSuccess(`Created a new secret version ${toSecretVersionResourceName(secretVersion)}`);
logBullet(
"Please deploy your functions for the change to take effect by running:\n\t" +
clc.bold("firebase deploy --only functions")
);
});
2 changes: 2 additions & 0 deletions src/commands/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,8 @@ module.exports = function (client) {
if (previews.deletegcfartifacts) {
client.functions.deletegcfartifacts = loadCommand("functions-deletegcfartifacts");
}
client.functions.secrets = {};
client.functions.secrets.set = loadCommand("functions-secrets-set");
client.help = loadCommand("help");
client.hosting = {};
client.hosting.channel = {};
Expand Down
20 changes: 20 additions & 0 deletions src/deploy/functions/backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,10 +166,19 @@ export interface TargetIds {
project: string;
}

export interface SecretEnvVar {
key: string;
secret: string;

// Internal use only. Users cannot pin secret to a specific version.
version?: string;
}

export interface ServiceConfiguration {
concurrency?: number;
labels?: Record<string, string>;
environmentVariables?: Record<string, string>;
secretEnvironmentVariables?: SecretEnvVar[];
availableMemoryMb?: MemoryOptions;
timeout?: proto.Duration;
maxInstances?: number;
Expand Down Expand Up @@ -470,6 +479,17 @@ export function someEndpoint(
return false;
}

/** A helper utility for finding an endpoint that matches the predicate. */
export function findEndpoint(
backend: Backend,
predicate: (endpoint: Endpoint) => boolean
): Endpoint | undefined {
for (const endpoints of Object.values(backend.endpoints)) {
const endpoint = Object.values<Endpoint>(endpoints).find(predicate);
if (endpoint) return endpoint;
}
}

/** A helper utility function that returns a subset of the backend that includes only matching endpoints */
export function matchingBackend(
backend: Backend,
Expand Down
164 changes: 164 additions & 0 deletions src/deploy/functions/ensure.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import * as clc from "cli-color";

import { ensure } from "../../ensureApiEnabled";
import { FirebaseError, isBillingError } from "../../error";
import { logLabeledBullet, logLabeledSuccess } from "../../utils";
import { ensureServiceAgentRole } from "../../gcp/secretManager";
import { previews } from "../../previews";
import { getFirebaseProject } from "../../management/projects";
import { assertExhaustive } from "../../functional";
import * as track from "../../track";
import * as backend from "./backend";
import * as ensureApiEnabled from "../../ensureApiEnabled";

const FAQ_URL = "https://firebase.google.com/support/faq#functions-runtime";
const CLOUD_BUILD_API = "cloudbuild.googleapis.com";

/**
* By default:
* 1. GCFv1 uses App Engine default service account.
* 2. GCFv2 (Cloud Run) uses Compute Engine default service account.
*/
export async function defaultServiceAccount(e: backend.Endpoint): Promise<string> {
const metadata = await getFirebaseProject(e.project);
if (e.platform === "gcfv1") {
return `${metadata.projectId}@appspot.gserviceaccount.com`;
} else if (e.platform === "gcfv2") {
return `${metadata.projectNumber}[email protected]`;
}
assertExhaustive(e.platform);
}

function nodeBillingError(projectId: string): FirebaseError {
track("functions_runtime_notices", "nodejs10_billing_error");
return new FirebaseError(
`Cloud Functions deployment requires the pay-as-you-go (Blaze) billing plan. To upgrade your project, visit the following URL:

https://console.firebase.google.com/project/${projectId}/usage/details

For additional information about this requirement, see Firebase FAQs:

${FAQ_URL}`,
{ exit: 1 }
);
}

function nodePermissionError(projectId: string): FirebaseError {
track("functions_runtime_notices", "nodejs10_permission_error");
return new FirebaseError(`Cloud Functions deployment requires the Cloud Build API to be enabled. The current credentials do not have permission to enable APIs for project ${clc.bold(
projectId
)}.

Please ask a project owner to visit the following URL to enable Cloud Build:

https://console.cloud.google.com/apis/library/cloudbuild.googleapis.com?project=${projectId}

For additional information about this requirement, see Firebase FAQs:
${FAQ_URL}
`);
}

function isPermissionError(e: { context?: { body?: { error?: { status?: string } } } }): boolean {
return e.context?.body?.error?.status === "PERMISSION_DENIED";
}

/**
* Checks for various warnings and API enablements needed based on the runtime
* of the deployed functions.
*
* @param projectId Project ID upon which to check enablement.
*/
export async function cloudBuildEnabled(projectId: string): Promise<void> {
try {
await ensure(projectId, CLOUD_BUILD_API, "functions");
} catch (e: any) {
if (isBillingError(e)) {
throw nodeBillingError(projectId);
} else if (isPermissionError(e)) {
throw nodePermissionError(projectId);
}

throw e;
}
}

// We previously force-enabled AR. We want to wait on this to see if we can give
// an upgrade warning in the future. If it already is enabled though we want to
// remember this and still use the cleaner if necessary.
export async function maybeEnableAR(projectId: string): Promise<boolean> {
if (!previews.artifactregistry) {
return ensureApiEnabled.check(
projectId,
"artifactregistry.googleapis.com",
"functions",
/* silent= */ true
);
}
await ensureApiEnabled.ensure(projectId, "artifactregistry.googleapis.com", "functions");
return true;
}

/**
* Returns a mapping of all secrets declared in a stack to the bound service accounts.
*/
async function secretsToServiceAccounts(b: backend.Backend): Promise<Record<string, Set<string>>> {
const secretsToSa: Record<string, Set<string>> = {};
for (const e of backend.allEndpoints(b)) {
const sa = e.serviceAccountEmail || (await module.exports.defaultServiceAccount(e));
for (const s of e.secretEnvironmentVariables! || []) {
const serviceAccounts = secretsToSa[s.secret] || new Set();
serviceAccounts.add(sa);
secretsToSa[s.secret] = serviceAccounts;
}
}
return secretsToSa;
}

/**
* Ensures that runtime service account has access to the secrets.
*
* To avoid making more than one simultaneous call to setIamPolicy calls per secret, the function batches all
* service account that requires access to it.
*/
export async function secretAccess(
projectId: string,
wantBackend: backend.Backend,
haveBackend: backend.Backend
) {
const ensureAccess = async (secret: string, serviceAccounts: string[]) => {
logLabeledBullet(
"functions",
`ensuring ${clc.bold(serviceAccounts.join(", "))} access to ${clc.bold(secret)}.`
);
await ensureServiceAgentRole(
{ name: secret, projectId },
serviceAccounts,
"roles/secretmanager.secretAccessor"
);
logLabeledSuccess(
"functions",
`ensured ${clc.bold(serviceAccounts.join(", "))} access to ${clc.bold(secret)}.`
);
};

const wantSecrets = await secretsToServiceAccounts(wantBackend);
const haveSecrets = await secretsToServiceAccounts(haveBackend);

// Remove secret/service account pairs that already exists to avoid unnecessary IAM calls.
for (const [secret, serviceAccounts] of Object.entries(haveSecrets)) {
for (const serviceAccount of serviceAccounts) {
if (wantSecrets?.[secret].has(serviceAccount)) {
wantSecrets[secret].delete(serviceAccount);
if (wantSecrets[secret].size === 0) {
delete wantSecrets[secret];
}
}
}
}

const ensure = [];
for (const [secret, serviceAccounts] of Object.entries(wantSecrets)) {
ensure.push(ensureAccess(secret, Array.from(serviceAccounts)));
}
await Promise.all(ensure);
}
61 changes: 0 additions & 61 deletions src/deploy/functions/ensureCloudBuildEnabled.ts
Original file line number Diff line number Diff line change
@@ -1,61 +0,0 @@
import { bold } from "cli-color";

import * as track from "../../track";
import { ensure } from "../../ensureApiEnabled";
import { FirebaseError, isBillingError } from "../../error";

const FAQ_URL = "https://firebase.google.com/support/faq#functions-runtime";
const CLOUD_BUILD_API = "cloudbuild.googleapis.com";

function nodeBillingError(projectId: string): FirebaseError {
track("functions_runtime_notices", "nodejs10_billing_error");
return new FirebaseError(
`Cloud Functions deployment requires the pay-as-you-go (Blaze) billing plan. To upgrade your project, visit the following URL:

https://console.firebase.google.com/project/${projectId}/usage/details

For additional information about this requirement, see Firebase FAQs:

${FAQ_URL}`,
{ exit: 1 }
);
}

function nodePermissionError(projectId: string): FirebaseError {
track("functions_runtime_notices", "nodejs10_permission_error");
return new FirebaseError(`Cloud Functions deployment requires the Cloud Build API to be enabled. The current credentials do not have permission to enable APIs for project ${bold(
projectId
)}.

Please ask a project owner to visit the following URL to enable Cloud Build:

https://console.cloud.google.com/apis/library/cloudbuild.googleapis.com?project=${projectId}

For additional information about this requirement, see Firebase FAQs:
${FAQ_URL}
`);
}

function isPermissionError(e: { context?: { body?: { error?: { status?: string } } } }): boolean {
return e.context?.body?.error?.status === "PERMISSION_DENIED";
}

/**
* Checks for various warnings and API enablements needed based on the runtime
* of the deployed functions.
*
* @param projectId Project ID upon which to check enablement.
*/
export async function ensureCloudBuildEnabled(projectId: string): Promise<void> {
try {
await ensure(projectId, CLOUD_BUILD_API, "functions");
} catch (e: any) {
if (isBillingError(e)) {
throw nodeBillingError(projectId);
} else if (isPermissionError(e)) {
throw nodePermissionError(projectId);
}

throw e;
}
}
Loading