Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
27 changes: 25 additions & 2 deletions src/deploy/functions/backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@
return allMemoryOptions.includes(mem as MemoryOptions);
}

export function isValidEgressSetting(egress: unknown): egress is VpcEgressSettings {

Check warning on line 198 in src/deploy/functions/backend.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Missing JSDoc comment
return egress === "PRIVATE_RANGES_ONLY" || egress === "ALL_TRAFFIC";
}

Expand Down Expand Up @@ -425,6 +425,23 @@
api: string;
}

export interface LifecycleHook {
task?: {
function: string;
body?: Record<string, unknown>;
};
callable?: {
function: string;
body?: Record<string, unknown>;
};
http?: {
function?: string;
url?: string;
method?: string;
body?: unknown;
};
}

/** An API agnostic definition of an entire deployment a customer has or wants. */
export interface Backend {
/**
Expand All @@ -434,6 +451,7 @@
environmentVariables: EnvironmentVariables;
// region -> id -> Endpoint
endpoints: Record<string, Record<string, Endpoint>>;
lifecycleHooks?: Record<string, LifecycleHook>;
}

/**
Expand Down Expand Up @@ -482,8 +500,11 @@
}
apiToReasons[api] = reasons;
}
// Mere all environment variables.
// Merge all environment variables.
merged.environmentVariables = { ...merged.environmentVariables, ...b.environmentVariables };
if (b.lifecycleHooks) {
merged.lifecycleHooks = { ...(merged.lifecycleHooks || {}), ...b.lifecycleHooks };
}
}
for (const [api, reasons] of Object.entries(apiToReasons)) {
merged.requiredAPIs.push({ api, reason: Array.from(reasons).join(" ") });
Expand All @@ -498,7 +519,9 @@
*/
export function isEmptyBackend(backend: Backend): boolean {
return (
Object.keys(backend.requiredAPIs).length === 0 && Object.keys(backend.endpoints).length === 0
Object.keys(backend.requiredAPIs).length === 0 &&
Object.keys(backend.endpoints).length === 0 &&
Object.keys(backend.lifecycleHooks || {}).length === 0
);
}

Expand Down Expand Up @@ -598,7 +621,7 @@
/**
* Loads Cloud Run services into the existing backend.
* @param ctx Context from the Command library, used for caching.
* @param existingBackend The existing backend to load Cloud Run services into.

Check warning on line 624 in src/deploy/functions/backend.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Missing @param "unreachableRegions.run"
* @param unreachableRegions Object to track unreachable regions.
* @param onlyMissing If true, only loads missing Cloud Run services.
*/
Expand All @@ -618,8 +641,8 @@
existingBackend.endpoints[endpoint.region][endpoint.id] = endpoint;
}
}
} catch (err: any) {

Check warning on line 644 in src/deploy/functions/backend.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unexpected any. Specify a different type
logger.debug(`Error loading Cloud Run services: ${err.message}`);

Check warning on line 645 in src/deploy/functions/backend.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe member access .message on an `any` value

Check warning on line 645 in src/deploy/functions/backend.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Invalid type "any" of template literal expression
unreachableRegions.run = ["unknown"];
}
}
Expand Down Expand Up @@ -805,7 +828,7 @@
logger.info(
`Function name ${httpsFunc.id} is too long to have a deterministic Cloud Run URI. Printing the non-deterministic URI instead.`,
);
return httpsFunc.uri!;

Check warning on line 831 in src/deploy/functions/backend.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Forbidden non-null assertion
}
return `https://${serviceName}-${projectNumber}.${httpsFunc.region}.run.app`;
}
52 changes: 52 additions & 0 deletions src/deploy/functions/build.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -504,4 +504,56 @@ describe("applyPrefix", () => {
/Function names must start with a letter/,
);
});

it("should prefix target functions in lifecycleHooks", () => {
const testBuild: build.Build = {
endpoints: {
func1: {
region: "us-central1",
project: "test-project",
platform: "gcfv2",
runtime: "nodejs18",
entryPoint: "func1",
httpsTrigger: {},
},
},
params: [],
requiredAPIs: [],
lifecycleHooks: {
afterInstall: {
task: {
function: "func1",
body: { foo: "bar" },
},
},
afterUpdate: {
callable: {
function: "func1",
},
http: {
function: "func1",
},
},
},
};

build.applyPrefix(testBuild, "staging");

expect(testBuild.lifecycleHooks).to.deep.equal({
afterInstall: {
task: {
function: "staging-func1",
body: { foo: "bar" },
},
},
afterUpdate: {
callable: {
function: "staging-func1",
},
http: {
function: "staging-func1",
},
},
});
});
});
20 changes: 20 additions & 0 deletions src/deploy/functions/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,16 @@

export const REGION_TBD = "REGION_TBD";

export type LifecycleHook = backend.LifecycleHook;

/* The union of a customer-controlled deployment and potentially deploy-time defined parameters */
export interface Build {
requiredAPIs: RequiredApi[];
endpoints: Record<string, Endpoint>;
params: params.Param[];
runtime?: Runtime;
extensions?: Record<string, DynamicExtension>;
lifecycleHooks?: Record<string, LifecycleHook>;
}

/**
Expand Down Expand Up @@ -483,7 +486,7 @@
// List param, we try resolving a String param instead.
try {
regions = params.resolveList(bdEndpoint.region, paramValues);
} catch (err: any) {

Check warning on line 489 in src/deploy/functions/build.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unexpected any. Specify a different type
if (err instanceof ExprParseError) {
regions = [params.resolveString(bdEndpoint.region, paramValues)];
} else {
Expand Down Expand Up @@ -588,6 +591,9 @@

const bkend = backend.of(...bkEndpoints);
bkend.requiredAPIs = build.requiredAPIs;
if (build.lifecycleHooks) {
bkend.lifecycleHooks = build.lifecycleHooks;
}
return bkend;
}

Expand Down Expand Up @@ -733,4 +739,18 @@
}
}
build.endpoints = newEndpoints;

if (build.lifecycleHooks) {
for (const hook of Object.values(build.lifecycleHooks)) {
if (hook.task?.function) {
hook.task.function = `${prefix}-${hook.task.function}`;
}
if (hook.callable?.function) {
hook.callable.function = `${prefix}-${hook.callable.function}`;
}
if (hook.http?.function) {
hook.http.function = `${prefix}-${hook.http.function}`;
}
}
}
}
2 changes: 1 addition & 1 deletion src/deploy/functions/prepare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@
// ===Phase 1. Load codebases from source with optional runtime config.
let runtimeConfig: Record<string, unknown> = { firebase: firebaseConfig };

const targetedCodebaseConfigs = context.config!.filter((cfg) => codebases.includes(cfg.codebase));

Check warning on line 102 in src/deploy/functions/prepare.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Forbidden non-null assertion

Check warning on line 102 in src/deploy/functions/prepare.ts

View workflow job for this annotation

GitHub Actions / lint (24)

This assertion is unnecessary since it does not change the type of the expression

// Load runtime config if API is enabled and at least one targeted codebase uses it
if (checkAPIsEnabled[1] && targetedCodebaseConfigs.some(shouldUseRuntimeConfig)) {
Expand Down Expand Up @@ -248,7 +248,7 @@
? "tar.gz"
: "zip";

const isDart = supported.runtimeIsLanguage(wantBuilds[codebase].runtime!, "dart");

Check warning on line 251 in src/deploy/functions/prepare.ts

View workflow job for this annotation

GitHub Actions / lint (24)

This assertion is unnecessary since the receiver accepts the original type of the expression
const executablePaths = isDart ? ["bin/server"] : [];

const packagedSource = await prepareFunctionsUpload(
Expand Down Expand Up @@ -297,7 +297,7 @@
await ensureTriggerRegions(wantBackend);
resolveCpuAndConcurrency(wantBackend);
resolveDefaultTimeout(wantBackend);
validate.endpointsAreValid(wantBackend);
validate.endpointsAreValid(wantBackend, existingBackend);
inferBlockingDetails(wantBackend);
}

Expand Down
5 changes: 5 additions & 0 deletions src/deploy/functions/release/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { FirebaseError } from "../../../error";
import { getProjectNumber } from "../../../getProjectNumber";
import { release as extRelease } from "../../extensions";
import * as artifacts from "../../../functions/artifacts";
import { executeLifecycleHooks } from "./lifecycle";

/** Releases new versions of functions and extensions to prod. */
export async function release(
Expand Down Expand Up @@ -114,6 +115,10 @@ export async function release(
const wantBackend = backend.merge(...Object.values(payload.functions).map((p) => p.wantBackend));
printTriggerUrls(wantBackend, projectNumber);

for (const [codebase, { wantBackend: w, haveBackend: h }] of Object.entries(payload.functions)) {
await executeLifecycleHooks(w, h, plan, codebase);
}

await setupArtifactCleanupPolicies(
options,
options.projectId!,
Expand Down
183 changes: 183 additions & 0 deletions src/deploy/functions/release/lifecycle.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import { expect } from "chai";
import * as sinon from "sinon";
import * as backend from "../backend";
import { determineDeploymentDelta, executeLifecycleHooks } from "./lifecycle";
import * as cloudtasks from "../../../gcp/cloudtasks";
import { logger } from "../../../logger";
import * as getProjectNumber from "../../../getProjectNumber";
import * as computeEngine from "../../../gcp/computeEngine";

describe("lifecycle", () => {
let sandbox: sinon.SinonSandbox;

beforeEach(() => {
sandbox = sinon.createSandbox();
sandbox.stub(getProjectNumber, "getProjectNumber").resolves("123456");
sandbox
.stub(computeEngine, "getDefaultServiceAccount")
.resolves("123456-compute@developer.gserviceaccount.com");
});

afterEach(() => {
sandbox.restore();
});

describe("determineDeploymentDelta", () => {
it("returns afterInstall when haveBackend has no endpoints", () => {
const haveBackend = backend.empty();

const delta = determineDeploymentDelta(haveBackend);
expect(delta).to.equal("afterInstall");
});

it("returns afterUpdate when haveBackend has existing endpoints", () => {
const haveBackend = backend.of({
id: "myFunc",
project: "myProj",
region: "us-central1",
entryPoint: "myFunc",
platform: "gcfv2",
httpsTrigger: {},
});

const delta = determineDeploymentDelta(haveBackend);
expect(delta).to.equal("afterUpdate");
});
});

describe("executeLifecycleHooks", () => {
it("returns false if no lifecycle hook is configured for the deployment delta", async () => {
const wantBackend = backend.empty();
const haveBackend = backend.empty();

const executed = await executeLifecycleHooks(wantBackend, haveBackend);
expect(executed).to.be.false;
});

it("enqueues task when afterInstall TaskQueue hook is configured on fresh install", async () => {
const enqueueStub = sandbox.stub(cloudtasks, "enqueueTask").resolves();
const loggerStub = sandbox.stub(logger, "info");
const wantBackend = backend.of({
id: "installHookTask",
project: "myProj",
region: "us-central1",
entryPoint: "installHookTask",
platform: "gcfv2",
uri: "https://installhooktask-12345.a.run.app",
taskQueueTrigger: {},
});
wantBackend.lifecycleHooks = {
afterInstall: {
task: {
function: "installHookTask",
body: { setupVersion: 1 },
},
},
};
const haveBackend = backend.empty();

const executed = await executeLifecycleHooks(wantBackend, haveBackend);
expect(executed).to.be.true;
expect(enqueueStub).to.have.been.calledOnce;
const [queueName, task] = enqueueStub.firstCall.args;
expect(queueName).to.equal("projects/myProj/locations/us-central1/queues/installHookTask");
expect(task.httpRequest.url).to.equal("https://installhooktask-12345.a.run.app");
expect(task.httpRequest.httpMethod).to.equal("POST");
expect(task.httpRequest.body).to.equal(
Buffer.from(JSON.stringify({ setupVersion: 1 })).toString("base64"),
);
expect(task.httpRequest.oidcToken).to.deep.equal({
serviceAccountEmail: "123456-compute@developer.gserviceaccount.com",
});
expect(loggerStub).to.have.been.calledWith(
sinon.match.any,
"View logs for installHookTask at: https://console.cloud.google.com/logs/query;query=resource.type%3D%22cloud_run_revision%22%0Aresource.labels.service_name%3D%22installHookTask%22%0Aresource.labels.location%3D%22us-central1%22;project=myProj",
);
});

it("enqueues task when afterUpdate TaskQueue hook is configured on subsequent update", async () => {
const enqueueStub = sandbox.stub(cloudtasks, "enqueueTask").resolves();
const wantBackend = backend.of({
id: "updateHookTask",
project: "myProj",
region: "us-central1",
entryPoint: "updateHookTask",
platform: "gcfv2",
uri: "https://updatehooktask-12345.a.run.app",
taskQueueTrigger: {},
});
wantBackend.lifecycleHooks = {
afterUpdate: {
task: {
function: "updateHookTask",
body: { migrationStep: 2 },
},
},
};
const haveBackend = backend.of({
id: "existingFunc",
project: "myProj",
region: "us-central1",
entryPoint: "existingFunc",
platform: "gcfv2",
httpsTrigger: {},
});

const executed = await executeLifecycleHooks(wantBackend, haveBackend);
expect(executed).to.be.true;
expect(enqueueStub).to.have.been.calledOnce;
const [queueName, task] = enqueueStub.firstCall.args;
expect(queueName).to.equal("projects/myProj/locations/us-central1/queues/updateHookTask");
expect(task.httpRequest.url).to.equal("https://updatehooktask-12345.a.run.app");
expect(task.httpRequest.httpMethod).to.equal("POST");
expect(task.httpRequest.body).to.equal(
Buffer.from(JSON.stringify({ migrationStep: 2 })).toString("base64"),
);
expect(task.httpRequest.oidcToken).to.deep.equal({
serviceAccountEmail: "123456-compute@developer.gserviceaccount.com",
});
});

it("skips afterUpdate hook when deployment plan contains no resource modifications", async () => {
const enqueueStub = sandbox.stub(cloudtasks, "enqueueTask").resolves();
const wantBackend = backend.of({
id: "updateHookTask",
project: "myProj",
region: "us-central1",
entryPoint: "updateHookTask",
platform: "gcfv2",
uri: "https://updatehooktask-12345.a.run.app",
taskQueueTrigger: {},
});
wantBackend.lifecycleHooks = {
afterUpdate: {
task: {
function: "updateHookTask",
},
},
};
const haveBackend = backend.of({
id: "updateHookTask",
project: "myProj",
region: "us-central1",
entryPoint: "updateHookTask",
platform: "gcfv2",
uri: "https://updatehooktask-12345.a.run.app",
taskQueueTrigger: {},
});

const emptyPlan = {
"default-us-central1-default": {
endpointsToCreate: [],
endpointsToUpdate: [],
endpointsToDelete: [],
endpointsToSkip: [wantBackend.endpoints["us-central1"]["updateHookTask"]],
},
};

const executed = await executeLifecycleHooks(wantBackend, haveBackend, emptyPlan, "default");
expect(executed).to.be.false;
expect(enqueueStub).to.not.have.been.called;
});
});
});
Loading
Loading