Skip to content

Commit d2994c3

Browse files
committed
Functions can now deploy based on YAML definitions
Golang now has support for functions/backend.yaml definitions Golang also conceivably supports launching a customer's project and calling /__/backend.yaml, but we currenlty mock out the server.
1 parent 2e9e0cf commit d2994c3

File tree

4 files changed

+413
-89
lines changed

4 files changed

+413
-89
lines changed
Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,283 @@
1+
import fetch from "node-fetch";
2+
import * as fs from "fs";
3+
import * as path from "path";
4+
import * as yaml from "js-yaml";
5+
import { promisify } from "util";
6+
7+
import { logger } from "../../../../logger";
8+
import * as api from "../../.../../../../api";
9+
import * as backend from "../../backend";
10+
import * as runtimes from "..";
11+
import { FirebaseError } from "../../../../error";
12+
import { schedule } from "firebase-functions/lib/providers/pubsub";
13+
import { type } from "os";
14+
import { reject } from "lodash";
15+
16+
const readFileAsync = promisify(fs.readFile);
17+
18+
// Use "omit" for output only fields. This allows us to fully exhaust keyof T
19+
// while still recognizing output-only fields
20+
type type = "string" | "number" | "boolean" | "object" | "array" | "omit";
21+
function requireKeys<T extends object>(prefix: string, yaml: T, ...keys: (keyof T)[]) {
22+
if (prefix) {
23+
prefix = prefix + ".";
24+
}
25+
for (const key of keys) {
26+
if (!yaml[key]) {
27+
throw new FirebaseError(`Expected key ${prefix + key}`);
28+
}
29+
}
30+
}
31+
32+
function assertKeyTypes<T extends Object>(prefix: string, yaml: T | undefined, schema: Record<keyof T, type>) {
33+
if (!yaml) {
34+
return;
35+
}
36+
for (const [keyAsString, value] of Object.entries(yaml)) {
37+
// I don't know why Object.entries(foo)[0] isn't type of keyof foo...
38+
const key = keyAsString as keyof T;
39+
let fullKey = prefix ? prefix + "." + key : key;
40+
if (!schema[key] || schema[key] === "omit") {
41+
throw new FirebaseError(`Unexpected key ${fullKey}. You may need to install a newer version of the Firebase CLI`);
42+
}
43+
if (schema[key] === "string") {
44+
if (typeof value !== "string") {
45+
throw new FirebaseError(`Expected ${fullKey} to be string; was ${typeof value}`);
46+
}
47+
} else if (schema[key] === "number") {
48+
if (typeof value !== "number") {
49+
throw new FirebaseError(`Expected ${fullKey} to be a number; was ${typeof value}`);
50+
}
51+
} else if (schema[key] === "boolean") {
52+
if (typeof value !== "boolean") {
53+
throw new FirebaseError(`Expected ${fullKey} to be a boolean; was ${typeof value}`);
54+
}
55+
} else if (schema[key] === "array") {
56+
if (!Array.isArray(value)) {
57+
throw new FirebaseError(`Expected ${fullKey} to be an array; was ${typeof value}`);
58+
}
59+
} else if (schema[key] === "object") {
60+
if (typeof value !== "object") {
61+
throw new FirebaseError(`Expected ${fullKey} to be an object; was ${typeof value}`);
62+
}
63+
} else {
64+
throw new FirebaseError("YAML validation is missing a handled type " + schema[key]);
65+
}
66+
}
67+
}
68+
69+
export function validateYaml(yaml: any) {
70+
try {
71+
tryValidateYaml(yaml);
72+
} catch (err) {
73+
throw new FirebaseError("Failed to parsebackend specification", {children: [err]});
74+
}
75+
}
76+
77+
function tryValidateYaml(yaml: any) {
78+
backend.empty().cloudFunctions[0]
79+
// Use a helper type to help guide code complete when writing this function
80+
const typed = yaml as backend.Backend;
81+
assertKeyTypes("", typed, {
82+
"requiredAPIs": "object",
83+
"cloudFunctions": "array",
84+
"topics": "array",
85+
"schedules": "array",
86+
"environmentVariables": "object",
87+
});
88+
requireKeys("", typed, "cloudFunctions");
89+
90+
for (let ndx = 0; ndx < typed.cloudFunctions.length; ndx++) {
91+
const prefix = `cloudFunctions[${ndx}]`;
92+
const func = typed.cloudFunctions[ndx];
93+
requireKeys(prefix, func, "apiVersion", "id", "entryPoint", "trigger");
94+
assertKeyTypes(prefix, func, {
95+
"apiVersion": "number",
96+
"id": "string",
97+
"region": "string",
98+
"project": "string",
99+
"runtime": "string",
100+
"entryPoint": "string",
101+
"availableMemoryMb": "number",
102+
"maxInstances": "number",
103+
"minInstances": "number",
104+
"serviceAccountEmail": "string",
105+
"timeout": "string",
106+
"trigger": "object",
107+
"vpcConnector": "string",
108+
"vpcConnectorEgressSettings": "object",
109+
"labels": "object",
110+
"ingressSettings": "object",
111+
"environmentVariables": "omit",
112+
"uri": "omit",
113+
"sourceUploadUrl": "omit",
114+
})
115+
if (backend.isEventTrigger(func.trigger)) {
116+
requireKeys(prefix + ".trigger", func.trigger, "eventType", "eventFilters");
117+
assertKeyTypes(prefix + ".trigger", func.trigger, {
118+
"eventFilters": "object",
119+
"eventType": "string",
120+
"retry": "boolean",
121+
"region": "string",
122+
"serviceAccountEmail": "string",
123+
});
124+
} else {
125+
assertKeyTypes(prefix + ".trigger", func.trigger, {
126+
"allowInsecure": "boolean",
127+
});
128+
}
129+
// TODO: ingressSettings and vpccConnectorSettings
130+
}
131+
132+
for (let ndx = 0; ndx < typed.topics?.length; ndx++) {
133+
let prefix = `topics[${ndx}]`;
134+
const topic = typed.topics[ndx];
135+
requireKeys(prefix, topic, "id", "targetService");
136+
assertKeyTypes(prefix, topic, {
137+
"id": "string",
138+
"labels": "object",
139+
"project": "string",
140+
"targetService": "object"
141+
});
142+
143+
prefix += ".targetService";
144+
requireKeys(prefix, topic.targetService, "id");
145+
assertKeyTypes(prefix, topic.targetService, {
146+
"id": "string",
147+
"project": "string",
148+
"region": "string",
149+
});
150+
}
151+
152+
for (let ndx = 0; ndx < typed.schedules?.length; ndx++) {
153+
let prefix = `schedules[${ndx}]`;
154+
const schedule = typed.schedules[ndx];
155+
requireKeys(prefix, schedule, "id", "schedule", "transport", "targetService");
156+
assertKeyTypes(prefix, schedule, {
157+
"id": "string",
158+
"project": "string",
159+
"retryConfig": "object",
160+
"schedule": "string",
161+
"timeZone": "string",
162+
"transport": "string",
163+
"targetService": "object",
164+
})
165+
166+
assertKeyTypes(prefix + ".retryConfig", schedule.retryConfig, {
167+
"maxBackoffDuration": "string",
168+
"minBackoffDuration": "string",
169+
"maxDoublings": "number",
170+
"maxRetryDuration": "string",
171+
"retryCount": "number",
172+
});
173+
174+
requireKeys(prefix = ".targetService", schedule.targetService, "id");
175+
assertKeyTypes(prefix + ".targetService", schedule.targetService, {
176+
"id": "string",
177+
"project": "string",
178+
"region": "string"
179+
});
180+
}
181+
}
182+
183+
export async function detectFromYaml(directory: string, project: string, runtime: runtimes.Runtime): Promise<backend.Backend | undefined> {
184+
let text: string;
185+
try {
186+
text = await readFileAsync(path.join(directory, "backend.yaml"), "utf8")
187+
} catch (err) {
188+
if (err.code === "ENOENT") {
189+
logger.debug("Could not find backend.yaml. Must use http discovery");
190+
} else {
191+
logger.debug("Unexpected error looking for backend.yaml file:", err);
192+
}
193+
return;
194+
}
195+
196+
logger.debug("Found backend.yaml. Got spec:", text);
197+
// TOODO(inlined): use a schema instead of manually checking everything or blindly trusting input.
198+
const parsed = yaml.load(text);
199+
validateYaml(parsed);
200+
fillDefaults(parsed, project, api.functionsDefaultRegion, runtime);
201+
return parsed;
202+
}
203+
204+
function fillDefaults(want: backend.Backend, project: string, region: string, runtime: runtimes.Runtime) {
205+
want.requiredAPIs = want.requiredAPIs || {};
206+
want.environmentVariables = want.environmentVariables || {};
207+
want.schedules = want.schedules || [];
208+
want.topics = want.topics || [];
209+
210+
for (const cloudFunction of want.cloudFunctions) {
211+
if (!cloudFunction.project) {
212+
cloudFunction.project = project;
213+
}
214+
if (!cloudFunction.region) {
215+
cloudFunction.region = region;
216+
}
217+
if (!cloudFunction.runtime) {
218+
cloudFunction.runtime = runtime;
219+
}
220+
}
221+
222+
for (const topic of want.topics) {
223+
if (!topic.project) {
224+
topic.project = project;
225+
}
226+
if (!topic.targetService.project) {
227+
topic.targetService.project = project;
228+
}
229+
if (!topic.targetService.region) {
230+
topic.targetService.region = region;
231+
}
232+
}
233+
234+
for (const schedule of want.schedules) {
235+
if (!schedule.project) {
236+
schedule.project = project;
237+
}
238+
if (!schedule.targetService.project) {
239+
schedule.targetService.project = project;
240+
}
241+
if(!schedule.targetService.region) {
242+
schedule.targetService.region = region;
243+
}
244+
}
245+
}
246+
247+
export async function detectFromPort(port: number, project: string, runtime: runtimes.Runtime): Promise<backend.Backend> {
248+
// The result type of fetch isn't exported
249+
let res: { text(): Promise<string> };
250+
let timeout = new Promise<never>((resolve, reject) => {
251+
setTimeout(() => {
252+
reject(new FirebaseError("User code failed to load. Cannot determine backend specification"));
253+
}, /* 30s to boot up */ 30_000)
254+
});
255+
256+
while (true) {
257+
try {
258+
res = await Promise.race([fetch(`http://localhost:${port}/__/backend.yaml`), timeout]);
259+
break;
260+
} catch (err) {
261+
// Allow us to wait until the server is listening.
262+
if (/ECONNREFUSED/.exec(err?.message)) {
263+
continue;
264+
}
265+
throw err;
266+
}
267+
}
268+
269+
const text = await res.text();
270+
logger.debug("Got response from /__/backend.yaml", text);
271+
272+
let parsed: any;
273+
try {
274+
parsed = yaml.load(text);
275+
} catch (err) {
276+
throw new FirebaseError("Failed to parse backend specification", { children: [err]});
277+
}
278+
279+
validateYaml(parsed);
280+
fillDefaults(parsed, project, api.functionsDefaultRegion, runtime);
281+
282+
return parsed;
283+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import * as express from "express";
2+
3+
const app = express();
4+
app.get("/__/backend.yaml", (req, res) => {
5+
res.setHeader("content-type", "text/yaml");
6+
res.send(process.env.BACKEND);
7+
});
8+
9+
let port = 8080;
10+
if (process.env.ADMIN_PORT) {
11+
port = Number.parseInt(process.env.ADMIN_PORT);
12+
}
13+
app.listen(port);

0 commit comments

Comments
 (0)