Skip to content
38 changes: 38 additions & 0 deletions src/deploy/functions/release/fabricator.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -452,7 +452,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 455 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 455 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 @@ -527,7 +527,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 530 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 530 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 @@ -593,7 +593,7 @@
eventarc.getChannel.resolves(undefined);
eventarc.createChannel.callsFake(() => {
const err = new Error("🤷‍♂️");
(err as any).status = 400;

Check warning on line 596 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 596 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 @@ -1137,6 +1137,30 @@
"delete topic",
);
});

it("ignores a 404 when the schedule or topic is already deleted", async () => {
scheduler.deleteJob.rejects(new FirebaseError("Job not found.", { status: 404 }));
pubsub.deleteTopic.rejects(new FirebaseError("Topic not found.", { status: 404 }));
await expect(fab.deleteScheduleV1(ep)).to.eventually.be.fulfilled;
expect(scheduler.deleteJob).to.have.been.called;
expect(pubsub.deleteTopic).to.have.been.called;
});

it("ignores a raw GCP 404 where the status is on err.code", async () => {
scheduler.deleteJob.rejects(Object.assign(new Error("Job not found."), { code: 404 }));
pubsub.deleteTopic.rejects(Object.assign(new Error("Topic not found."), { code: 404 }));
await expect(fab.deleteScheduleV1(ep)).to.eventually.be.fulfilled;
expect(scheduler.deleteJob).to.have.been.called;
expect(pubsub.deleteTopic).to.have.been.called;
});

it("still wraps non-404 errors", async () => {
scheduler.deleteJob.rejects(new FirebaseError("Permission denied.", { status: 403 }));
await expect(fab.deleteScheduleV1(ep)).to.eventually.be.rejectedWith(
reporter.DeploymentError,
"delete schedule",
);
});
});

describe("deleteScheduleV2", () => {
Expand All @@ -1162,6 +1186,20 @@
"delete schedule",
);
});

it("ignores a 404 when the schedule is already deleted", async () => {
scheduler.deleteJob.rejects(new FirebaseError("Job not found.", { status: 404 }));
await expect(fab.deleteScheduleV2(ep)).to.eventually.be.fulfilled;
expect(scheduler.deleteJob).to.have.been.called;
});

it("still wraps non-404 errors", async () => {
scheduler.deleteJob.rejects(new FirebaseError("Permission denied.", { status: 403 }));
await expect(fab.deleteScheduleV2(ep)).to.eventually.be.rejectedWith(
reporter.DeploymentError,
"delete schedule",
);
});
});

describe("upsertTaskQueue", () => {
Expand Down Expand Up @@ -1813,7 +1851,7 @@
expect(deleteEndpoint).to.not.have.been.called;

// Resolve the create operation
resolveCreate!();

Check warning on line 1854 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 @@ -1860,7 +1898,7 @@

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 1901 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 1901 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 @@ -1901,7 +1939,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 1942 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
19 changes: 16 additions & 3 deletions src/deploy/functions/release/fabricator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
isCloudRunResourceExhausted,
isServiceAccount404,
isTransientError,
parseErrorCode,
} from "./executor";
import { FirebaseError } from "../../../error";

Expand Down Expand Up @@ -81,6 +82,18 @@ const rethrowAs =
throw new reporter.DeploymentError(endpoint, op, err);
};

// A 404 while deleting means the resource is already gone — the desired end
// state — so treat it as success rather than failing the deployment. See #4795.
const rethrowAsUnlessNotFound =
<T>(endpoint: backend.Endpoint, op: reporter.OperationType) =>
(err: unknown): T | void => {
if (parseErrorCode(err) === 404) {
logger.debug(`Ignoring 404 for ${op} on ${endpoint.id}; resource already deleted.`);
return;
}
return rethrowAs<T>(endpoint, op)(err);
};

/** Fabricators make a customer's backend match a spec by applying a plan. */
export class Fabricator {
executor: Executor;
Expand Down Expand Up @@ -1082,19 +1095,19 @@ export class Fabricator {
const jobName = scheduler.jobNameForEndpoint(endpoint, this.appEngineLocation);
await this.executor
.run(() => scheduler.deleteJob(jobName))
.catch(rethrowAs(endpoint, "delete schedule"));
.catch(rethrowAsUnlessNotFound(endpoint, "delete schedule"));

const topicName = scheduler.topicNameForEndpoint(endpoint);
await this.executor
.run(() => pubsub.deleteTopic(topicName))
.catch(rethrowAs(endpoint, "delete topic"));
.catch(rethrowAsUnlessNotFound(endpoint, "delete topic"));
}

async deleteScheduleV2(endpoint: backend.Endpoint & backend.ScheduleTriggered): Promise<void> {
const jobName = scheduler.jobNameForEndpoint(endpoint, endpoint.region);
await this.executor
.run(() => scheduler.deleteJob(jobName))
.catch(rethrowAs(endpoint, "delete schedule"));
.catch(rethrowAsUnlessNotFound(endpoint, "delete schedule"));
}

async disableTaskQueue(endpoint: backend.Endpoint & backend.TaskQueueTriggered): Promise<void> {
Expand Down
Loading