diff --git a/README.md b/README.md index 2f03ceee..e9a5674f 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ If no options are applied, it will fetch all recent pricing data from default re ### Credentials -This CLI utility uses AWS-SDK and requires AWS Access & Secret keys. If `~/.aws/credentials` is available it will use it. Otherwise, you will need to supply credentials through CLI options [`--accessKeyId`](#accessKeyId) and [`--secretAccessKey`](#secretAccessKey). +This CLI utility uses AWS-SDK and requires AWS Access & Secret keys. If environment variables pair `AWS_ACCESS_KEY_ID` & `AWS_SECRET_ACCESS_KEY` or `~/.aws/credentials` is available it will use it. Otherwise, you will need to supply credentials through CLI options [`--accessKeyId`](#accessKeyId) and [`--secretAccessKey`](#secretAccessKey). ### Options diff --git a/src/cli.spec.ts b/src/cli.spec.ts index 6cad87c2..99d49e2c 100644 --- a/src/cli.spec.ts +++ b/src/cli.spec.ts @@ -1,22 +1,26 @@ import { spawnSync } from 'child_process'; import mockConsole, { RestoreConsole } from 'jest-mock-console'; -import * as nock from 'nock'; import { resolve } from 'path'; -import { consoleMockCallJoin, nockEndpoint } from '../test/test-utils'; +import { + consoleMockCallJoin, + mockAwsCredentials, + mockAwsCredentialsClear, + mockDefaultRegionEndpoints, + mockDefaultRegionEndpointsClear, +} from '../test/test-utils'; import { main } from './cli'; -import { defaultRegions } from './regions'; describe('cli', () => { describe('test by import', () => { let restoreConsole: RestoreConsole; beforeAll(() => { - defaultRegions.forEach(region => nockEndpoint({ region })); + mockDefaultRegionEndpoints(); }); afterAll(() => { - nock.cleanAll(); + mockDefaultRegionEndpointsClear(); }); beforeEach(() => { @@ -94,8 +98,22 @@ describe('cli', () => { expect(caughtError).toBeTruthy(); expect(consoleMockCallJoin()).toMatchSnapshot(); }); + }); + + describe('should handle invalid credentials error', () => { + let restoreConsole: RestoreConsole; + + beforeAll(() => { + mockAwsCredentials(true); + restoreConsole = mockConsole(); + }); + + afterAll(() => { + mockAwsCredentialsClear(); + restoreConsole(); + }); - it('should handle invalid credentials error', async () => { + it('should throw error', async () => { let caughtError = false; try { await main(['--accessKeyId', 'rand', '--secretAccessKey', 'rand']); diff --git a/src/cli.ts b/src/cli.ts index 0123da84..389578c8 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -11,7 +11,7 @@ import { instanceSizes, InstanceType, } from './ec2-types'; -import { awsCredentialsCheck, defaults, getGlobalSpotPrices } from './lib'; +import { AuthError, awsCredentialsCheck, defaults, getGlobalSpotPrices } from './lib'; import { allProductDescriptions, instanceOfProductDescription, @@ -189,15 +189,10 @@ export const main = (argvInput?: string[]): Promise => } // test credentials - const awsCredentialValidity = await awsCredentialsCheck({ + await awsCredentialsCheck({ accessKeyId, secretAccessKey, }); - if (!awsCredentialValidity) { - console.log('Invalid AWS credentials provided.'); - rej(); - return; - } const productDescriptionsSetArray = Array.from(productDescriptionsSet); const familyTypeSetArray = Array.from(familyTypeSet); @@ -221,9 +216,18 @@ export const main = (argvInput?: string[]): Promise => res(); } catch (error) { - /* istanbul ignore next */ - console.log('unexpected getGlobalSpotPrices error:', JSON.stringify(error, null, 2)); - /* istanbul ignore next */ + if (error instanceof AuthError) { + if (error.code === 'UnAuthorized') { + console.log('Invalid AWS credentials provided.'); + } else { + // error.reason === 'CredentialsNotFound' + console.log('AWS credentials are not found.'); + } + } else { + /* istanbul ignore next */ + console.log('unexpected getGlobalSpotPrices error:', JSON.stringify(error, null, 2)); + /* istanbul ignore next */ + } rej(); } }, diff --git a/src/lib.spec.ts b/src/lib.spec.ts index df337081..bfcb4788 100644 --- a/src/lib.spec.ts +++ b/src/lib.spec.ts @@ -3,9 +3,15 @@ import mockConsole, { RestoreConsole } from 'jest-mock-console'; import { filter } from 'lodash'; import * as nock from 'nock'; -import { consoleMockCallJoin, nockEndpoint } from '../test/test-utils'; +import { + consoleMockCallJoin, + mockAwsCredentials, + mockAwsCredentialsClear, + mockDefaultRegionEndpoints, + mockDefaultRegionEndpointsClear, +} from '../test/test-utils'; import { awsCredentialsCheck, getGlobalSpotPrices } from './lib'; -import { defaultRegions, Region } from './regions'; +import { Region } from './regions'; describe('lib', () => { describe('getGlobalSpotPrices', () => { @@ -15,13 +21,13 @@ describe('lib', () => { beforeAll(async () => { restoreConsole = mockConsole(); - defaultRegions.forEach(region => nockEndpoint({ region })); + mockDefaultRegionEndpoints(); results = await getGlobalSpotPrices(); }); afterAll(() => { restoreConsole(); - nock.cleanAll(); + mockDefaultRegionEndpointsClear(); }); it('should return expected values', () => { @@ -33,9 +39,7 @@ describe('lib', () => { let results: SpotPrice[]; beforeAll(async () => { - defaultRegions.forEach(region => - nockEndpoint({ region, maxLength: 5, returnPartialBlankValues: true }), - ); + mockDefaultRegionEndpoints({ maxLength: 5, returnPartialBlankValues: true }); results = await getGlobalSpotPrices({ familyTypes: ['c4', 'c5'], @@ -48,7 +52,7 @@ describe('lib', () => { }); afterAll(() => { - nock.cleanAll(); + mockDefaultRegionEndpointsClear(); }); it('should return expected values', () => { @@ -60,7 +64,7 @@ describe('lib', () => { let results: SpotPrice[]; beforeAll(async () => { - defaultRegions.forEach(region => nockEndpoint({ region })); + mockDefaultRegionEndpoints(); results = await getGlobalSpotPrices({ familyTypes: ['c4', 'c5'], @@ -73,7 +77,7 @@ describe('lib', () => { }); afterAll(() => { - nock.cleanAll(); + mockDefaultRegionEndpointsClear(); }); it('should contain all instance types', () => { @@ -95,7 +99,7 @@ describe('lib', () => { let results: SpotPrice[]; beforeAll(async () => { - defaultRegions.forEach(region => nockEndpoint({ region })); + mockDefaultRegionEndpoints(); results = await getGlobalSpotPrices({ priceMax, @@ -104,7 +108,7 @@ describe('lib', () => { }); afterAll(() => { - nock.cleanAll(); + mockDefaultRegionEndpointsClear(); }); it(`should return prices less than ${priceMax}`, () => { @@ -120,6 +124,7 @@ describe('lib', () => { beforeAll(() => { restoreConsole = mockConsole(); + mockAwsCredentials(); nock(`https://ec2.${region}.amazonaws.com`) .persist() .post('/') @@ -127,6 +132,7 @@ describe('lib', () => { }); afterAll(() => { restoreConsole(); + mockAwsCredentialsClear(); nock.cleanAll(); }); it('should console log error', async () => { @@ -142,47 +148,42 @@ describe('lib', () => { nock.cleanAll(); }); - it('should return false', async () => { - nock('https://sts.amazonaws.com') - .persist() - .post('/') - .reply( - 403, - ` - - Sender - MissingAuthenticationToken - Request is missing Authentication Token - - 4fc0d3ee-efef-11e9-9282-3b7bffe54a9b - `, - ); - const results = await awsCredentialsCheck(); - expect(results).toBeFalsy(); + describe('should throw error', () => { + beforeEach(() => { + mockAwsCredentials(true); + }); + + afterEach(() => { + mockAwsCredentialsClear(); + }); + + it('should throw error', async () => { + let threwError = false; + try { + await awsCredentialsCheck(); + } catch (error) { + threwError = true; + } + expect(threwError).toBeTruthy(); + }); }); - it('should return true', async () => { - nock('https://sts.amazonaws.com') - .persist() - .post('/') - .reply( - 200, - ` - - arn:aws:iam::123456789012:user/Alice - EXAMPLE - 123456789012 - - - 01234567-89ab-cdef-0123-456789abcdef - - `, - ); - const results = await awsCredentialsCheck({ - accessKeyId: 'accessKeyId', - secretAccessKey: 'secretAccessKey', - }); - expect(results).toBeTruthy(); + describe('should not throw error', () => { + beforeEach(() => { + mockAwsCredentials(); + }); + afterEach(() => { + mockAwsCredentialsClear(); + }); + it('should not throw error', async () => { + let threwError = false; + try { + await awsCredentialsCheck(); + } catch (error) { + threwError = true; + } + expect(threwError).toBeFalsy(); + }); }); }); }); diff --git a/src/lib.ts b/src/lib.ts index 997f5683..f540394b 100644 --- a/src/lib.ts +++ b/src/lib.ts @@ -1,4 +1,4 @@ -import { EC2, STS } from 'aws-sdk'; +import { config, EC2, STS } from 'aws-sdk'; import { find, findIndex } from 'lodash'; import * as ora from 'ora'; import { table } from 'table'; @@ -47,9 +47,9 @@ class Ec2SpotPriceError extends Error { Object.setPrototypeOf(this, Ec2SpotPriceError.prototype); } - region: string; + readonly region: string; - code: string; + readonly code: string; } const getEc2SpotPrice = async (options: { @@ -269,13 +269,33 @@ export const getGlobalSpotPrices = async (options?: { return rtn; }; +type AuthErrorCode = 'CredentialsNotFound' | 'UnAuthorized'; + +export class AuthError extends Error { + constructor(message: string, code: AuthErrorCode) { + super(message); + this.code = code; + Object.setPrototypeOf(this, AuthError.prototype); + } + + readonly code: AuthErrorCode; +} + export const awsCredentialsCheck = async (options?: { accessKeyId?: string; secretAccessKey?: string; -}): Promise => { +}): Promise => { const { accessKeyId, secretAccessKey } = options || {}; - let isValid = true; + if ( + !accessKeyId && + !secretAccessKey && + !config.credentials && + !(process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY) + ) { + throw new AuthError('AWS credentials unavailable.', 'CredentialsNotFound'); + } + try { const sts = new STS({ accessKeyId, @@ -283,7 +303,6 @@ export const awsCredentialsCheck = async (options?: { }); await sts.getCallerIdentity().promise(); } catch (error) { - isValid = false; + throw new AuthError(error.message, 'UnAuthorized'); } - return isValid; }; diff --git a/test/test-utils.ts b/test/test-utils.ts index 66e159f8..ee5a7aa5 100644 --- a/test/test-utils.ts +++ b/test/test-utils.ts @@ -1,3 +1,4 @@ +import { config } from 'aws-sdk'; import { SpotPrice } from 'aws-sdk/clients/ec2'; import { readFileSync } from 'fs'; import { filter } from 'lodash'; @@ -5,7 +6,7 @@ import * as nock from 'nock'; import { resolve } from 'path'; import { parse } from 'querystring'; -import { allRegions, Region } from '../src/regions'; +import { allRegions, defaultRegions, Region } from '../src/regions'; const data = JSON.parse( readFileSync(resolve(__dirname, '../test/spot-prices-mock.json')).toString(), @@ -27,7 +28,7 @@ const regionalData: RegionalData = allRegions.reduce( * @param region * @param returnPartialBlankValues for sortSpotPrice() coverage */ -export const nockEndpoint = (options: { +const nockEndpoint = (options: { region: Region; maxLength?: number; returnPartialBlankValues?: boolean; @@ -90,6 +91,20 @@ export const nockEndpoint = (options: { returnPartialBlankValues && nextIndex === undefined && idx === instanceDataSlice.length - 1; + if (returnWithBlank) + process.stdout.write( + `\n\nSKIPPED: ${JSON.stringify( + { + InstanceType: d.InstanceType, + ProductDescription: d.ProductDescription, + SpotPrice: d.SpotPrice, + Timestamp: d.Timestamp, + AvailabilityZone: d.AvailabilityZone, + }, + null, + 2, + )}\n\n`, + ); return ` ${d.InstanceType} ${d.ProductDescription} @@ -105,6 +120,70 @@ export const nockEndpoint = (options: { }); }; +export const mockAwsCredentials = (fail?: boolean): void => { + process.env.AWS_ACCESS_KEY_ID = 'accessKeyId'; + process.env.AWS_SECRET_ACCESS_KEY = 'secretAccessKey'; + config.accessKeyId = 'accessKeyId'; + config.secretAccessKey = 'secretAccessKey'; + + if (fail) { + nock('https://sts.amazonaws.com') + .persist() + .post('/') + .reply( + 403, + ` + + Sender + MissingAuthenticationToken + Request is missing Authentication Token + + 4fc0d3ee-efef-11e9-9282-3b7bffe54a9b + `, + ); + } else { + nock(`https://sts.amazonaws.com`) + .persist() + .post('/') + .reply( + 200, + ` + + arn:aws:iam::123456789012:user/Alice + EXAMPLE + 123456789012 + + + 01234567-89ab-cdef-0123-456789abcdef + + `, + ); + } +}; + +export const mockAwsCredentialsClear = (): void => { + delete process.env.AWS_ACCESS_KEY_ID; + delete process.env.AWS_SECRET_ACCESS_KEY; + delete config.accessKeyId; + delete config.secretAccessKey; +}; + +export const mockDefaultRegionEndpoints = ( + options: { + maxLength?: number; + returnPartialBlankValues?: boolean; + } = {}, +): void => { + const { maxLength, returnPartialBlankValues } = options; + mockAwsCredentials(); + defaultRegions.forEach(region => nockEndpoint({ region, maxLength, returnPartialBlankValues })); +}; + +export const mockDefaultRegionEndpointsClear = (): void => { + mockAwsCredentialsClear(); + nock.cleanAll(); +}; + export const consoleMockCallJoin = (type: 'log' | 'warn' | 'error' = 'log'): string => { // @ts-ignore const { calls }: { calls: string[][] } = console[type].mock;