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

Assign emulator ports and resolve hostnames upfront. #5083

Merged
merged 6 commits into from Oct 7, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
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
1 change: 1 addition & 0 deletions CHANGELOG.md
@@ -1 +1,2 @@
- Enable single project mode for the database emulator (#5068).
- Ravamp emulator networking to assign ports early and explictly listen on IP addresses (#5083).
27 changes: 12 additions & 15 deletions scripts/emulator-tests/functionsEmulator.spec.ts
Expand Up @@ -10,13 +10,12 @@ import * as logform from "logform";

import { EmulatedTriggerDefinition } from "../../src/emulator/functionsEmulatorShared";
import { FunctionsEmulator } from "../../src/emulator/functionsEmulator";
import { Emulators } from "../../src/emulator/types";
import { EmulatorInfo, Emulators } from "../../src/emulator/types";
import { FakeEmulator } from "../../src/test/emulators/fakeEmulator";
import { TIMEOUT_LONG, TIMEOUT_MED, MODULE_ROOT } from "./fixtures";
import { logger } from "../../src/logger";
import * as registry from "../../src/emulator/registry";
import * as secretManager from "../../src/gcp/secretManager";
import { findAvailablePort } from "../../src/emulator/portUtils";

if ((process.env.DEBUG || "").toLowerCase().includes("spec")) {
const dropLogLevels = (info: logform.TransformableInfo) => info.message;
Expand Down Expand Up @@ -614,22 +613,20 @@ describe("FunctionsEmulator-Hub", function () {
});

describe("environment variables", () => {
const host = "127.0.0.1";
const startFakeEmulator = async (emulator: Emulators): Promise<number> => {
const port = await findAvailablePort(host, 4000);
const fake = new FakeEmulator(emulator, host, port);
const startFakeEmulator = async (emulator: Emulators): Promise<EmulatorInfo> => {
const fake = await FakeEmulator.create(emulator);
await registry.EmulatorRegistry.start(fake);
return port;
return fake.getInfo();
};

afterEach(() => {
return registry.EmulatorRegistry.stopAll();
});

it("should set env vars when the emulator is running", async () => {
const databasePort = await startFakeEmulator(Emulators.DATABASE);
const firestorePort = await startFakeEmulator(Emulators.FIRESTORE);
const authPort = await startFakeEmulator(Emulators.AUTH);
const database = await startFakeEmulator(Emulators.DATABASE);
const firestore = await startFakeEmulator(Emulators.FIRESTORE);
const auth = await startFakeEmulator(Emulators.AUTH);

await useFunction(emu, "functionId", () => {
return {
Expand All @@ -649,14 +646,14 @@ describe("FunctionsEmulator-Hub", function () {
.get("/fake-project-id/us-central1/functionId")
.expect(200)
.then((res) => {
expect(res.body.databaseHost).to.eql(`${host}:${databasePort}`);
expect(res.body.firestoreHost).to.eql(`${host}:${firestorePort}`);
expect(res.body.authHost).to.eql(`${host}:${authPort}`);
expect(res.body.databaseHost).to.eql(`${database.host}:${database.port}`);
expect(res.body.firestoreHost).to.eql(`${firestore.host}:${firestore.port}`);
expect(res.body.authHost).to.eql(`${auth.host}:${auth.port}`);
});
}).timeout(TIMEOUT_MED);

it("should return an emulated databaseURL when RTDB emulator is running", async () => {
const databasePort = await startFakeEmulator(Emulators.DATABASE);
const database = await startFakeEmulator(Emulators.DATABASE);

await useFunction(emu, "functionId", () => {
return {
Expand All @@ -673,7 +670,7 @@ describe("FunctionsEmulator-Hub", function () {
.expect(200)
.then((res) => {
expect(res.body.databaseURL).to.eql(
`http://${host}:${databasePort}/?ns=fake-project-id-default-rtdb`
`http://${database.host}:${database.port}/?ns=fake-project-id-default-rtdb`
);
});
}).timeout(TIMEOUT_MED);
Expand Down
2 changes: 1 addition & 1 deletion scripts/extensions-emulator-tests/tests.ts
Expand Up @@ -51,7 +51,7 @@ describe("CF3 and Extensions emulator", () => {

const config = readConfig();
const storagePort = config.emulators!.storage.port;
process.env.STORAGE_EMULATOR_HOST = `http://localhost:${storagePort}`;
process.env.STORAGE_EMULATOR_HOST = `http://127.0.0.1:${storagePort}`;

const firestorePort = config.emulators!.firestore.port;
process.env.FIRESTORE_EMULATOR_HOST = `localhost:${firestorePort}`;
Expand Down
4 changes: 2 additions & 2 deletions scripts/storage-emulator-integration/utils.ts
Expand Up @@ -26,15 +26,15 @@ export function readEmulatorConfig(config = FIREBASE_EMULATOR_CONFIG): Framework
export function getStorageEmulatorHost(emulatorConfig: FrameworkOptions) {
const port = emulatorConfig.emulators?.storage?.port;
if (port) {
return `http://localhost:${port}`;
return `http://127.0.0.1:${port}`;
}
throw new Error("Storage emulator config not found or invalid");
}

export function getAuthEmulatorHost(emulatorConfig: FrameworkOptions) {
const port = emulatorConfig.emulators?.auth?.port;
if (port) {
return `http://localhost:${port}`;
return `http://127.0.0.1:${port}`;
}
throw new Error("Auth emulator config not found or invalid");
}
Expand Down
127 changes: 127 additions & 0 deletions src/emulator/ExpressBasedEmulator.ts
@@ -0,0 +1,127 @@
import * as cors from "cors";
import * as express from "express";
import * as bodyParser from "body-parser";

import * as utils from "../utils";
import { Emulators, EmulatorInstance, EmulatorInfo, ListenSpec } from "./types";
import { createServer } from "node:http";
import { IPV4_UNSPECIFIED, IPV6_UNSPECIFIED } from "./dns";
import { ListenOptions } from "node:net";

export interface ExpressBasedEmulatorOptions {
listen: ListenSpec[];
noCors?: boolean;
noBodyParser?: boolean;
}

/**
* An EmulatorInstance that starts express servers with multi-listen support.
*
* This class correctly destroys the server(s) when `stop()`-ed. When overriding
* life-cycle methods, make sure to call the super methods for those behaviors.
*/
export abstract class ExpressBasedEmulator implements EmulatorInstance {
static PATH_EXPORT = "/_admin/export";
static PATH_DISABLE_FUNCTIONS = "/functions/disableBackgroundTriggers";
static PATH_ENABLE_FUNCTIONS = "/functions/enableBackgroundTriggers";
static PATH_EMULATORS = "/emulators";

private destroyers = new Set<() => Promise<void>>();

constructor(private options: ExpressBasedEmulatorOptions) {}

protected createExpressApp(): Promise<express.Express> {
const app = express();
if (!this.options.noCors) {
// Enable CORS for all APIs, all origins (reflected), and all headers (reflected).
// This is enabled by default since most emulators are cookieless.
app.use(cors({ origin: true }));

// Return access-control-allow-private-network heder if requested
// Enables accessing locahost when site is exposed via tunnel see https://github.com/firebase/firebase-tools/issues/4227
// Aligns with https://wicg.github.io/private-network-access/#headers
// Replace with cors option if adopted, see https://github.com/expressjs/cors/issues/236
app.use((req, res, next) => {
if (req.headers["access-control-request-private-network"]) {
res.setHeader("access-control-allow-private-network", "true");
}
next();
});
}
if (!this.options.noBodyParser) {
app.use(bodyParser.json()); // used in most emulators
}
app.set("json spaces", 2);

return Promise.resolve(app);
}

async start(): Promise<void> {
const app = await this.createExpressApp();

const promises = [];
const specs = this.options.listen;

const listenOptions: ListenOptions[] = [];

const dualStackPorts = new Set();
for (const spec of specs) {
if (spec.address === IPV6_UNSPECIFIED.address) {
if (specs.some((s) => s.port === spec.port && s.address === IPV4_UNSPECIFIED.address)) {
// We can use the default dual-stack behavior in Node.js to listen on
// the same port on both IPv4 and IPv6 unspecified addresses on most OSes.
// https://nodejs.org/api/net.html#serverlistenport-host-backlog-callback
listenOptions.push({
port: spec.port,
ipv6Only: false,
});
dualStackPorts.add(spec.port);
}
}
}
// Then add options for non-dual-stack addresses and ports.
for (const spec of specs) {
if (!dualStackPorts.has(spec.port)) {
listenOptions.push({
host: spec.address,
port: spec.port,
ipv6Only: spec.family === "IPv6",
});
}
}

for (const opt of listenOptions) {
promises.push(
new Promise((resolve, reject) => {
const server = createServer(app).listen(opt);
server.once("listening", resolve);
server.once("error", reject);
this.destroyers.add(utils.createDestroyer(server));
})
);
}
}

async connect(): Promise<void> {
// no-op
}

async stop(): Promise<void> {
const promises = [];
for (const destroyer of this.destroyers) {
promises.push(destroyer().then(() => this.destroyers.delete(destroyer)));
}
await Promise.all(promises);
}

getInfo(): EmulatorInfo {
return {
name: this.getName(),
listen: this.options.listen,
host: this.options.listen[0].address,
port: this.options.listen[0].port,
};
}

abstract getName(): Emulators;
}