Skip to content

Commit

Permalink
Block deploys to preview channels if would break prod (#5731)
Browse files Browse the repository at this point in the history
  • Loading branch information
inlined authored Apr 26, 2023
1 parent 0d2acc5 commit 89d7302
Show file tree
Hide file tree
Showing 2 changed files with 199 additions and 2 deletions.
81 changes: 80 additions & 1 deletion src/deploy/hosting/prepare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,11 @@ import * as deploymentTool from "../../deploymentTool";
import { Context } from "./context";
import { Options } from "../../options";
import { HostingOptions } from "../../hosting/options";
import { zipIn } from "../../functional";
import { assertExhaustive, zipIn } from "../../functional";
import { track } from "../../track";
import * as utils from "../../utils";
import { HostingSource } from "../../firebaseConfig";
import * as backend from "../functions/backend";

/**
* Prepare creates versions for each Hosting site to be deployed.
Expand Down Expand Up @@ -34,6 +37,12 @@ export async function prepare(context: Context, options: HostingOptions & Option
if (config.webFramework) {
labels["firebase-web-framework"] = config.webFramework;
}
const unsafe = await unsafePins(context, config);
if (unsafe.length) {
const msg = `Cannot deploy site ${config.site} to channel ${context.hostingChannel} because it would modify one or more rewrites in "live" that are not pinned, breaking production. Please pin "live" before pinning other channels.`;
utils.logLabeledError("Hosting", msg);
throw new Error(msg);
}
const version: Omit<api.Version, api.VERSION_OUTPUT_FIELDS> = {
status: "CREATED",
labels,
Expand All @@ -52,3 +61,73 @@ export async function prepare(context: Context, options: HostingOptions & Option
context.hosting.deploys.push({ config, version });
}
}

function rewriteTarget(source: HostingSource): string {
if ("glob" in source) {
return source.glob;
} else if ("source" in source) {
return source.source;
} else if ("regex" in source) {
return source.regex;
} else {
assertExhaustive(source);
}
}

/**
* Returns a list of rewrite targets that would break in prod if deployed.
* People use tag pinning so that they can deploy to preview channels without
* modifying production. This assumption is violated if the live channel isn't
* actually pinned. This method returns "unsafe" pins, where we're deploying to
* a non-live channel with a rewrite that is pinned but haven't yet pinned live.
*/
export async function unsafePins(
context: Context,
config: config.HostingResolved
): Promise<string[]> {
// Overwriting prod won't break prod
if ((context.hostingChannel || "live") === "live") {
return [];
}

const targetTaggedRewrites: Record<string, string> = {};
for (const rewrite of config.rewrites || []) {
const target = rewriteTarget(rewrite);
if ("run" in rewrite && rewrite.run.pinTag) {
targetTaggedRewrites[target] = `${rewrite.run.region || "us-central1"}/${
rewrite.run.serviceId
}`;
}
if ("function" in rewrite && typeof rewrite.function === "object" && rewrite.function.pinTag) {
const region = rewrite.function.region || "us-central1";
const endpoint = (await backend.existingBackend(context)).endpoints[region][
rewrite.function.functionId
];
// This function is new. It can't be pinned elsewhere
if (!endpoint) {
continue;
}
targetTaggedRewrites[target] = `${region}/${endpoint.runServiceId || endpoint.id}`;
}
}

if (!Object.keys(targetTaggedRewrites).length) {
return [];
}

const channelConfig = await api.getChannel(context.projectId, config.site, "live");
const existingUntaggedRewrites: Record<string, string> = {};
for (const rewrite of channelConfig?.release?.version?.config?.rewrites || []) {
if ("run" in rewrite && !rewrite.run.tag) {
existingUntaggedRewrites[
rewriteTarget(rewrite)
] = `${rewrite.run.region}/${rewrite.run.serviceId}`;
}
}

// There is only a problem if we're targeting the same exact run service but
// live isn't tagged.
return Object.keys(targetTaggedRewrites).filter(
(target) => targetTaggedRewrites[target] === existingUntaggedRewrites[target]
);
}
120 changes: 119 additions & 1 deletion src/test/deploy/hosting/prepare.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ import * as hostingApi from "../../../hosting/api";
import * as tracking from "../../../track";
import * as deploymentTool from "../../../deploymentTool";
import * as config from "../../../hosting/config";
import { prepare } from "../../../deploy/hosting";
import { prepare, unsafePins } from "../../../deploy/hosting/prepare";
import { cloneDeep } from "../../../utils";
import * as backend from "../../../deploy/functions/backend";

describe("hosting prepare", () => {
let hostingStub: sinon.SinonStubbedInstance<typeof hostingApi>;
Expand Down Expand Up @@ -115,4 +117,120 @@ describe("hosting prepare", () => {
],
});
});

describe("unsafePins", () => {
const apiRewriteWithoutPin: hostingApi.Rewrite = {
glob: "**",
run: {
serviceId: "service",
region: "us-central1",
},
};
const apiRewriteWithPin = cloneDeep(apiRewriteWithoutPin);
apiRewriteWithPin.run.tag = "tag";
const configWithRunPin: config.HostingResolved = {
site: "site",
rewrites: [
{
glob: "**",
run: {
serviceId: "service",
pinTag: true,
},
},
],
};
const configWithFuncPin: config.HostingResolved = {
site: "site",
rewrites: [
{
glob: "**",
function: {
functionId: "function",
pinTag: true,
},
},
],
};

let backendStub: sinon.SinonStubbedInstance<typeof backend>;

beforeEach(() => {
backendStub = sinon.stub(backend);
backendStub.existingBackend.resolves({
endpoints: {
"us-central1": {
function: {
id: "function",
runServiceId: "service",
} as unknown as backend.Endpoint,
},
},
requiredAPIs: [],
environmentVariables: {},
});
});

function stubUnpinnedRewrite(): void {
stubRewrite(apiRewriteWithoutPin);
}

function stubPinnedRewrite(): void {
stubRewrite(apiRewriteWithPin);
}

function stubRewrite(rewrite: hostingApi.Rewrite): void {
hostingStub.getChannel.resolves({
release: {
version: {
config: {
rewrites: [rewrite],
},
},
},
} as unknown as hostingApi.Channel);
}

it("does not care about modifying live (implicit)", async () => {
stubUnpinnedRewrite();
await expect(unsafePins({ projectId: "project" }, configWithRunPin)).to.eventually.deep.equal(
[]
);
});

it("does not care about modifying live (explicit)", async () => {
stubUnpinnedRewrite();
await expect(
unsafePins({ projectId: "project", hostingChannel: "live" }, configWithRunPin)
).to.eventually.deep.equal([]);
});

it("does not care about already pinned rewrites (run)", async () => {
stubPinnedRewrite();
await expect(
unsafePins({ projectId: "project", hostingChannel: "test" }, configWithRunPin)
).to.eventually.deep.equal([]);
});

it("does not care about already pinned rewrites (gcf)", async () => {
stubPinnedRewrite();
await expect(
unsafePins({ projectId: "project", hostingChannel: "test" }, configWithFuncPin)
).to.eventually.deep.equal([]);
});

it("rejects about newly pinned rewrites (run)", async () => {
stubUnpinnedRewrite();
await expect(
unsafePins({ projectId: "project", hostingChannel: "test" }, configWithRunPin)
).to.eventually.deep.equal(["**"]);
});

it("rejects about newly pinned rewrites (gcf)", async () => {
stubUnpinnedRewrite();
await expect(
unsafePins({ projectId: "project", hostingChannel: "test" }, configWithFuncPin)
).to.eventually.deep.equal(["**"]);
});
});
});

0 comments on commit 89d7302

Please sign in to comment.