Skip to content

Commit 2cfa51a

Browse files
authored
Discovering project ID from the local metadata service (#755)
* Discovering project ID from the local metadata service * Minor refactoring for variable names and comments
1 parent e953b34 commit 2cfa51a

File tree

6 files changed

+129
-29
lines changed

6 files changed

+129
-29
lines changed

src/auth/credential.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,7 @@ export class ComputeEngineCredential implements Credential {
198198

199199
private readonly httpClient = new HttpClient();
200200
private readonly httpAgent?: Agent;
201+
private projectId?: string;
201202

202203
constructor(httpAgent?: Agent) {
203204
this.httpAgent = httpAgent;
@@ -209,10 +210,21 @@ export class ComputeEngineCredential implements Credential {
209210
}
210211

211212
public getProjectId(): Promise<string> {
213+
if (this.projectId) {
214+
return Promise.resolve(this.projectId);
215+
}
216+
212217
const request = this.buildRequest(GOOGLE_METADATA_SERVICE_PROJECT_ID_PATH);
213218
return this.httpClient.send(request)
214219
.then((resp) => {
215-
return resp.text!;
220+
this.projectId = resp.text!;
221+
return this.projectId;
222+
})
223+
.catch((err) => {
224+
const detail: string = (err instanceof HttpError) ? getDetailFromResponse(err.response) : err.message;
225+
throw new FirebaseAppError(
226+
AppErrorCodes.INVALID_CREDENTIAL,
227+
`Failed to determine project ID: ${detail}`);
216228
});
217229
}
218230

src/firestore/firestore.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ export function getFirestoreOptions(app: FirebaseApp): Settings {
7171
});
7272
}
7373

74-
const projectId: string | null = utils.getProjectId(app);
74+
const projectId: string | null = utils.getExplicitProjectId(app);
7575
const credential = app.options.credential;
7676
const { version: firebaseVersion } = require('../../package.json');
7777
if (credential instanceof ServiceAccountCredential) {
@@ -80,8 +80,8 @@ export function getFirestoreOptions(app: FirebaseApp): Settings {
8080
private_key: credential.privateKey,
8181
client_email: credential.clientEmail,
8282
},
83-
// When the SDK is initialized with ServiceAccountCredentials projectId is guaranteed to
84-
// be available.
83+
// When the SDK is initialized with ServiceAccountCredentials an explicit projectId is
84+
// guaranteed to be available.
8585
projectId: projectId!,
8686
firebaseVersion,
8787
};

src/storage/storage.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,12 +71,12 @@ export class Storage implements FirebaseServiceInterface {
7171
});
7272
}
7373

74-
const projectId: string | null = utils.getProjectId(app);
74+
const projectId: string | null = utils.getExplicitProjectId(app);
7575
const credential = app.options.credential;
7676
if (credential instanceof ServiceAccountCredential) {
7777
this.storageClient = new storage({
78-
// When the SDK is initialized with ServiceAccountCredentials projectId is guaranteed to
79-
// be available.
78+
// When the SDK is initialized with ServiceAccountCredentials an explicit projectId is
79+
// guaranteed to be available.
8080
projectId: projectId!,
8181
credentials: {
8282
private_key: credential.privateKey,

src/utils/index.ts

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
*/
1616

1717
import {FirebaseApp, FirebaseAppOptions} from '../firebase-app';
18-
import {ServiceAccountCredential} from '../auth/credential';
18+
import {ServiceAccountCredential, ComputeEngineCredential} from '../auth/credential';
1919

2020
import * as validator from './validator';
2121

@@ -56,14 +56,15 @@ export function addReadonlyGetter(obj: object, prop: string, value: any): void {
5656
}
5757

5858
/**
59-
* Determines the Google Cloud project ID associated with a Firebase app by examining
60-
* the Firebase app options, credentials and the local environment in that order.
59+
* Returns the Google Cloud project ID associated with a Firebase app, if it's explicitly
60+
* specified in either the Firebase app options, credentials or the local environment.
61+
* Otherwise returns null.
6162
*
6263
* @param {FirebaseApp} app A Firebase app to get the project ID from.
6364
*
6465
* @return {string} A project ID string or null.
6566
*/
66-
export function getProjectId(app: FirebaseApp): string | null {
67+
export function getExplicitProjectId(app: FirebaseApp): string | null {
6768
const options: FirebaseAppOptions = app.options;
6869
if (validator.isNonEmptyString(options.projectId)) {
6970
return options.projectId;
@@ -82,17 +83,28 @@ export function getProjectId(app: FirebaseApp): string | null {
8283
}
8384

8485
/**
85-
* Determines the Google Cloud project ID associated with a Firebase app by examining
86-
* the Firebase app options, credentials and the local environment in that order. This
87-
* is an async wrapper of the getProjectId method. This enables us to migrate the rest
88-
* of the SDK into asynchronously determining the current project ID. See b/143090254.
86+
* Determines the Google Cloud project ID associated with a Firebase app. This method
87+
* first checks if a project ID is explicitly specified in either the Firebase app options,
88+
* credentials or the local environment in that order. If no explicit project ID is
89+
* configured, but the SDK has been initialized with ComputeEngineCredentials, this
90+
* method attempts to discover the project ID from the local metadata service.
8991
*
9092
* @param {FirebaseApp} app A Firebase app to get the project ID from.
9193
*
9294
* @return {Promise<string | null>} A project ID string or null.
9395
*/
9496
export function findProjectId(app: FirebaseApp): Promise<string | null> {
95-
return Promise.resolve(getProjectId(app));
97+
const projectId = getExplicitProjectId(app);
98+
if (projectId) {
99+
return Promise.resolve(projectId);
100+
}
101+
102+
const credential = app.options.credential;
103+
if (credential instanceof ComputeEngineCredential) {
104+
return credential.getProjectId();
105+
}
106+
107+
return Promise.resolve(null);
96108
}
97109

98110
/**

test/unit/auth/credential.spec.ts

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import {
3636
} from '../../../src/auth/credential';
3737
import { HttpClient } from '../../../src/utils/api-request';
3838
import {Agent} from 'https';
39+
import { FirebaseAppError } from '../../../src/utils/error';
3940

4041
chai.should();
4142
chai.use(sinonChai);
@@ -313,13 +314,13 @@ describe('Credential', () => {
313314
});
314315

315316
it('should discover project id', () => {
316-
const expected = 'test-project-id';
317-
const response = utils.responseFrom(expected);
317+
const expectedProjectId = 'test-project-id';
318+
const response = utils.responseFrom(expectedProjectId);
318319
httpStub.resolves(response);
319320

320321
const c = new ComputeEngineCredential();
321322
return c.getProjectId().then((projectId) => {
322-
expect(projectId).to.equal(expected);
323+
expect(projectId).to.equal(expectedProjectId);
323324
expect(httpStub).to.have.been.calledOnce.and.calledWith({
324325
method: 'GET',
325326
url: 'http://metadata.google.internal/computeMetadata/v1/project/project-id',
@@ -328,6 +329,47 @@ describe('Credential', () => {
328329
});
329330
});
330331
});
332+
333+
it('should cache discovered project id', () => {
334+
const expectedProjectId = 'test-project-id';
335+
const response = utils.responseFrom(expectedProjectId);
336+
httpStub.resolves(response);
337+
338+
const c = new ComputeEngineCredential();
339+
return c.getProjectId()
340+
.then((projectId) => {
341+
expect(projectId).to.equal(expectedProjectId);
342+
return c.getProjectId();
343+
})
344+
.then((projectId) => {
345+
expect(projectId).to.equal(expectedProjectId);
346+
expect(httpStub).to.have.been.calledOnce.and.calledWith({
347+
method: 'GET',
348+
url: 'http://metadata.google.internal/computeMetadata/v1/project/project-id',
349+
headers: {'Metadata-Flavor': 'Google'},
350+
httpAgent: undefined,
351+
});
352+
});
353+
});
354+
355+
it('should reject when the metadata service is not available', () => {
356+
httpStub.rejects(new FirebaseAppError('network-error', 'Failed to connect'));
357+
358+
const c = new ComputeEngineCredential();
359+
return c.getProjectId().should.eventually
360+
.rejectedWith('Failed to determine project ID: Failed to connect')
361+
.and.have.property('code', 'app/invalid-credential');
362+
});
363+
364+
it('should reject when the metadata service responds with an error', () => {
365+
const response = utils.errorFrom('Unexpected error');
366+
httpStub.rejects(response);
367+
368+
const c = new ComputeEngineCredential();
369+
return c.getProjectId().should.eventually
370+
.rejectedWith('Failed to determine project ID: Unexpected error')
371+
.and.have.property('code', 'app/invalid-credential');
372+
});
331373
});
332374

333375
describe('getApplicationDefault()', () => {

test/unit/utils/index.spec.ts

Lines changed: 44 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,19 @@
1616

1717
import * as _ from 'lodash';
1818
import {expect} from 'chai';
19+
import * as sinon from 'sinon';
1920

2021
import * as mocks from '../../resources/mocks';
2122
import {
22-
addReadonlyGetter, getProjectId, findProjectId, toWebSafeBase64, formatString, generateUpdateMask,
23+
addReadonlyGetter, getExplicitProjectId, findProjectId,
24+
toWebSafeBase64, formatString, generateUpdateMask,
2325
} from '../../../src/utils/index';
2426
import {isNonEmptyString} from '../../../src/utils/validator';
2527
import {FirebaseApp, FirebaseAppOptions} from '../../../src/firebase-app';
28+
import { ComputeEngineCredential } from '../../../src/auth/credential';
29+
import { HttpClient } from '../../../src/utils/api-request';
30+
import * as utils from '../utils';
31+
import { FirebaseAppError } from '../../../src/utils/error';
2632

2733
interface Obj {
2834
[key: string]: any;
@@ -66,7 +72,7 @@ describe('toWebSafeBase64()', () => {
6672
});
6773
});
6874

69-
describe('getProjectId()', () => {
75+
describe('getExplicitProjectId()', () => {
7076
let googleCloudProject: string | undefined;
7177
let gcloudProject: string | undefined;
7278

@@ -95,37 +101,38 @@ describe('getProjectId()', () => {
95101
projectId: 'explicit-project-id',
96102
};
97103
const app: FirebaseApp = mocks.appWithOptions(options);
98-
expect(getProjectId(app)).to.equal(options.projectId);
104+
expect(getExplicitProjectId(app)).to.equal(options.projectId);
99105
});
100106

101107
it('should return the project ID from service account', () => {
102108
const app: FirebaseApp = mocks.app();
103-
expect(getProjectId(app)).to.equal('project_id');
109+
expect(getExplicitProjectId(app)).to.equal('project_id');
104110
});
105111

106112
it('should return the project ID set in GOOGLE_CLOUD_PROJECT environment variable', () => {
107113
process.env.GOOGLE_CLOUD_PROJECT = 'env-var-project-id';
108114
const app: FirebaseApp = mocks.mockCredentialApp();
109-
expect(getProjectId(app)).to.equal('env-var-project-id');
115+
expect(getExplicitProjectId(app)).to.equal('env-var-project-id');
110116
});
111117

112118
it('should return the project ID set in GCLOUD_PROJECT environment variable', () => {
113119
process.env.GCLOUD_PROJECT = 'env-var-project-id';
114120
const app: FirebaseApp = mocks.mockCredentialApp();
115-
expect(getProjectId(app)).to.equal('env-var-project-id');
121+
expect(getExplicitProjectId(app)).to.equal('env-var-project-id');
116122
});
117123

118124
it('should return null when project ID is not set', () => {
119125
delete process.env.GOOGLE_CLOUD_PROJECT;
120126
delete process.env.GCLOUD_PROJECT;
121127
const app: FirebaseApp = mocks.mockCredentialApp();
122-
expect(getProjectId(app)).to.be.null;
128+
expect(getExplicitProjectId(app)).to.be.null;
123129
});
124130
});
125131

126132
describe('findProjectId()', () => {
127133
let googleCloudProject: string | undefined;
128134
let gcloudProject: string | undefined;
135+
let httpStub: sinon.SinonStub;
129136

130137
before(() => {
131138
googleCloudProject = process.env.GOOGLE_CLOUD_PROJECT;
@@ -146,6 +153,16 @@ describe('findProjectId()', () => {
146153
}
147154
});
148155

156+
beforeEach(() => {
157+
delete process.env.GOOGLE_CLOUD_PROJECT;
158+
delete process.env.GCLOUD_PROJECT;
159+
httpStub = sinon.stub(HttpClient.prototype, 'send');
160+
});
161+
162+
afterEach(() => {
163+
httpStub.restore();
164+
});
165+
149166
it('should return the explicitly specified project ID from app options', () => {
150167
const options: FirebaseAppOptions = {
151168
credential: new mocks.MockCredential(),
@@ -172,9 +189,26 @@ describe('findProjectId()', () => {
172189
return findProjectId(app).should.eventually.equal('env-var-project-id');
173190
});
174191

175-
it('should return null when project ID is not set', () => {
176-
delete process.env.GOOGLE_CLOUD_PROJECT;
177-
delete process.env.GCLOUD_PROJECT;
192+
it('should return the project ID discovered from the metadata service', () => {
193+
const expectedProjectId = 'test-project-id';
194+
const response = utils.responseFrom(expectedProjectId);
195+
httpStub.resolves(response);
196+
const app: FirebaseApp = mocks.appWithOptions({
197+
credential: new ComputeEngineCredential(),
198+
});
199+
return findProjectId(app).should.eventually.equal(expectedProjectId);
200+
});
201+
202+
it('should reject when the metadata service is not available', () => {
203+
httpStub.rejects(new FirebaseAppError('network-error', 'Failed to connect'));
204+
const app: FirebaseApp = mocks.appWithOptions({
205+
credential: new ComputeEngineCredential(),
206+
});
207+
return findProjectId(app).should.eventually
208+
.rejectedWith('Failed to determine project ID: Failed to connect');
209+
});
210+
211+
it('should return null when project ID is not set and discoverable', () => {
178212
const app: FirebaseApp = mocks.mockCredentialApp();
179213
return findProjectId(app).should.eventually.be.null;
180214
});

0 commit comments

Comments
 (0)