Skip to content
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
- Fixed an issue where unhandled exceptions during Cloud Functions deployment could be silently ignored and exit with code 0 (#6989).
- Added `crashlytics:heapdumps:{enable,disable,status}` commands to manage Crashlytics Android heap dump collection. (#11225)
- Removed unused dependencies `proxy-agent`, `universal-analytics`, and `@apphosting/common`.
- Fixed an issue where throttled task queues stopped processing remaining tasks when a task exhausted its retries or timed out (#6989, #11040).
Expand Down
117 changes: 117 additions & 0 deletions src/deploy/functions/release/fabricator.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -453,7 +453,7 @@
it("handles topics that already exist", async () => {
pubsub.createTopic.callsFake(() => {
const err = new Error("Already exists");
(err as any).status = 409;

Check warning on line 456 in src/deploy/functions/release/fabricator.spec.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unexpected any. Specify a different type

Check warning on line 456 in src/deploy/functions/release/fabricator.spec.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe member access .status on an `any` value
return Promise.reject(err);
});
gcfv2.createFunction.resolves({ name: "op", done: false });
Expand Down Expand Up @@ -528,7 +528,7 @@
eventarc.createChannel.callsFake(({ name }) => {
expect(name).to.equal("channel");
const err = new Error("Already exists");
(err as any).status = 409;

Check warning on line 531 in src/deploy/functions/release/fabricator.spec.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unexpected any. Specify a different type

Check warning on line 531 in src/deploy/functions/release/fabricator.spec.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe member access .status on an `any` value
return Promise.reject(err);
});
gcfv2.createFunction.resolves({ name: "op", done: false });
Expand Down Expand Up @@ -594,7 +594,7 @@
eventarc.getChannel.resolves(undefined);
eventarc.createChannel.callsFake(() => {
const err = new Error("🤷‍♂️");
(err as any).status = 400;

Check warning on line 597 in src/deploy/functions/release/fabricator.spec.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unexpected any. Specify a different type

Check warning on line 597 in src/deploy/functions/release/fabricator.spec.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe member access .status on an `any` value
return Promise.reject(err);
});

Expand Down Expand Up @@ -1933,7 +1933,7 @@
expect(deleteEndpoint).to.not.have.been.called;

// Resolve the create operation
resolveCreate!();

Check warning on line 1936 in src/deploy/functions/release/fabricator.spec.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Forbidden non-null assertion

await applyPlanPromise;

Expand Down Expand Up @@ -1976,11 +1976,128 @@
expect(scrapers).to.have.lengthOf(2);
expect(scrapers[0]).to.not.equal(scrapers[1]);
});

it("throws FirebaseError and skips deletes when applyUpserts rejects unexpectedly", async () => {
const ep1 = endpoint({ httpsTrigger: {} }, { id: "createFn", region: "us-central1" });
const ep2 = endpoint({ httpsTrigger: {} }, { id: "deleteFn", region: "us-west1" });
const plan: planner.DeploymentPlan = {
default: {
plannedBackend: backend.of(ep1),
regionalChangesets: {
"us-central1": {
endpointsToCreate: [ep1],
endpointsToUpdate: [],
endpointsToDelete: [],
endpointsToSkip: [],
},
"us-west1": {
endpointsToCreate: [],
endpointsToUpdate: [],
endpointsToDelete: [ep2],
endpointsToSkip: [],
},
},
},
};

const upsertCause = new Error("Unexpected upsert crash");
const applyUpsertsStub = sinon.stub(fab, "applyUpserts");
applyUpsertsStub.onFirstCall().rejects(upsertCause);
applyUpsertsStub.onSecondCall().resolves([]);
const applyDeletesSpy = sinon.spy(fab, "applyDeletes");

const err = (await expect(fab.applyPlan(plan)).to.be.rejectedWith(
FirebaseError,
"Fabricator.applyUpserts encountered an unhandled exception: Unexpected upsert crash",
)) as FirebaseError;
expect(err.original).to.equal(upsertCause);
expect(applyDeletesSpy).to.not.have.been.called;
});

it("throws FirebaseError with children and skips role removals when applyDeletes rejects across regions", async () => {
const ep1 = endpoint({ httpsTrigger: {} }, { id: "deleteFnCentral", region: "us-central1" });
const ep2 = endpoint({ httpsTrigger: {} }, { id: "deleteFnWest", region: "us-west1" });
const plan: planner.DeploymentPlan = {
default: {
plannedBackend: backend.empty(),
regionalChangesets: {
"us-central1": {
endpointsToCreate: [],
endpointsToUpdate: [],
endpointsToDelete: [ep1],
endpointsToSkip: [],
},
"us-west1": {
endpointsToCreate: [],
endpointsToUpdate: [],
endpointsToDelete: [ep2],
endpointsToSkip: [],
},
},
},
};

const applyDeletesStub = sinon.stub(fab, "applyDeletes");
applyDeletesStub.onFirstCall().rejects(new Error("Unexpected delete crash central"));
applyDeletesStub.onSecondCall().rejects(new Error("Unexpected delete crash west"));
const removeOldRolesSpy = sinon.spy(fab, "removeOldRoles");

const err = (await expect(fab.applyPlan(plan)).to.be.rejectedWith(
FirebaseError,
"Fabricator.applyDeletes encountered unhandled exceptions",
)) as FirebaseError;
expect(err.children).to.have.lengthOf(2);
expect((err.children[0] as Error).message).to.equal("Unexpected delete crash central");
expect((err.children[1] as Error).message).to.equal("Unexpected delete crash west");
expect(removeOldRolesSpy).to.not.have.been.called;
});

it("isolates logOpStart errors to the failing endpoint and handles unknown runtimes", async () => {
const epUnknownRuntime = endpoint(
{ httpsTrigger: {} },
{
id: "unknownRuntimeFn",
region: "us-central1",
runtime: "custom-future-runtime" as backend.Endpoint["runtime"],
},
);
const epFailingLog = endpoint(
{ httpsTrigger: {} },
{ id: "failingLogFn", region: "us-central1" },
);
const changes: planner.Changeset = {
endpointsToCreate: [epUnknownRuntime, epFailingLog],
endpointsToUpdate: [],
endpointsToDelete: [],
endpointsToSkip: [],
};

sinon.stub(fab, "createEndpoint").resolves();
const origLogOpStart = fab.logOpStart.bind(fab);
sinon.stub(fab, "logOpStart").callsFake((op: string, ep: backend.Endpoint) => {
if (ep.id === "failingLogFn") {
throw new Error("logOpStart failure");
}
origLogOpStart(op, ep);
});

const results = await fab.applyUpserts(
changes,
new scraper.SourceTokenScraper(),
new scraper.SourceTokenScraper(),
);

expect(results).to.have.lengthOf(2);
expect(results[0].endpoint.id).to.equal("unknownRuntimeFn");
expect(results[0].error).to.be.undefined;
expect(results[1].endpoint.id).to.equal("failingLogFn");
expect(results[1].error?.message).to.equal("logOpStart failure");
});
});

describe("createRunFunction", () => {
it("creates a Cloud Run service with correct configuration", async () => {
runv2.createService.resolves({ uri: "https://service", name: "service" } as any);

Check warning on line 2100 in src/deploy/functions/release/fabricator.spec.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unexpected any. Specify a different type

Check warning on line 2100 in src/deploy/functions/release/fabricator.spec.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe argument of type `any` assigned to a parameter of type `Service | undefined`
run.setInvokerCreate.resolves();

const ep = endpoint(
Expand Down Expand Up @@ -2021,7 +2138,7 @@
});

it("always sets callable triggers to public on creation", async () => {
runv2.createService.resolves({ uri: "https://service", name: "service" } as any);

Check warning on line 2141 in src/deploy/functions/release/fabricator.spec.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe argument of type `any` assigned to a parameter of type `Service | undefined`
run.setInvokerCreate.resolves();

const ep = endpoint(
Expand Down
68 changes: 54 additions & 14 deletions src/deploy/functions/release/fabricator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
parseErrorCode,
} from "./executor";
import * as ensure from "../ensure";
import { FirebaseError } from "../../../error";
import { FirebaseError, getError, getErrStack } from "../../../error";

import { SourceTokenScraper } from "./sourceTokenScraper";
import { Timer } from "./timer";
Expand Down Expand Up @@ -275,20 +275,37 @@ export class Fabricator {
const createAndUpdateResultsArray = await Promise.allSettled(createAndUpdatePromises);

// Process results of Phase 1
const upsertErrors: Error[] = [];
summary.results = createAndUpdateResultsArray.reduce<reporter.DeployResult[]>((acc, r) => {
if (r.status === "fulfilled") {
return [...acc, ...r.value];
}
// Handle rejection
upsertErrors.push(getError(r.reason));
Comment thread
ajperel marked this conversation as resolved.
Resolved
logger.debug(
"Fabricator.applyUpserts returned an unhandled exception.",
JSON.stringify(r.reason, null, 2),
getErrStack(r.reason),
);
return acc;
}, []);

await this.cleanupUnusedServiceAccounts(plan, summary.results);

// If applyUpserts rejected outside wrapOperation, fail immediately instead of
// proceeding to function deletions with an incomplete result set and exiting 0.
if (upsertErrors.length) {
const opts =
upsertErrors.length === 1 ? { original: upsertErrors[0] } : { children: upsertErrors };
const msg =
upsertErrors.length === 1
? `Fabricator.applyUpserts encountered an unhandled exception: ${upsertErrors[0].message}`
: "Fabricator.applyUpserts encountered unhandled exceptions";
throw new FirebaseError(msg, {
...opts,
exit: 2,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using exit: 2 is inconsistent with other errors in this file and the rest of the codebase, which standardly use exit: 1 for general deployment failures.

To maintain consistency and adhere to the standard exit codes, consider changing this to exit: 1 or omitting it entirely to let it default to 1.

Suggested change
exit: 2,
exit: 1,
References
  1. Throw FirebaseError for expected, user-facing errors. If the error is due to a violation of a precondition, specify a non-zero exit code ({ exit: 1 }). (link)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I chatted with AI a whole lot about this and it was convinced we should do 2 since it's an unknown internal error and not a user caused error. I defer to @joehan

});
}

const hasFailures = summary.results.some((r) => r.error);

if (hasFailures) {
Expand All @@ -312,19 +329,36 @@ export class Fabricator {
allChangesets.map((changes) => this.applyDeletes(changes)),
);

const deleteErrors: Error[] = [];
const deleteResults = deleteResultsArray.reduce<reporter.DeployResult[]>((acc, r) => {
if (r.status === "fulfilled") {
return [...acc, ...r.value];
}
deleteErrors.push(getError(r.reason));
logger.debug(
"Fabricator.applyDeletes returned an unhandled exception. This should never happen",
JSON.stringify(r.reason, null, 2),
getErrStack(r.reason),
);
return acc;
}, []);

summary.results.push(...deleteResults);

// If applyDeletes rejected outside wrapOperation, fail instead of returning
// an incomplete summary that allows the deploy to succeed with exit code 0.
if (deleteErrors.length) {
const opts =
deleteErrors.length === 1 ? { original: deleteErrors[0] } : { children: deleteErrors };
const msg =
deleteErrors.length === 1
? `Fabricator.applyDeletes encountered an unhandled exception: ${deleteErrors[0].message}`
: "Fabricator.applyDeletes encountered unhandled exceptions";
throw new FirebaseError(msg, {
...opts,
exit: 2,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using exit: 2 is inconsistent with other errors in this file and the rest of the codebase, which standardly use exit: 1 for general deployment failures.

To maintain consistency and adhere to the standard exit codes, consider changing this to exit: 1 or omitting it entirely to let it default to 1.

Suggested change
exit: 2,
exit: 1,
References
  1. Throw FirebaseError for expected, user-facing errors. If the error is due to a violation of a precondition, specify a non-zero exit code ({ exit: 1 }). (link)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I chatted with AI a whole lot about this and it was convinced we should do 2 since it's an unknown internal error and not a user caused error. I defer to @joehan

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I buy your logic here. We've not been super consistent about error code handling accross the codebase, but thats no reason to not try to be better here.

});
}

// Similarly to grantNewRoles, we execute role removals sequentially to avoid concurrent
// IAM policy update conflicts (409 Conflict / etag mismatch) on GCP.
for (const [codebase, codebasePlan] of Object.entries(plan)) {
Expand Down Expand Up @@ -370,11 +404,11 @@ export class Fabricator {
const ops: Array<Promise<reporter.DeployResult>> = [];

for (const endpoint of changes.endpointsToCreate) {
this.logOpStart("creating", endpoint);
ops.push(
this.wrapOperation("create", endpoint, () =>
this.createEndpoint(endpoint, scraperV1, scraperV2),
),
this.wrapOperation("create", endpoint, () => {
this.logOpStart("creating", endpoint);
return this.createEndpoint(endpoint, scraperV1, scraperV2);
}),
);
}

Expand All @@ -383,11 +417,11 @@ export class Fabricator {
}

for (const update of changes.endpointsToUpdate) {
this.logOpStart("updating", update.endpoint);
ops.push(
this.wrapOperation("update", update.endpoint, () =>
this.updateEndpoint(update, scraperV1, scraperV2),
),
this.wrapOperation("update", update.endpoint, () => {
this.logOpStart("updating", update.endpoint);
return this.updateEndpoint(update, scraperV1, scraperV2);
}),
);
}

Expand All @@ -398,8 +432,12 @@ export class Fabricator {
const ops: Array<Promise<reporter.DeployResult>> = [];

for (const endpoint of changes.endpointsToDelete) {
this.logOpStart("deleting", endpoint);
ops.push(this.wrapOperation("delete", endpoint, () => this.deleteEndpoint(endpoint)));
ops.push(
this.wrapOperation("delete", endpoint, () => {
this.logOpStart("deleting", endpoint);
return this.deleteEndpoint(endpoint);
}),
);
}

return Promise.all(ops);
Expand Down Expand Up @@ -1177,7 +1215,9 @@ export class Fabricator {
}

logOpStart(op: string, endpoint: backend.Endpoint): void {
const runtime = endpoint.runtime ? RUNTIMES[endpoint.runtime].friendly : "unknown";
const runtime = endpoint.runtime
? RUNTIMES[endpoint.runtime]?.friendly ?? endpoint.runtime
: "unknown";
const platform = getHumanFriendlyPlatformName(endpoint.platform);
const label = helper.getFunctionLabel(endpoint);
utils.logLabeledBullet(
Expand Down
Loading