Skip to content

Commit 0a29eef

Browse files
committed
Add support for generic ECS provider
1 parent 90b3b53 commit 0a29eef

File tree

3 files changed

+218
-62
lines changed

3 files changed

+218
-62
lines changed

packages/credential-provider/__tests__/chain.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,30 @@ describe('chain', () => {
3838
expect(providers[2].mock.calls.length).toBe(0);
3939
});
4040

41+
it(
42+
'should not invoke subsequent providers one is rejected with a terminal error',
43+
async () => {
44+
const creds = {accessKeyId: 'foo', secretAccessKey: 'bar'};
45+
const providers = [
46+
jest.fn(() => Promise.reject(new CredentialError('Move along'))),
47+
jest.fn(() => Promise.reject(
48+
new CredentialError('Stop here', false)
49+
)),
50+
jest.fn(() => fail('This provider should not be invoked'))
51+
];
52+
53+
await chain(...providers)().then(
54+
() => { throw new Error('The promise should have been rejected'); },
55+
err => {
56+
expect(err.message).toBe('Stop here');
57+
expect(providers[0].mock.calls.length).toBe(1);
58+
expect(providers[1].mock.calls.length).toBe(1);
59+
expect(providers[2].mock.calls.length).toBe(0);
60+
}
61+
);
62+
}
63+
);
64+
4165
it('should reject chains with no links', async () => {
4266
await chain()().then(
4367
() => { throw new Error('The promise should have been rejected'); },
Lines changed: 135 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {
2+
ENV_CMDS_FULL_URI,
23
ENV_CMDS_RELATIVE_URI,
34
fromContainerMetadata
45
} from "../lib/fromContainerMetadata";
@@ -18,14 +19,17 @@ const mockHttpGet = <MockInstance<HttpGet>><any>httpGet;
1819
jest.mock('../lib/remoteProvider/httpGet', () => ({httpGet: jest.fn()}));
1920

2021
const relativeUri = process.env[ENV_CMDS_RELATIVE_URI];
22+
const fullUri = process.env[ENV_CMDS_FULL_URI];
2123

2224
beforeEach(() => {
2325
mockHttpGet.mockReset();
24-
process.env[ENV_CMDS_RELATIVE_URI] = '/relative/uri';
26+
delete process.env[ENV_CMDS_RELATIVE_URI];
27+
delete process.env[ENV_CMDS_FULL_URI];
2528
});
2629

2730
afterAll(() => {
2831
process.env[ENV_CMDS_RELATIVE_URI] = relativeUri;
32+
process.env[ENV_CMDS_FULL_URI] = fullUri;
2933
});
3034

3135
describe('fromContainerMetadata', () => {
@@ -37,9 +41,8 @@ describe('fromContainerMetadata', () => {
3741
});
3842

3943
it(
40-
'should reject the promise if the container credentials environment variable is not set',
44+
'should reject the promise with a terminal error if the container credentials environment variable is not set',
4145
async () => {
42-
delete process.env[ENV_CMDS_RELATIVE_URI];
4346
await fromContainerMetadata()().then(
4447
() => { throw new Error('The promise should have been rejected'); },
4548
err => {
@@ -49,53 +52,142 @@ describe('fromContainerMetadata', () => {
4952
}
5053
);
5154

52-
it(
53-
'should resolve credentials by fetching them from the container metadata service',
54-
async () => {
55-
mockHttpGet.mockReturnValue(Promise.resolve(JSON.stringify(creds)));
56-
expect(await fromContainerMetadata()())
57-
.toEqual(fromImdsCredentials(creds));
58-
}
59-
);
55+
describe(ENV_CMDS_RELATIVE_URI, () => {
56+
beforeEach(() => {
57+
process.env[ENV_CMDS_RELATIVE_URI] = '/relative/uri';
58+
});
6059

61-
it('should retry the fetching operation up to maxRetries times', async () => {
62-
const maxRetries = 5;
63-
for (let i = 0; i < maxRetries - 1; i++) {
64-
mockHttpGet.mockReturnValueOnce(Promise.reject('No!'));
65-
}
66-
mockHttpGet.mockReturnValueOnce(
67-
Promise.resolve(JSON.stringify(creds))
60+
it(
61+
'should resolve credentials by fetching them from the container metadata service',
62+
async () => {
63+
mockHttpGet.mockReturnValue(Promise.resolve(JSON.stringify(creds)));
64+
expect(await fromContainerMetadata()())
65+
.toEqual(fromImdsCredentials(creds));
66+
}
6867
);
6968

70-
expect(await fromContainerMetadata({maxRetries})())
71-
.toEqual(fromImdsCredentials(creds));
72-
expect(mockHttpGet.mock.calls.length).toEqual(maxRetries);
73-
});
69+
it(
70+
'should retry the fetching operation up to maxRetries times',
71+
async () => {
72+
const maxRetries = 5;
73+
for (let i = 0; i < maxRetries - 1; i++) {
74+
mockHttpGet.mockReturnValueOnce(Promise.reject('No!'));
75+
}
76+
mockHttpGet.mockReturnValueOnce(
77+
Promise.resolve(JSON.stringify(creds))
78+
);
7479

75-
it('should retry responses that receive invalid response values', async () => {
76-
for (let key of Object.keys(creds)) {
77-
const invalidCreds: any = {...creds};
78-
delete invalidCreds[key];
79-
mockHttpGet.mockReturnValueOnce(
80-
Promise.resolve(JSON.stringify(invalidCreds))
81-
);
82-
}
83-
mockHttpGet.mockReturnValueOnce(Promise.resolve(JSON.stringify(creds)));
80+
expect(await fromContainerMetadata({maxRetries})())
81+
.toEqual(fromImdsCredentials(creds));
82+
expect(mockHttpGet.mock.calls.length).toEqual(maxRetries);
83+
}
84+
);
8485

85-
await fromContainerMetadata({maxRetries: 100})();
86-
expect(mockHttpGet.mock.calls.length)
87-
.toEqual(Object.keys(creds).length + 1);
86+
it(
87+
'should retry responses that receive invalid response values',
88+
async () => {
89+
for (let key of Object.keys(creds)) {
90+
const invalidCreds: any = {...creds};
91+
delete invalidCreds[key];
92+
mockHttpGet.mockReturnValueOnce(
93+
Promise.resolve(JSON.stringify(invalidCreds))
94+
);
95+
}
96+
mockHttpGet.mockReturnValueOnce(
97+
Promise.resolve(JSON.stringify(creds))
98+
);
99+
100+
await fromContainerMetadata({maxRetries: 100})();
101+
expect(mockHttpGet.mock.calls.length)
102+
.toEqual(Object.keys(creds).length + 1);
103+
}
104+
);
105+
106+
it('should pass relevant configuration to httpGet', async () => {
107+
const timeout = Math.ceil(Math.random() * 1000);
108+
mockHttpGet.mockReturnValue(Promise.resolve(JSON.stringify(creds)));
109+
await fromContainerMetadata({timeout})();
110+
expect(mockHttpGet.mock.calls.length).toEqual(1);
111+
expect(mockHttpGet.mock.calls[0][0]).toEqual({
112+
hostname: '169.254.170.2',
113+
path: process.env[ENV_CMDS_RELATIVE_URI],
114+
timeout,
115+
});
116+
});
88117
});
89118

90-
it('should pass relevant configuration to httpGet', async () => {
91-
const timeout = Math.ceil(Math.random() * 1000);
92-
mockHttpGet.mockReturnValue(Promise.resolve(JSON.stringify(creds)));
93-
await fromContainerMetadata({timeout})();
94-
expect(mockHttpGet.mock.calls.length).toEqual(1);
95-
expect(mockHttpGet.mock.calls[0][0]).toEqual({
96-
host: '169.254.170.2',
97-
path: process.env[ENV_CMDS_RELATIVE_URI],
98-
timeout,
119+
describe(ENV_CMDS_FULL_URI, () => {
120+
it('should pass relevant configuration to httpGet', async () => {
121+
process.env[ENV_CMDS_FULL_URI] = 'http://localhost:8080/path';
122+
123+
const timeout = Math.ceil(Math.random() * 1000);
124+
mockHttpGet.mockReturnValue(Promise.resolve(JSON.stringify(creds)));
125+
await fromContainerMetadata({timeout})();
126+
expect(mockHttpGet.mock.calls.length).toEqual(1);
127+
const {
128+
protocol,
129+
hostname,
130+
path,
131+
port,
132+
timeout: actualTimeout,
133+
} = mockHttpGet.mock.calls[0][0];
134+
expect(protocol).toBe('http:');
135+
expect(hostname).toBe('localhost');
136+
expect(path).toBe('/path');
137+
expect(port).toBe(8080);
138+
expect(actualTimeout).toBe(timeout);
99139
});
140+
141+
it(
142+
`should prefer ${ENV_CMDS_RELATIVE_URI} to ${ENV_CMDS_FULL_URI}`,
143+
async () => {
144+
process.env[ENV_CMDS_RELATIVE_URI] = 'foo';
145+
process.env[ENV_CMDS_FULL_URI] = 'http://localhost:8080/path';
146+
147+
const timeout = Math.ceil(Math.random() * 1000);
148+
mockHttpGet.mockReturnValue(
149+
Promise.resolve(JSON.stringify(creds))
150+
);
151+
await fromContainerMetadata({timeout})();
152+
expect(mockHttpGet.mock.calls.length).toEqual(1);
153+
expect(mockHttpGet.mock.calls[0][0]).toEqual({
154+
hostname: '169.254.170.2',
155+
path: 'foo',
156+
timeout,
157+
});
158+
}
159+
);
160+
161+
it(
162+
'should reject the promise with a terminal error if a unexpected protocol is specified',
163+
async () => {
164+
process.env[ENV_CMDS_FULL_URI] = 'wss://localhost:8080/path';
165+
166+
await fromContainerMetadata()().then(
167+
() => {
168+
throw new Error('The promise should have been rejected');
169+
},
170+
err => {
171+
expect((err as any).tryNextLink).toBeFalsy();
172+
}
173+
);
174+
}
175+
);
176+
177+
it(
178+
'should reject the promise with a terminal error if a unexpected hostname is specified',
179+
async () => {
180+
process.env[ENV_CMDS_FULL_URI] = 'https://bucket.s3.amazonaws.com/key';
181+
182+
await fromContainerMetadata()().then(
183+
() => {
184+
throw new Error('The promise should have been rejected');
185+
},
186+
err => {
187+
expect((err as any).tryNextLink).toBeFalsy();
188+
}
189+
);
190+
}
191+
);
100192
});
101193
});

packages/credential-provider/lib/fromContainerMetadata.ts

Lines changed: 59 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -10,28 +10,20 @@ import {
1010
} from './remoteProvider/ImdsCredentials';
1111
import {retry} from './remoteProvider/retry';
1212
import {CredentialError} from "./CredentialError";
13+
import {parse} from "url";
14+
import {RequestOptions} from "http";
1315

16+
export const ENV_CMDS_FULL_URI = 'AWS_CONTAINER_CREDENTIALS_FULL_URI';
1417
export const ENV_CMDS_RELATIVE_URI = 'AWS_CONTAINER_CREDENTIALS_RELATIVE_URI';
1518

1619
export function fromContainerMetadata(
1720
init: RemoteProviderInit = {}
1821
): CredentialProvider {
1922
const {timeout, maxRetries} = providerConfigFromInit(init);
2023
return () => {
21-
const path = process.env[ENV_CMDS_RELATIVE_URI];
22-
23-
if (!path) {
24-
return Promise.reject(new CredentialError(
25-
'The container metadata credential provider cannot be used' +
26-
` unless the ${ENV_CMDS_RELATIVE_URI} environment variable` +
27-
' is set',
28-
false
29-
));
30-
}
31-
32-
return retry(async () => {
24+
return getCmdsUri().then(url => retry(async () => {
3325
const credsResponse = JSON.parse(
34-
await requestFromEcsImds(timeout, path)
26+
await requestFromEcsImds(timeout, url)
3527
);
3628
if (!isImdsCredentials(credsResponse)) {
3729
throw new CredentialError(
@@ -40,17 +32,65 @@ export function fromContainerMetadata(
4032
}
4133

4234
return fromImdsCredentials(credsResponse);
43-
}, maxRetries);
35+
}, maxRetries));
4436
}
4537
}
4638

47-
const CMDS_IP = '169.254.170.2';
48-
49-
function requestFromEcsImds(timeout: number, path: string): Promise<string> {
39+
function requestFromEcsImds(
40+
timeout: number,
41+
options: RequestOptions
42+
): Promise<string> {
5043
return httpGet({
51-
host: CMDS_IP,
52-
path,
44+
...options,
5345
timeout,
5446
})
5547
.then(buffer => buffer.toString());
5648
}
49+
50+
const CMDS_IP = '169.254.170.2';
51+
const GREENGRASS_HOSTS = new Set([
52+
'localhost',
53+
'127.0.0.1',
54+
]);
55+
const GREENGRASS_PROTOCOLS = new Set([
56+
'http:',
57+
'https:',
58+
]);
59+
60+
function getCmdsUri(): Promise<RequestOptions> {
61+
if (process.env[ENV_CMDS_RELATIVE_URI]) {
62+
return Promise.resolve({
63+
hostname: CMDS_IP,
64+
path: process.env[ENV_CMDS_RELATIVE_URI],
65+
});
66+
}
67+
68+
if (process.env[ENV_CMDS_FULL_URI]) {
69+
const parsed = parse(process.env[ENV_CMDS_FULL_URI]);
70+
if (!parsed.hostname || !GREENGRASS_HOSTS.has(parsed.hostname)) {
71+
return Promise.reject(new CredentialError(
72+
`${parsed.hostname} is not a valid container metadata service hostname`,
73+
false
74+
));
75+
}
76+
77+
if (!parsed.protocol || !GREENGRASS_PROTOCOLS.has(parsed.protocol)) {
78+
return Promise.resolve(new CredentialError(
79+
`${parsed.protocol} is not a valid container metadata service protocol`,
80+
false
81+
));
82+
}
83+
84+
return Promise.resolve({
85+
...parsed,
86+
port: parsed.port ? Number.parseInt(parsed.port, 10) : undefined
87+
});
88+
}
89+
90+
return Promise.reject(new CredentialError(
91+
'The container metadata credential provider cannot be used unless' +
92+
` the ${ENV_CMDS_RELATIVE_URI} or ${ENV_CMDS_FULL_URI} environment` +
93+
' variable is set',
94+
false
95+
));
96+
}

0 commit comments

Comments
 (0)