Skip to content

Commit 885bf1b

Browse files
authored
Merge pull request #26 from jeskew/feature/credential-provider-imds
Feature/credential provider imds
2 parents bcd9316 + 807c292 commit 885bf1b

17 files changed

+865
-0
lines changed
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
/node_modules/
2+
*.js
3+
*.js.map
4+
*.d.ts
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
import {
2+
ENV_CMDS_AUTH_TOKEN,
3+
ENV_CMDS_FULL_URI,
4+
ENV_CMDS_RELATIVE_URI,
5+
fromContainerMetadata
6+
} from "../lib/fromContainerMetadata";
7+
import {httpGet} from "../lib/remoteProvider/httpGet";
8+
import {
9+
fromImdsCredentials,
10+
ImdsCredentials
11+
} from "../lib/remoteProvider/ImdsCredentials";
12+
import MockInstance = jest.MockInstance;
13+
import {RequestOptions} from "http";
14+
15+
interface HttpGet {
16+
(options: RequestOptions): Promise<Buffer>;
17+
}
18+
19+
const mockHttpGet = <MockInstance<HttpGet>><any>httpGet;
20+
jest.mock('../lib/remoteProvider/httpGet', () => ({httpGet: jest.fn()}));
21+
22+
const relativeUri = process.env[ENV_CMDS_RELATIVE_URI];
23+
const fullUri = process.env[ENV_CMDS_FULL_URI];
24+
const authToken = process.env[ENV_CMDS_AUTH_TOKEN];
25+
26+
beforeEach(() => {
27+
mockHttpGet.mockReset();
28+
delete process.env[ENV_CMDS_RELATIVE_URI];
29+
delete process.env[ENV_CMDS_FULL_URI];
30+
delete process.env[ENV_CMDS_AUTH_TOKEN];
31+
});
32+
33+
afterAll(() => {
34+
process.env[ENV_CMDS_RELATIVE_URI] = relativeUri;
35+
process.env[ENV_CMDS_FULL_URI] = fullUri;
36+
process.env[ENV_CMDS_AUTH_TOKEN] = authToken;
37+
});
38+
39+
describe('fromContainerMetadata', () => {
40+
const creds: ImdsCredentials = Object.freeze({
41+
AccessKeyId: 'foo',
42+
SecretAccessKey: 'bar',
43+
Token: 'baz',
44+
Expiration: new Date().toISOString(),
45+
});
46+
47+
it(
48+
'should reject the promise with a terminal error if the container credentials environment variable is not set',
49+
async () => {
50+
await fromContainerMetadata()().then(
51+
() => { throw new Error('The promise should have been rejected'); },
52+
err => {
53+
expect((err as any).tryNextLink).toBeFalsy();
54+
}
55+
);
56+
}
57+
);
58+
59+
it(
60+
`should inject an authorization header containing the contents of the ${ENV_CMDS_AUTH_TOKEN} environment variable if defined`,
61+
async () => {
62+
const token = 'Basic abcd';
63+
process.env[ENV_CMDS_FULL_URI] = 'http://localhost:8080/path';
64+
process.env[ENV_CMDS_AUTH_TOKEN] = token;
65+
mockHttpGet.mockReturnValue(Promise.resolve(JSON.stringify(creds)));
66+
67+
await fromContainerMetadata()();
68+
69+
expect(mockHttpGet.mock.calls.length).toBe(1);
70+
const [options = {}] = mockHttpGet.mock.calls[0];
71+
expect(options.headers).toMatchObject({
72+
Authorization: token,
73+
});
74+
}
75+
);
76+
77+
describe(ENV_CMDS_RELATIVE_URI, () => {
78+
beforeEach(() => {
79+
process.env[ENV_CMDS_RELATIVE_URI] = '/relative/uri';
80+
});
81+
82+
it(
83+
'should resolve credentials by fetching them from the container metadata service',
84+
async () => {
85+
mockHttpGet.mockReturnValue(
86+
Promise.resolve(JSON.stringify(creds))
87+
);
88+
89+
expect(await fromContainerMetadata()())
90+
.toEqual(fromImdsCredentials(creds));
91+
}
92+
);
93+
94+
it(
95+
'should retry the fetching operation up to maxRetries times',
96+
async () => {
97+
const maxRetries = 5;
98+
for (let i = 0; i < maxRetries - 1; i++) {
99+
mockHttpGet.mockReturnValueOnce(Promise.reject('No!'));
100+
}
101+
mockHttpGet.mockReturnValueOnce(
102+
Promise.resolve(JSON.stringify(creds))
103+
);
104+
105+
expect(await fromContainerMetadata({maxRetries})())
106+
.toEqual(fromImdsCredentials(creds));
107+
expect(mockHttpGet.mock.calls.length).toEqual(maxRetries);
108+
}
109+
);
110+
111+
it(
112+
'should retry responses that receive invalid response values',
113+
async () => {
114+
for (let key of Object.keys(creds)) {
115+
const invalidCreds: any = {...creds};
116+
delete invalidCreds[key];
117+
mockHttpGet.mockReturnValueOnce(
118+
Promise.resolve(JSON.stringify(invalidCreds))
119+
);
120+
}
121+
mockHttpGet.mockReturnValueOnce(
122+
Promise.resolve(JSON.stringify(creds))
123+
);
124+
125+
await fromContainerMetadata({maxRetries: 100})();
126+
expect(mockHttpGet.mock.calls.length)
127+
.toEqual(Object.keys(creds).length + 1);
128+
}
129+
);
130+
131+
it('should pass relevant configuration to httpGet', async () => {
132+
const timeout = Math.ceil(Math.random() * 1000);
133+
mockHttpGet.mockReturnValue(Promise.resolve(JSON.stringify(creds)));
134+
await fromContainerMetadata({timeout})();
135+
expect(mockHttpGet.mock.calls.length).toEqual(1);
136+
expect(mockHttpGet.mock.calls[0][0]).toEqual({
137+
hostname: '169.254.170.2',
138+
path: process.env[ENV_CMDS_RELATIVE_URI],
139+
timeout,
140+
});
141+
});
142+
});
143+
144+
describe(ENV_CMDS_FULL_URI, () => {
145+
it('should pass relevant configuration to httpGet', async () => {
146+
process.env[ENV_CMDS_FULL_URI] = 'http://localhost:8080/path';
147+
148+
const timeout = Math.ceil(Math.random() * 1000);
149+
mockHttpGet.mockReturnValue(Promise.resolve(JSON.stringify(creds)));
150+
await fromContainerMetadata({timeout})();
151+
expect(mockHttpGet.mock.calls.length).toEqual(1);
152+
const {
153+
protocol,
154+
hostname,
155+
path,
156+
port,
157+
timeout: actualTimeout,
158+
} = mockHttpGet.mock.calls[0][0];
159+
expect(protocol).toBe('http:');
160+
expect(hostname).toBe('localhost');
161+
expect(path).toBe('/path');
162+
expect(port).toBe(8080);
163+
expect(actualTimeout).toBe(timeout);
164+
});
165+
166+
it(
167+
`should prefer ${ENV_CMDS_RELATIVE_URI} to ${ENV_CMDS_FULL_URI}`,
168+
async () => {
169+
process.env[ENV_CMDS_RELATIVE_URI] = 'foo';
170+
process.env[ENV_CMDS_FULL_URI] = 'http://localhost:8080/path';
171+
172+
const timeout = Math.ceil(Math.random() * 1000);
173+
mockHttpGet.mockReturnValue(
174+
Promise.resolve(JSON.stringify(creds))
175+
);
176+
await fromContainerMetadata({timeout})();
177+
expect(mockHttpGet.mock.calls.length).toEqual(1);
178+
expect(mockHttpGet.mock.calls[0][0]).toEqual({
179+
hostname: '169.254.170.2',
180+
path: 'foo',
181+
timeout,
182+
});
183+
}
184+
);
185+
186+
it(
187+
'should reject the promise with a terminal error if a unexpected protocol is specified',
188+
async () => {
189+
process.env[ENV_CMDS_FULL_URI] = 'wss://localhost:8080/path';
190+
191+
await fromContainerMetadata()().then(
192+
() => {
193+
throw new Error('The promise should have been rejected');
194+
},
195+
err => {
196+
expect((err as any).tryNextLink).toBeFalsy();
197+
}
198+
);
199+
}
200+
);
201+
202+
it(
203+
'should reject the promise with a terminal error if a unexpected hostname is specified',
204+
async () => {
205+
process.env[ENV_CMDS_FULL_URI] = 'https://bucket.s3.amazonaws.com/key';
206+
207+
await fromContainerMetadata()().then(
208+
() => {
209+
throw new Error('The promise should have been rejected');
210+
},
211+
err => {
212+
expect((err as any).tryNextLink).toBeFalsy();
213+
}
214+
);
215+
}
216+
);
217+
});
218+
});
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import {fromInstanceMetadata} from "../lib/fromInstanceMetadata";
2+
import {httpGet} from "../lib/remoteProvider/httpGet";
3+
import {
4+
fromImdsCredentials,
5+
ImdsCredentials
6+
} from "../lib/remoteProvider/ImdsCredentials";
7+
import MockInstance = jest.MockInstance;
8+
import {RequestOptions} from "http";
9+
10+
interface HttpGet {
11+
(options: RequestOptions): Promise<Buffer>;
12+
}
13+
14+
const mockHttpGet = <MockInstance<HttpGet>><any>httpGet;
15+
jest.mock('../lib/remoteProvider/httpGet', () => ({httpGet: jest.fn()}));
16+
17+
beforeEach(() => {
18+
mockHttpGet.mockReset();
19+
});
20+
21+
describe('fromInstanceMetadata', () => {
22+
const creds: ImdsCredentials = Object.freeze({
23+
AccessKeyId: 'foo',
24+
SecretAccessKey: 'bar',
25+
Token: 'baz',
26+
Expiration: new Date().toISOString(),
27+
});
28+
29+
it(
30+
'should resolve credentials by fetching them from the container metadata service',
31+
async () => {
32+
mockHttpGet.mockReturnValue(Promise.resolve(JSON.stringify(creds)));
33+
expect(await fromInstanceMetadata({profile: 'foo'})())
34+
.toEqual(fromImdsCredentials(creds));
35+
}
36+
);
37+
38+
it('should retry the fetching operation up to maxRetries times', async () => {
39+
const maxRetries = 5;
40+
for (let i = 0; i < maxRetries - 1; i++) {
41+
mockHttpGet.mockReturnValueOnce(Promise.reject('No!'));
42+
}
43+
mockHttpGet.mockReturnValueOnce(
44+
Promise.resolve(JSON.stringify(creds))
45+
);
46+
47+
expect(await fromInstanceMetadata({maxRetries, profile: 'foo'})())
48+
.toEqual(fromImdsCredentials(creds));
49+
expect(mockHttpGet.mock.calls.length).toEqual(maxRetries);
50+
});
51+
52+
it('should retry responses that receive invalid response values', async () => {
53+
for (let key of Object.keys(creds)) {
54+
const invalidCreds: any = {...creds};
55+
delete invalidCreds[key];
56+
mockHttpGet.mockReturnValueOnce(
57+
Promise.resolve(JSON.stringify(invalidCreds))
58+
);
59+
}
60+
mockHttpGet.mockReturnValueOnce(Promise.resolve(JSON.stringify(creds)));
61+
62+
await fromInstanceMetadata({maxRetries: 100, profile: 'foo'})();
63+
expect(mockHttpGet.mock.calls.length)
64+
.toEqual(Object.keys(creds).length + 1);
65+
});
66+
67+
it('should pass relevant configuration to httpGet', async () => {
68+
const timeout = Math.ceil(Math.random() * 1000);
69+
const profile = 'foo-profile';
70+
mockHttpGet.mockReturnValue(Promise.resolve(JSON.stringify(creds)));
71+
await fromInstanceMetadata({timeout, profile})();
72+
expect(mockHttpGet.mock.calls.length).toEqual(1);
73+
expect(mockHttpGet.mock.calls[0][0]).toEqual({
74+
host: '169.254.169.254',
75+
path: `/latest/meta-data/iam/security-credentials/${profile}`,
76+
timeout,
77+
});
78+
});
79+
80+
it('should fetch the profile name if not supplied', async () => {
81+
const defaultTimeout = 1000;
82+
const profile = 'foo-profile';
83+
mockHttpGet.mockReturnValueOnce(Promise.resolve(profile));
84+
mockHttpGet.mockReturnValueOnce(Promise.resolve(JSON.stringify(creds)));
85+
86+
await fromInstanceMetadata()();
87+
expect(mockHttpGet.mock.calls.length).toEqual(2);
88+
expect(mockHttpGet.mock.calls[0][0]).toEqual({
89+
host: '169.254.169.254',
90+
path: '/latest/meta-data/iam/security-credentials/',
91+
timeout: defaultTimeout,
92+
});
93+
expect(mockHttpGet.mock.calls[1][0]).toEqual({
94+
host: '169.254.169.254',
95+
path: `/latest/meta-data/iam/security-credentials/${profile}`,
96+
timeout: defaultTimeout,
97+
});
98+
});
99+
100+
it('should retry the profile name fetch as necessary', async () => {
101+
const defaultTimeout = 1000;
102+
const profile = 'foo-profile';
103+
mockHttpGet.mockReturnValueOnce(Promise.reject('Too busy'));
104+
mockHttpGet.mockReturnValueOnce(Promise.resolve(profile));
105+
mockHttpGet.mockReturnValueOnce(Promise.resolve(JSON.stringify(creds)));
106+
107+
await fromInstanceMetadata({maxRetries: 1})();
108+
expect(mockHttpGet.mock.calls.length).toEqual(3);
109+
expect(mockHttpGet.mock.calls[2][0]).toEqual({
110+
host: '169.254.169.254',
111+
path: `/latest/meta-data/iam/security-credentials/${profile}`,
112+
timeout: defaultTimeout,
113+
});
114+
for (let index of [0, 1]) {
115+
expect(mockHttpGet.mock.calls[index][0]).toEqual({
116+
host: '169.254.169.254',
117+
path: '/latest/meta-data/iam/security-credentials/',
118+
timeout: defaultTimeout,
119+
});
120+
}
121+
});
122+
});

0 commit comments

Comments
 (0)