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

Release CF3's support for environment variables and secrets #4149

Merged
merged 14 commits into from
Feb 10, 2022
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
Prev Previous commit
Next Next commit
CF3 Secrets Support (#3959)
Support deploying secret environment variables on a function.

Prior to deploying functions with secret configuration, the CLI will run somewhat comprehensive validation to ensure that secret config will work when deployed, e.g.

1. Secret version exists.
2. Secret version is in ENABLED state.
3. Secret version can be access by the runtime service account.

We do this since the GCF doesn't do the same level of validation and instead repeatedly fail to spin up a new instance with an invalid secret config. This often results on super long deploys (probably until some master timeout is met for function instance deploy).

I took the opportunity to refactor the code a little to group various "ensure" and "validate" used in function deploys in their own files.

Emulator support for secrets will come in a separate PR.
  • Loading branch information
taeold committed Feb 3, 2022
commit 39f2ac738dccd99988f95dd13a2b1dece0ed857c
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
162 changes: 162 additions & 0 deletions src/deploy/functions/ensure.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
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 secret ${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) {
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;
}
}
26 changes: 13 additions & 13 deletions src/deploy/functions/prepare.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,24 @@
import * as clc from "cli-color";

import { Options } from "../../options";
import { ensureCloudBuildEnabled } from "./ensureCloudBuildEnabled";
import { functionMatchesAnyGroup, getFilterGroups } from "./functionsDeployHelper";
import { logBullet } from "../../utils";
import { getFunctionsConfig, prepareFunctionsUpload } from "./prepareFunctionsUpload";
import { promptForFailurePolicies, promptForMinInstances } from "./prompts";
import * as args from "./args";
import * as backend from "./backend";
import * as ensureApiEnabled from "../../ensureApiEnabled";
import * as functionsConfig from "../../functionsConfig";
import * as functionsEnv from "../../functions/env";
import * as runtimes from "./runtimes";
import * as validate from "./validate";
import * as ensure from "./ensure";
import { Options } from "../../options";
import { functionMatchesAnyGroup, getFilterGroups } from "./functionsDeployHelper";
import { logBullet } from "../../utils";
import { getFunctionsConfig, prepareFunctionsUpload } from "./prepareFunctionsUpload";
import { promptForFailurePolicies, promptForMinInstances } from "./prompts";
import { previews } from "../../previews";
import { needProjectId } from "../../projectUtils";
import { track } from "../../track";
import * as runtimes from "./runtimes";
import * as validate from "./validate";
import * as utils from "../../utils";
import { logger } from "../../logger";
import { ensureTriggerRegions } from "./triggerRegionHelper";
import { ensureServiceAgentRoles } from "./checkIam";
import { DelegateContext } from "./runtimes";
import { FirebaseError } from "../../error";

function hasUserConfig(config: Record<string, unknown>): boolean {
Expand Down Expand Up @@ -64,7 +62,7 @@ export async function prepare(
}
const sourceDir = options.config.path(sourceDirName);

const delegateContext: DelegateContext = {
const delegateContext: runtimes.DelegateContext = {
projectId,
sourceDir,
projectDir: options.config.projectDir,
Expand All @@ -85,8 +83,8 @@ export async function prepare(
"runtimeconfig",
/* silent=*/ true
),
ensureCloudBuildEnabled(projectId),
maybeEnableAR(projectId),
ensure.cloudBuildEnabled(projectId),
ensure.maybeEnableAR(projectId),
]);
context.runtimeConfigEnabled = checkAPIsEnabled[1];
context.artifactRegistryEnabled = checkAPIsEnabled[3];
Expand Down Expand Up @@ -186,6 +184,8 @@ export async function prepare(
await promptForFailurePolicies(options, matchingBackend, haveBackend);
await promptForMinInstances(options, matchingBackend, haveBackend);
await backend.checkAvailability(context, wantBackend);
await validate.secretsAreValid(projectId, matchingBackend);
await ensure.secretAccess(projectId, matchingBackend, haveBackend);
}

/**
Expand Down
1 change: 1 addition & 0 deletions src/deploy/functions/runtimes/discovery/v1alpha1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ function parseEndpoints(
labels: "object",
ingressSettings: "string",
environmentVariables: "object",
secretEnvironmentVariables: "array",
httpsTrigger: "object",
eventTrigger: "object",
scheduleTrigger: "object",
Expand Down
14 changes: 14 additions & 0 deletions src/deploy/functions/runtimes/node/parseTriggers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export interface TriggerAnnotation {
maxInstances?: number;
minInstances?: number;
serviceAccountEmail?: string;
secrets?: string[];
httpsTrigger?: {
invoker?: string[];
};
Expand Down Expand Up @@ -219,6 +220,19 @@ export function addResourcesToBackend(
}
endpoint.vpcConnector = maybeId;
}

if (annotation.secrets) {
const secretEnvs: backend.SecretEnvVar[] = [];
for (const secret of annotation.secrets) {
const secretEnv: backend.SecretEnvVar = {
secret,
key: secret,
};
secretEnvs.push(secretEnv);
}
endpoint.secretEnvironmentVariables = secretEnvs;
}

proto.copyIfPresent(
endpoint,
annotation,
Expand Down