Skip to content

Commit

Permalink
Add strong typing to context, options, and paylaod in Functions codeb…
Browse files Browse the repository at this point in the history
…ase (#3271)

What it says in the box. The nebulous context: any, options: any, payload: any are now strongly typed.

To get part of this working I had to convert strings to Runtimes. I also removed a few lodash calls that I saw.
  • Loading branch information
inlined authored Apr 13, 2021
1 parent 388208d commit e8c817c
Show file tree
Hide file tree
Showing 14 changed files with 219 additions and 77 deletions.
79 changes: 79 additions & 0 deletions src/deploy/functions/args.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
// These types should proably be in a root deploy.ts, but we can only boil the ocean one bit at a time.

import { ReadStream } from "fs";
import * as gcf from "../../gcp/cloudfunctions";
import * as deploymentPlanner from "./deploymentPlanner";

// Payload holds the output types of what we're building.
export interface Payload {
functions?: {
byRegion: deploymentPlanner.RegionMap;
triggers: deploymentPlanner.CloudFunctionTrigger[];
};
}

// Options come from command-line options and stored config values
// TODO: actually define all of this stuff in command.ts and import it from there.
export interface Options {
cwd: string;
configPath: string;

// OMITTED: project. Use context.projectId instead

only: string;

// defined in /config.js
config: {
// Note: it might be worth defining overloads for config values we use in
// deploy/functions.
get(key: string, defaultValue?: any): any;
set(key: string, value: any): void;
has(key: string): boolean;
path(pathName: string): string;

// I/O methods: these methods work with JSON objects.
// WARNING: they all use synchronous I/O
readProjectFile(file: string): unknown;
writeProjectFile(path: string, content: unknown): void;
askWriteProjectFile(path: string, content: unknown): void;

projectDir: string;
};
filteredTargets: string[];
nonInteractive: boolean;
force: boolean;
}

export interface FunctionsSource {
file: string;
stream: ReadStream;
size: number;
}

// Context holds cached values of what we've looked up in handling this request.
// For non-trivial values, use helper functions that cache automatically and/or hide implementation
// details.
export interface Context {
projectId: string;
filters: string[][];

// Filled in the "prepare" phase.
functionsSource?: FunctionsSource;
// TODO: replace with backend.Runtime once it is committed.
runtimeChoice?: gcf.Runtime;
runtimeConfigEnabled?: boolean;
firebaseConfig?: FirebaseConfig;

// Filled in the "deploy" phase.
uploadUrl?: string;

// TOOD: move to caching function w/ helper
existingFunctions?: gcf.CloudFunction[];
}

export interface FirebaseConfig {
locationId: string;
projectId: string;
storageBucket: string;
databaseURL: string;
}
11 changes: 8 additions & 3 deletions src/deploy/functions/checkIam.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { getReleaseNames, getFilterGroups } from "../../functionsDeployHelper";
import { CloudFunctionTrigger } from "./deploymentPlanner";
import { FirebaseError } from "../../error";
import { testIamPermissions, testResourceIamPermissions } from "../../gcp/iam";
import * as args from "./args";

const PERMISSION = "cloudfunctions.functions.setIamPolicy";

Expand Down Expand Up @@ -51,15 +52,19 @@ export async function checkServiceAccountIam(projectId: string): Promise<void> {
* @param options The command-wide options object.
* @param payload The deploy payload.
*/
export async function checkHttpIam(context: any, options: any, payload: any): Promise<void> {
const functionsInfo = payload.functions.triggers;
export async function checkHttpIam(
context: args.Context,
options: args.Options,
payload: args.Payload
): Promise<void> {
const functionsInfo = payload.functions!.triggers;
const filterGroups = context.filters || getFilterGroups(options);

const httpFunctionNames: string[] = functionsInfo
.filter((f: CloudFunctionTrigger) => has(f, "httpsTrigger"))
.map((f: CloudFunctionTrigger) => f.name);
const httpFunctionFullNames: string[] = getReleaseNames(httpFunctionNames, [], filterGroups);
const existingFunctionFullNames: string[] = context.existingFunctions.map(
const existingFunctionFullNames: string[] = context.existingFunctions!.map(
(f: { name: string }) => f.name
);

Expand Down
45 changes: 26 additions & 19 deletions src/deploy/functions/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,13 @@ import { functionsUploadRegion } from "../../api";
import * as gcp from "../../gcp";
import { logSuccess, logWarning } from "../../utils";
import { checkHttpIam } from "./checkIam";
import * as args from "./args";

const GCP_REGION = functionsUploadRegion;

setGracefulCleanup();

async function uploadSource(context: any): Promise<void> {
async function uploadSource(context: args.Context): Promise<void> {
const uploadUrl = await gcp.cloudfunctions.generateUploadUrl(context.projectId, GCP_REGION);
context.uploadUrl = uploadUrl;
const apiUploadUrl = uploadUrl.replace("https://storage.googleapis.com", "");
Expand All @@ -24,24 +25,30 @@ async function uploadSource(context: any): Promise<void> {
* @param payload The deploy payload.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export async function deploy(context: any, options: any, payload: any): Promise<void> {
if (options.config.get("functions")) {
await checkHttpIam(context, options, payload);
export async function deploy(
context: args.Context,
options: args.Options,
payload: args.Payload
): Promise<void> {
if (!options.config.get("functions")) {
return;
}

await checkHttpIam(context, options, payload);

if (!context.functionsSource) {
return;
}
try {
await uploadSource(context);
logSuccess(
clc.green.bold("functions:") +
" " +
clc.bold(options.config.get("functions.source")) +
" folder uploaded successfully"
);
} catch (err) {
logWarning(clc.yellow("functions:") + " Upload Error: " + err.message);
throw err;
}
if (!context.functionsSource) {
return;
}
try {
await uploadSource(context);
logSuccess(
clc.green.bold("functions:") +
" " +
clc.bold(options.config.get("functions.source")) +
" folder uploaded successfully"
);
} catch (err) {
logWarning(clc.yellow("functions:") + " Upload Error: " + err.message);
throw err;
}
}
26 changes: 19 additions & 7 deletions src/deploy/functions/prepare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,18 @@ import { functionMatchesAnyGroup, getFilterGroups } from "../../functionsDeployH
import { CloudFunctionTrigger, functionsByRegion, allFunctions } from "./deploymentPlanner";
import { promptForFailurePolicies } from "./prompts";
import { prepareFunctionsUpload } from "../../prepareFunctionsUpload";
import * as args from "./args";
import * as gcp from "../../gcp";

import * as validate from "./validate";
import { checkRuntimeDependencies } from "./checkRuntimeDependencies";
import { FirebaseError } from "../../error";

export async function prepare(context: any, options: any, payload: any): Promise<void> {
export async function prepare(
context: args.Context,
options: args.Options,
payload: args.Payload
): Promise<void> {
if (!options.config.has("functions")) {
return;
}
Expand All @@ -31,9 +36,14 @@ export async function prepare(context: any, options: any, payload: any): Promise

// Check that all necessary APIs are enabled.
const checkAPIsEnabled = await Promise.all([
ensureApiEnabled.ensure(options.project, "cloudfunctions.googleapis.com", "functions"),
ensureApiEnabled.check(projectId, "runtimeconfig.googleapis.com", "runtimeconfig", true),
checkRuntimeDependencies(projectId, context.runtimeChoice),
ensureApiEnabled.ensure(projectId, "cloudfunctions.googleapis.com", "functions"),
ensureApiEnabled.check(
projectId,
"runtimeconfig.googleapis.com",
"runtimeconfig",
/* silent=*/ true
),
checkRuntimeDependencies(projectId, context.runtimeChoice!),
]);
context.runtimeConfigEnabled = checkAPIsEnabled[1];

Expand Down Expand Up @@ -70,11 +80,13 @@ export async function prepare(context: any, options: any, payload: any): Promise
}

// Build a regionMap, and duplicate functions for each region they are being deployed to.
payload.functions = {};
// TODO: Make byRegion an implementation detail of deploymentPlanner
// and only store a flat array of Functions in payload.
payload.functions.byRegion = functionsByRegion(projectId, functions);
payload.functions.triggers = allFunctions(payload.functions.byRegion);
const byRegion = functionsByRegion(projectId, functions);
payload.functions = {
byRegion,
triggers: allFunctions(byRegion),
};

// Validate the function code that is being deployed.
validate.functionsDirectoryExists(options, sourceDirName);
Expand Down
4 changes: 3 additions & 1 deletion src/deploy/functions/prompts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import { promptOnce } from "../../prompt";
import { CloudFunction } from "../../gcp/cloudfunctions";
import * as utils from "../../utils";
import { logger } from "../../logger";
import * as args from "./args";
import * as gcf from "../../gcp/cloudfunctions";

/**
* Checks if a deployment will create any functions with a failure policy.
Expand All @@ -15,7 +17,7 @@ import { logger } from "../../logger";
* @param functions A list of all functions in the deployment
*/
export async function promptForFailurePolicies(
options: any,
options: args.Options,
functions: CloudFunctionTrigger[],
existingFunctions: CloudFunction[]
): Promise<void> {
Expand Down
15 changes: 11 additions & 4 deletions src/deploy/functions/release.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,22 +12,29 @@ import { promptForFunctionDeletion } from "./prompts";
import Queue from "../../throttler/queue";
import { DeploymentTimer } from "./deploymentTimer";
import { ErrorHandler } from "./errorHandler";
import * as args from "./args";
import * as deploymentPlanner from "./deploymentPlanner";

export async function release(context: any, options: any, payload: any) {
export async function release(context: args.Context, options: args.Options, payload: args.Payload) {
if (!options.config.has("functions")) {
return;
}

const projectId = context.projectId;
const sourceUrl = context.uploadUrl;
const sourceUrl = context.uploadUrl!;
const appEngineLocation = getAppEngineLocation(context.firebaseConfig);

const timer = new DeploymentTimer();
const errorHandler = new ErrorHandler();

const fullDeployment = createDeploymentPlan(
payload.functions.byRegion,
context.existingFunctions,
payload.functions!.byRegion,

// Note: this is obviously a sketchy looking cast. And it's true; the shapes don't
// line up. But it just so happens that we don't hit any bugs with the current
// implementation of the function. This will all go away once everything uses
// backend.FunctionSpec.
(context.existingFunctions! as any) as deploymentPlanner.CloudFunctionTrigger[],
context.filters
);

Expand Down
3 changes: 2 additions & 1 deletion src/deploy/functions/tasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { logger } from "../../logger";
import * as utils from "../../utils";
import { CloudFunctionTrigger } from "./deploymentPlanner";
import { cloudfunctions, cloudscheduler } from "../../gcp";
import { Runtime } from "../../gcp/cloudfunctions";
import * as deploymentTool from "../../deploymentTool";
import * as helper from "../../functionsDeployHelper";
import { RegionalDeployment } from "./deploymentPlanner";
Expand Down Expand Up @@ -39,7 +40,7 @@ export interface DeploymentTask {

export interface TaskParams {
projectId: string;
runtime?: string;
runtime?: Runtime;
sourceUrl?: string;
errorHandler: ErrorHandler;
}
Expand Down
8 changes: 6 additions & 2 deletions src/deploy/functions/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,15 @@ const cjson = require("cjson");

/**
* Check that functions directory exists.
* @param options options object.
* @param options options object. In prod is an args.Options; in tests can just be {cwd: string}
* @param sourceDirName Relative path to source directory.
* @throws { FirebaseError } Functions directory must exist.
*/
export function functionsDirectoryExists(options: object, sourceDirName: string): void {
export function functionsDirectoryExists(
options: { cwd: string; configPath?: string },
sourceDirName: string
): void {
// Note(inlined): What's the difference between this and options.config.path(sourceDirName)?
if (!fsutils.dirExistsSync(projectPath.resolveProjectPath(options, sourceDirName))) {
const msg =
`could not deploy functions because the ${clc.bold('"' + sourceDirName + '"')} ` +
Expand Down
9 changes: 5 additions & 4 deletions src/functionsDeployHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { Job } from "./gcp/cloudscheduler";
import { CloudFunctionTrigger } from "./deploy/functions/deploymentPlanner";
import Queue from "./throttler/queue";
import { ErrorHandler } from "./deploy/functions/errorHandler";
import * as args from "./deploy/functions/args";

export function functionMatchesAnyGroup(fnName: string, filterGroups: string[][]) {
if (!filterGroups.length) {
Expand All @@ -32,21 +33,21 @@ export function functionMatchesGroup(functionName: string, groupChunks: string[]
return _.isEqual(groupChunks, functionNameChunks);
}

export function getFilterGroups(options: any): string[][] {
export function getFilterGroups(options: args.Options): string[][] {
if (!options.only) {
return [];
}

let opts;
return _.chain(options.only.split(","))
return options.only
.split(",")
.filter((filter) => {
opts = filter.split(":");
return opts[0] === "functions" && opts[1];
})
.map((filter) => {
return filter.split(":")[1].split(/[.-]/);
})
.value();
});
}

export function getReleaseNames(
Expand Down
Loading

0 comments on commit e8c817c

Please sign in to comment.