Skip to content

Commit

Permalink
Fixes source token expiration by token refresh (#5198)
Browse files Browse the repository at this point in the history
* refresh source token

* check if token is expired right before deploy call

* use Date lib, rename states & remove enums

* clean up, revert timer changes

* add type guard to token states
  • Loading branch information
blidd-google committed Nov 4, 2022
1 parent ea2a9ec commit 98e23ed
Show file tree
Hide file tree
Showing 4 changed files with 101 additions and 20 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
@@ -1 +1,2 @@
- Add support for object list using certain Admin SDKs (#5208)
- Fixes source token expiration issue by acquiring new source token upon expiration.
6 changes: 4 additions & 2 deletions src/deploy/functions/release/fabricator.ts
Expand Up @@ -210,9 +210,10 @@ export class Fabricator {
if (apiFunction.httpsTrigger) {
apiFunction.httpsTrigger.securityLevel = "SECURE_ALWAYS";
}
apiFunction.sourceToken = await scraper.tokenPromise();
const resultFunction = await this.functionExecutor
.run(async () => {
// try to get the source token right before deploying
apiFunction.sourceToken = await scraper.getToken();
const op: { name: string } = await gcf.createFunction(apiFunction);
return poller.pollOperation<gcf.CloudFunction>({
...gcfV1PollerOptions,
Expand Down Expand Up @@ -374,9 +375,10 @@ export class Fabricator {
throw new Error("Precondition failed");
}
const apiFunction = gcf.functionFromEndpoint(endpoint, sourceUrl);
apiFunction.sourceToken = await scraper.tokenPromise();

const resultFunction = await this.functionExecutor
.run(async () => {
apiFunction.sourceToken = await scraper.getToken();
const op: { name: string } = await gcf.updateFunction(apiFunction);
return await poller.pollOperation<gcf.CloudFunction>({
...gcfV1PollerOptions,
Expand Down
51 changes: 40 additions & 11 deletions src/deploy/functions/release/sourceTokenScraper.ts
@@ -1,28 +1,55 @@
import { FirebaseError } from "../../../error";
import { assertExhaustive } from "../../../functional";
import { logger } from "../../../logger";

type TokenFetchState = "NONE" | "FETCHING" | "VALID";

/**
* GCF v1 deploys support reusing a build between function deploys.
* This class will return a resolved promise for its first call to tokenPromise()
* and then will always return a promise that is resolved by the poller function.
*/
export class SourceTokenScraper {
private firstCall = true;
private resolve!: (token: string) => void;
private tokenValidDurationMs;
private resolve!: (token?: string) => void;
private promise: Promise<string | undefined>;
private expiry: number | undefined;
private fetchState: TokenFetchState;

constructor() {
constructor(validDurationMs = 1500000) {
this.tokenValidDurationMs = validDurationMs;
this.promise = new Promise((resolve) => (this.resolve = resolve));
this.fetchState = "NONE";
}

async getToken(): Promise<string | undefined> {
if (this.fetchState === "NONE") {
this.fetchState = "FETCHING";
return undefined;
} else if (this.fetchState === "FETCHING") {
return this.promise; // wait until we get a source token
} else if (this.fetchState === "VALID") {
if (this.isTokenExpired()) {
this.fetchState = "FETCHING";
this.promise = new Promise((resolve) => (this.resolve = resolve));
return undefined;
}
return this.promise;
} else {
assertExhaustive(this.fetchState);
}
}

// Token Promise will return undefined for the first caller
// (because we presume it's this function's source token we'll scrape)
// and then returns the promise generated from the first function's onCall
tokenPromise(): Promise<string | undefined> {
if (this.firstCall) {
this.firstCall = false;
return Promise.resolve(undefined);
isTokenExpired(): boolean {
if (this.expiry === undefined) {
throw new FirebaseError(
"Your deployment is checking the expiration of a source token that has not yet been polled. " +
"Hitting this case should never happen and should be considered a bug. " +
"Please file an issue at https://github.com/firebase/firebase-tools/issues " +
"and try deploying your functions again."
);
}
return this.promise;
return Date.now() >= this.expiry;
}

get poller() {
Expand All @@ -32,6 +59,8 @@ export class SourceTokenScraper {
op.metadata?.target?.split("/") || [];
logger.debug(`Got source token ${op.metadata?.sourceToken} for region ${region as string}`);
this.resolve(op.metadata?.sourceToken);
this.fetchState = "VALID";
this.expiry = Date.now() + this.tokenValidDurationMs;
}
};
}
Expand Down
63 changes: 56 additions & 7 deletions src/test/deploy/functions/release/sourceTokenScraper.spec.ts
Expand Up @@ -2,23 +2,23 @@ import { expect } from "chai";

import { SourceTokenScraper } from "../../../../deploy/functions/release/sourceTokenScraper";

describe("SourcTokenScraper", () => {
describe("SourceTokenScraper", () => {
it("immediately provides the first result", async () => {
const scraper = new SourceTokenScraper();
await expect(scraper.tokenPromise()).to.eventually.be.undefined;
await expect(scraper.getToken()).to.eventually.be.undefined;
});

it("provides results after the firt operation completes", async () => {
it("provides results after the first operation completes", async () => {
const scraper = new SourceTokenScraper();
// First result comes right away;
await expect(scraper.tokenPromise()).to.eventually.be.undefined;
await expect(scraper.getToken()).to.eventually.be.undefined;

let gotResult = false;
const timeout = new Promise((resolve, reject) => {
setTimeout(() => reject(new Error("Timeout")), 10);
});
const getResult = (async () => {
await scraper.tokenPromise();
await scraper.getToken();
gotResult = true;
})();
await expect(Promise.race([getResult, timeout])).to.be.rejectedWith("Timeout");
Expand All @@ -31,14 +31,63 @@ describe("SourcTokenScraper", () => {
it("provides tokens from an operation", async () => {
const scraper = new SourceTokenScraper();
// First result comes right away
await expect(scraper.tokenPromise()).to.eventually.be.undefined;
await expect(scraper.getToken()).to.eventually.be.undefined;

scraper.poller({
metadata: {
sourceToken: "magic token",
target: "projects/p/locations/l/functions/f",
},
});
await expect(scraper.tokenPromise()).to.eventually.equal("magic token");
await expect(scraper.getToken()).to.eventually.equal("magic token");
});

it("refreshes token after timer expires", async () => {
const scraper = new SourceTokenScraper(10);
await expect(scraper.getToken()).to.eventually.be.undefined;
scraper.poller({
metadata: {
sourceToken: "magic token",
target: "projects/p/locations/l/functions/f",
},
});
await expect(scraper.getToken()).to.eventually.equal("magic token");
const timeout = (duration: number): Promise<void> => {
return new Promise<void>((resolve) => setTimeout(resolve, duration));
};
await timeout(50);
await expect(scraper.getToken()).to.eventually.be.undefined;
scraper.poller({
metadata: {
sourceToken: "magic token #2",
target: "projects/p/locations/l/functions/f",
},
});
await expect(scraper.getToken()).to.eventually.equal("magic token #2");
});

it("concurrent requests for source token", async () => {
const scraper = new SourceTokenScraper();

const promises = [];
for (let i = 0; i < 3; i++) {
promises.push(scraper.getToken());
}
scraper.poller({
metadata: {
sourceToken: "magic token",
target: "projects/p/locations/l/functions/f",
},
});

let successes = 0;
const tokens = await Promise.all(promises);
for (const tok of tokens) {
if (tok === "magic token") {
successes++;
}
}
expect(tokens.includes(undefined)).to.be.true;
expect(successes).to.equal(2);
});
});

0 comments on commit 98e23ed

Please sign in to comment.