'use strict'; /* eslint-disable no-unused-expressions */ const _ = require('lodash'); const chai = require('chai'); const path = require('path'); const fs = require('fs-extra'); const os = require('os'); const proxyquire = require('proxyquire'); const sinon = require('sinon'); const overrideEnv = require('process-utils/override-env'); const AwsProvider = require('../../../../../lib/plugins/aws/provider'); const Serverless = require('../../../../../lib/Serverless'); const runServerless = require('../../../../utils/run-serverless'); chai.use(require('chai-as-promised')); chai.use(require('sinon-chai')); const expect = chai.expect; describe('AwsProvider', () => { let awsProvider; let serverless; let restoreEnv; const options = { stage: 'dev', region: 'us-east-1', }; beforeEach(() => { ({ restoreEnv } = overrideEnv()); serverless = new Serverless(options); serverless.cli = new serverless.classes.CLI(); awsProvider = new AwsProvider(serverless, options); }); afterEach(() => restoreEnv()); describe('#constructor()', () => { it('should set Serverless instance', () => { expect(typeof awsProvider.serverless).to.not.equal('undefined'); }); it('should set the provider property', () => { expect(awsProvider.provider).to.equal(awsProvider); }); describe('stage name validation', () => { const stages = ['myStage', 'my-stage', 'my_stage', "${opt:stage, 'prod'}"]; stages.forEach((stage) => { it(`should not throw an error before variable population even if http event is present and stage is ${stage}`, () => { const config = { stage, }; serverless = new Serverless(config); const serverlessYml = { service: 'new-service', provider: { name: 'aws', stage, }, functions: { first: { events: [ { http: { path: 'foo', method: 'GET', }, }, ], }, }, }; serverless.service = new serverless.classes.Service(serverless, serverlessYml); expect(() => new AwsProvider(serverless, config)).to.not.throw(Error); }); }); }); describe('deploymentBucket configuration', () => { it('should do nothing if not defined', () => { serverless.service.provider.deploymentBucket = undefined; const newAwsProvider = new AwsProvider(serverless, options); expect(newAwsProvider.serverless.service.provider.deploymentBucket).to.equal(undefined); }); it('should do nothing if the value is a string', () => { serverless.service.provider.deploymentBucket = 'my.deployment.bucket'; const newAwsProvider = new AwsProvider(serverless, options); expect(newAwsProvider.serverless.service.provider.deploymentBucket).to.equal( 'my.deployment.bucket' ); }); }); }); describe('values', () => { const obj = { a: 'b', c: { d: 'e', f: { g: 'h', }, }, }; const paths = [['a'], ['c', 'd'], ['c', 'f', 'g']]; const getExpected = [ { path: paths[0], value: obj.a }, { path: paths[1], value: obj.c.d }, { path: paths[2], value: obj.c.f.g }, ]; describe('#getValues', () => { it('should return an array of values given paths to them', () => { expect(awsProvider.getValues(obj, paths)).to.eql(getExpected); }); }); describe('#firstValue', () => { it("should ignore entries without a 'value' attribute", () => { const input = _.cloneDeep(getExpected); delete input[0].value; delete input[2].value; expect(awsProvider.firstValue(input)).to.eql(getExpected[1]); }); it("should ignore entries with an undefined 'value' attribute", () => { const input = _.cloneDeep(getExpected); input[0].value = undefined; input[2].value = undefined; expect(awsProvider.firstValue(input)).to.eql(getExpected[1]); }); it('should return the first value', () => { expect(awsProvider.firstValue(getExpected)).to.equal(getExpected[0]); }); it('should return the middle value', () => { const input = _.cloneDeep(getExpected); delete input[0].value; delete input[2].value; expect(awsProvider.firstValue(input)).to.equal(input[1]); }); it('should return the last value', () => { const input = _.cloneDeep(getExpected); delete input[0].value; delete input[1].value; expect(awsProvider.firstValue(input)).to.equal(input[2]); }); it('should return the last object if none have valid values', () => { const input = _.cloneDeep(getExpected); delete input[0].value; delete input[1].value; delete input[2].value; expect(awsProvider.firstValue(input)).to.equal(input[2]); }); }); }); describe('#request()', () => { let awsRequestStub; let awsProviderProxied; let logStub; beforeEach(() => { logStub = sinon.stub(); awsRequestStub = sinon.stub().resolves(); awsRequestStub.memoized = sinon.stub().resolves(); const AwsProviderProxyquired = proxyquire .noCallThru() .load('../../../../../lib/plugins/aws/provider.js', { '../../aws/request': awsRequestStub, '@serverless/utils/log': logStub, }); awsProviderProxied = new AwsProviderProxyquired(serverless, options); }); afterEach(() => {}); it('should pass resolved credentials as expected', async () => { awsProviderProxied.cachedCredentials = { accessKeyId: 'accessKeyId', secretAccessKey: 'secretAccessKey', sessionToken: 'sessionToken', }; await awsProviderProxied.request('S3', 'getObject', {}); expect(awsRequestStub.args[0][0]).to.deep.equal({ name: 'S3', params: { ...awsProviderProxied.cachedCredentials, region: 'us-east-1', isS3TransferAccelerationEnabled: false, }, }); }); it('should trigger the expected AWS SDK invokation', () => { return awsProviderProxied.request('S3', 'getObject', {}).then(() => { expect(awsRequestStub).to.have.been.calledOnce; }); }); it('should use local cache when using {useCache: true}', () => { return awsProviderProxied .request('S3', 'getObject', {}, { useCache: true }) .then(() => awsProviderProxied.request('S3', 'getObject', {}, { useCache: true })) .then(() => { expect(awsRequestStub).to.not.have.been.called; expect(awsRequestStub.memoized).to.have.been.calledTwice; }); }); it('should detect incompatible legacy use of aws request and print a debug warning', () => { // Enable debug log process.env.SLS_DEBUG = true; return awsProviderProxied .request('S3', 'getObject', {}, 'incompatible string option') .then(() => { expect(logStub).to.have.been.calledWith( 'WARNING: Inappropriate call of provider.request()' ); }) .finally(() => { process.env.SLS_DEBUG = false; }); }); }); describe('#getServerlessDeploymentBucketName()', () => { it('should return the name of the serverless deployment bucket', () => { const describeStackResourcesStub = sinon.stub(awsProvider, 'request').resolves({ StackResourceDetail: { PhysicalResourceId: 'serverlessDeploymentBucketName', }, }); return awsProvider.getServerlessDeploymentBucketName().then((bucketName) => { expect(bucketName).to.equal('serverlessDeploymentBucketName'); expect(describeStackResourcesStub.calledOnce).to.be.equal(true); expect( describeStackResourcesStub.calledWithExactly('CloudFormation', 'describeStackResource', { StackName: awsProvider.naming.getStackName(), LogicalResourceId: awsProvider.naming.getDeploymentBucketLogicalId(), }) ).to.be.equal(true); awsProvider.request.restore(); }); }); it('should return the name of the custom deployment bucket', () => { awsProvider.serverless.service.provider.deploymentBucket = 'custom-bucket'; const describeStackResourcesStub = sinon.stub(awsProvider, 'request').resolves({ StackResourceDetail: { PhysicalResourceId: 'serverlessDeploymentBucketName', }, }); return awsProvider.getServerlessDeploymentBucketName().then((bucketName) => { expect(describeStackResourcesStub.called).to.be.equal(false); expect(bucketName).to.equal('custom-bucket'); awsProvider.request.restore(); }); }); }); describe('#getAccountInfo()', () => { it('should return the AWS account id and partition', () => { const accountId = '12345678'; const partition = 'aws'; const stsGetCallerIdentityStub = sinon.stub(awsProvider, 'request').resolves({ ResponseMetadata: { RequestId: '12345678-1234-1234-1234-123456789012' }, UserId: 'ABCDEFGHIJKLMNOPQRSTU:VWXYZ', Account: accountId, Arn: 'arn:aws:sts::123456789012:assumed-role/ROLE-NAME/VWXYZ', }); return awsProvider.getAccountInfo().then((result) => { expect(stsGetCallerIdentityStub.calledOnce).to.equal(true); expect(result.accountId).to.equal(accountId); expect(result.partition).to.equal(partition); awsProvider.request.restore(); }); }); }); describe('#getAccountId()', () => { it('should return the AWS account id', () => { const accountId = '12345678'; const stsGetCallerIdentityStub = sinon.stub(awsProvider, 'request').resolves({ ResponseMetadata: { RequestId: '12345678-1234-1234-1234-123456789012' }, UserId: 'ABCDEFGHIJKLMNOPQRSTU:VWXYZ', Account: accountId, Arn: 'arn:aws:sts::123456789012:assumed-role/ROLE-NAME/VWXYZ', }); return awsProvider.getAccountId().then((result) => { expect(stsGetCallerIdentityStub.calledOnce).to.equal(true); expect(result).to.equal(accountId); awsProvider.request.restore(); }); }); }); describe('#isS3TransferAccelerationEnabled()', () => { it('should return false by default', () => { awsProvider.options['aws-s3-accelerate'] = undefined; return expect(awsProvider.isS3TransferAccelerationEnabled()).to.equal(false); }); it('should return true when CLI option is provided', () => { awsProvider.options['aws-s3-accelerate'] = true; return expect(awsProvider.isS3TransferAccelerationEnabled()).to.equal(true); }); }); describe('#disableTransferAccelerationForCurrentDeploy()', () => { it('should remove the corresponding option for the current deploy', () => { awsProvider.options['aws-s3-accelerate'] = true; awsProvider.disableTransferAccelerationForCurrentDeploy(); return expect(awsProvider.options['aws-s3-accelerate']).to.be.undefined; }); }); }); describe('test/unit/lib/plugins/aws/provider.test.js', () => { describe('#getProviderName and #sessionCache', () => { let sls; const expectedToken = '123'; before(async () => { // Fake service that update credentials class FakeCloudFormation { constructor(credentials) { this.credentials = credentials; this.credentials.credentials.sessionToken = expectedToken; } describeStacks() { return { promise: async () => {} }; } } // Stub functions for the credentials creation in the provider class SharedIniFileCredentials { constructor() { this.sessionToken = 'abc'; this.accessKeyId = 'keyId'; this.secretAccessKey = 'secret'; } } class EnvironmentCredentials { constructor() { this.sessionToken = 'env'; this.accessKeyId = 'keyId'; this.secretAccessKey = 'secret'; } } const modulesCacheStub = { 'aws-sdk': { SharedIniFileCredentials, EnvironmentCredentials, CloudFormation: FakeCloudFormation, }, }; const { serverless } = await runServerless({ fixture: 'aws', command: 'print', modulesCacheStub, }); sls = serverless; }); it('`AwsProvider.getProviderName()` should resolve provider name', () => { expect(AwsProvider.getProviderName()).to.equal('aws'); }); it('should retain sessionToken eventually updated internally by SDK', async () => { expect(sls.getProvider('aws').getCredentials().credentials.sessionToken).not.to.equal( expectedToken ); await sls.getProvider('aws').request('CloudFormation', 'describeStacks'); expect(sls.getProvider('aws').getCredentials().credentials.sessionToken).to.equal( expectedToken ); }); }); describe('#getCredentials()', () => { before(async () => { // create default aws credentials file in before so that grouped run can use it await fs.ensureDir(path.resolve(os.homedir(), './.aws')); await fs.outputFile( path.resolve(os.homedir(), './.aws/credentials'), ` [default] aws_access_key_id = DEFAULTKEYID aws_secret_access_key = DEFAULTSECRET [notDefault] aws_access_key_id = NOTDEFAULTKEYID aws_secret_access_key = NOTDEFAULTSECRET [notDefaultWithRole] source_profile = notDefault role_arn = NOTDEFAULTWITHROLEROLE `, { flag: 'w+' } ); }); it('should get credentials from default AWS profile', async () => { const { serverless } = await runServerless({ fixture: 'aws', command: 'print', }); const awsCredentials = serverless.getProvider('aws').getCredentials(); expect(awsCredentials.credentials.accessKeyId).to.equal('DEFAULTKEYID'); }); it('should get credentials from custom default AWS profile, set by AWS_DEFAULT_PROFILE', async () => { const { serverless } = await runServerless({ fixture: 'aws', command: 'print', }); // getCredentials resolve the env when called let awsCredentials; overrideEnv(() => { process.env.AWS_DEFAULT_PROFILE = 'notDefault'; awsCredentials = serverless.getProvider('aws').getCredentials(); }); expect(awsCredentials.credentials.accessKeyId).to.equal('NOTDEFAULTKEYID'); }); describe('assume role with provider.profile', () => { let awsCredentials; before(async () => { const { serverless } = await runServerless({ fixture: 'aws', command: 'print', configExt: { provider: { profile: 'notDefaultWithRole', }, }, }); awsCredentials = serverless.getProvider('aws').getCredentials(); }); it('should get credentials from `provider.profile`', () => { expect(awsCredentials.credentials.profile).to.equal('notDefaultWithRole'); }); it('should accept a role to assume on credentials', () => { expect(awsCredentials.credentials.roleArn).to.equal('NOTDEFAULTWITHROLEROLE'); }); }); it('should get credentials from environment variables', async () => { const { serverless } = await runServerless({ fixture: 'aws', command: 'print', }); let awsCredentials; // getCredentials resolve the env when called overrideEnv(() => { process.env.AWS_ACCESS_KEY_ID = 'ENVKEYID'; process.env.AWS_SECRET_ACCESS_KEY = 'ENVSECRET'; awsCredentials = serverless.getProvider('aws').getCredentials(); }); expect(awsCredentials.credentials.accessKeyId).to.equal('ENVKEYID'); }); describe('profile with non default credentials file', () => { let awsCredentials; before(async () => { await fs.outputFile( path.resolve(os.homedir(), './custom_credentials'), ` [default] aws_access_key_id = DEFAULTKEYID aws_secret_access_key = DEFAULTSECRET [customProfile] aws_access_key_id = CUSTOMKEYID aws_secret_access_key = CUSTOMSECRET `, { flag: 'w+' } ); const { serverless } = await runServerless({ fixture: 'aws', command: 'print', }); // getCredentials resolve the env when called overrideEnv(() => { process.env.AWS_PROFILE = 'customProfile'; process.env.AWS_SHARED_CREDENTIALS_FILE = path .resolve(os.homedir(), './custom_credentials') .toString(); awsCredentials = serverless.getProvider('aws').getCredentials(); }); }); after(async () => { await fs.remove(path.resolve(os.homedir(), './custom_credentials')); }); it('should get credentials from AWS_PROFILE environment variable', () => { expect(awsCredentials.credentials.profile).to.equal('customProfile'); }); it('should get credentials from AWS_SHARED_CREDENTIALS_FILE environment variable', () => { expect(awsCredentials.credentials.accessKeyId).to.equal('CUSTOMKEYID'); }); }); it('should get credentials from stage specific environment variables', async () => { const { serverless } = await runServerless({ fixture: 'aws', command: 'print', configExt: { provider: { stage: 'testStage', }, }, }); let awsCredentials; overrideEnv(() => { process.env.AWS_TESTSTAGE_ACCESS_KEY_ID = 'TESTSTAGEACCESSKEYID'; process.env.AWS_TESTSTAGE_SECRET_ACCESS_KEY = 'TESTSTAGESECRET'; awsCredentials = serverless.getProvider('aws').getCredentials(); }); expect(awsCredentials.credentials.accessKeyId).to.equal('TESTSTAGEACCESSKEYID'); }); it('should get credentials from AWS_{stage}_PROFILE environment variable', async () => { const { serverless } = await runServerless({ fixture: 'aws', command: 'print', configExt: { provider: { stage: 'testStage', }, }, }); let awsCredentials; overrideEnv(() => { process.env.AWS_TESTSTAGE_PROFILE = 'notDefault'; awsCredentials = serverless.getProvider('aws').getCredentials(); }); expect(awsCredentials.credentials.accessKeyId).to.equal('NOTDEFAULTKEYID'); }); describe('profile with cli and encryption', () => { let awsCredentials; before(async () => { const { serverless } = await runServerless({ fixture: 'aws', command: 'print', options: { 'aws-profile': 'notDefault', }, configExt: { provider: { deploymentBucket: { serverSideEncryption: 'aws:kms', }, }, }, }); awsCredentials = serverless.getProvider('aws').getCredentials(); }); it('should get credentials "--aws-profile" CLI option', () => { expect(awsCredentials.credentials.accessKeyId).to.equal('NOTDEFAULTKEYID'); }); it('should set the signatureVersion to v4 if the serverSideEncryption is aws:kms', () => { expect(awsCredentials.signatureVersion).to.equal('v4'); }); }); it('should throw an error if a non-existent profile is set', async () => { const { serverless } = await runServerless({ fixture: 'aws', command: 'print', options: { 'aws-profile': 'nonExistent', }, }); expect(() => serverless.getProvider('aws').getCredentials()).to.throw(Error); }); }); describe('#getRegion()', () => { it('should default to "us-east-1"', async () => { const { serverless } = await runServerless({ fixture: 'aws', command: 'print', }); expect(serverless.getProvider('aws').getRegion()).to.equal('us-east-1'); }); it('should allow to override via `provider.region`', async () => { const { serverless } = await runServerless({ fixture: 'aws', command: 'print', configExt: { provider: { region: 'eu-central-1', }, }, }); expect(serverless.getProvider('aws').getRegion()).to.equal('eu-central-1'); }); it('should allow to override via CLI `--region` param', async () => { const { serverless } = await runServerless({ fixture: 'aws', command: 'print', options: { region: 'us-west-1' }, configExt: { provider: { region: 'eu-central-1', }, }, }); expect(serverless.getProvider('aws').getRegion()).to.equal('us-west-1'); }); }); describe('#getProfile()', () => { it('should allow to set via `provider.profile`', async () => { const { serverless } = await runServerless({ fixture: 'aws', command: 'print', configExt: { provider: { profile: 'test-profile', }, }, }); expect(serverless.getProvider('aws').getProfile()).to.equal('test-profile'); }); it('should allow to set via CLI `--profile` param', async () => { const { serverless } = await runServerless({ fixture: 'aws', command: 'print', options: { profile: 'cli-override' }, configExt: { provider: { profile: 'test-profile', }, }, }); expect(serverless.getProvider('aws').getProfile()).to.equal('cli-override'); }); it('should allow to set via CLI `--aws-profile` param', async () => { // Test with `provider.profile` `--profile` and `--aws-pofile` CLI param set // Confirm that `--aws-profile` overrides const { serverless } = await runServerless({ fixture: 'aws', command: 'print', options: { 'profile': 'cli-override', 'aws-profile': 'aws-override', }, configExt: { provider: { profile: 'test-profile', }, }, }); expect(serverless.getProvider('aws').getProfile()).to.equal('aws-override'); }); }); describe('#getDeploymentPrefix()', () => { it('should put all artifacts in namespaced folder', async () => { const { cfTemplate } = await runServerless({ fixture: 'function', command: 'package', }); const functions = Object.values(cfTemplate.Resources).filter( (r) => r.Type === 'AWS::Lambda::Function' ); expect(functions.length).to.be.greaterThanOrEqual(1); for (const f of functions) { expect(f.Properties.Code.S3Key) .to.be.a('string') .and.satisfy((key) => key.startsWith('serverless/')); } }); it('should support custom namespaced folder', async () => { const { cfTemplate } = await runServerless({ fixture: 'function', command: 'package', configExt: { provider: { deploymentPrefix: 'test-prefix', }, }, }); const functions = Object.values(cfTemplate.Resources).filter( (r) => r.Type === 'AWS::Lambda::Function' ); expect(functions.length).to.be.greaterThanOrEqual(1); for (const f of functions) { expect(f.Properties.Code.S3Key) .to.be.a('string') .and.satisfy((key) => key.startsWith('test-prefix/')); } }); }); describe('#getAlbTargetGroupPrefix()', () => { it('should support `provider.alb.targetGroupPrefix`', async () => { const albId = '50dc6c495c0c9188'; const validBaseEventConfig = { listenerArn: `arn:aws:elasticloadbalancing:us-east-1:123456789012:listener/app/my-load-balancer/${albId}/f2f7dc8efc522ab2`, conditions: { path: '/', }, }; const { cfTemplate } = await runServerless({ fixture: 'function', command: 'package', configExt: { provider: { alb: { targetGroupPrefix: 'a-prefix', }, }, functions: { fnTargetGroupName: { handler: 'index.handler', events: [ { alb: { ...validBaseEventConfig, priority: 1, }, }, ], }, }, }, }); const targetGroups = Object.values(cfTemplate.Resources).filter( (r) => r.Type === 'AWS::ElasticLoadBalancingV2::TargetGroup' ); expect(targetGroups.length).to.be.greaterThanOrEqual(1); for (const t of targetGroups) { expect(t.Properties.Name) .to.be.a('string') .and.satisfy((key) => key.startsWith('a-prefix')); } }); }); describe('#getStage()', () => { it('should default to "dev"', async () => { const { serverless } = await runServerless({ fixture: 'aws', command: 'print', }); expect(serverless.getProvider('aws').getStage()).to.equal('dev'); }); it('should allow to override via `provider.stage`', async () => { const { serverless } = await runServerless({ fixture: 'aws', command: 'print', configExt: { provider: { stage: 'staging', }, }, }); expect(serverless.getProvider('aws').getStage()).to.equal('staging'); }); it('should allow to override via CLI `--stage` param', async () => { const { serverless } = await runServerless({ fixture: 'aws', command: 'print', options: { stage: 'production' }, configExt: { provider: { stage: 'staging', }, }, }); expect(serverless.getProvider('aws').getStage()).to.equal('production'); }); }); describe('when resolving images', () => { it('should fail if `functions[].image` references image with both path and uri', async () => { await expect( runServerless({ fixture: 'function', command: 'package', configExt: { provider: { ecr: { images: { invalidimage: { path: './', uri: '000000000000.dkr.ecr.sa-east-1.amazonaws.com/test-lambda-docker@sha256:6bb600b4d6e1d7cf521097177dd0c4e9ea373edb91984a505333be8ac9455d38', }, }, }, }, functions: { fnProviderInvalidImage: { image: 'invalidimage', }, }, }, }) ).to.be.eventually.rejected.and.have.property( 'code', 'ECR_IMAGE_BOTH_URI_AND_PATH_DEFINED_ERROR' ); }); it('should fail if `functions[].image` references image without path and uri', async () => { await expect( runServerless({ fixture: 'function', command: 'package', configExt: { provider: { ecr: { images: { invalidimage: {}, }, }, }, functions: { fnProviderInvalidImage: { image: 'invalidimage', }, }, }, }) ).to.be.eventually.rejected.and.have.property( 'code', 'ECR_IMAGE_NEITHER_URI_NOR_PATH_DEFINED_ERROR' ); }); it('should fail if `functions[].image` references image from `provider.ecr.images` that has invalid path', async () => { await expect( runServerless({ fixture: 'ecr', command: 'package', shouldStubSpawn: true, configExt: { provider: { ecr: { images: { baseimage: { path: './invalid', }, }, }, }, }, }) ).to.be.eventually.rejected.and.have.property('code', 'DOCKERFILE_NOT_AVAILABLE_ERROR'); }); it('should fail if `functions[].image` references image not defined in `provider.ecr.images`', async () => { await expect( runServerless({ fixture: 'function', command: 'package', configExt: { functions: { fnInvalid: { image: 'undefinedimage', }, }, }, }) ).to.be.eventually.rejected.and.have.property( 'code', 'REFERENCED_FUNCTION_IMAGE_NOT_DEFINED_IN_PROVIDER' ); }); it('should fail if both uri and name is provided for an image', async () => { await expect( runServerless({ fixture: 'ecr', command: 'package', shouldStubSpawn: true, configExt: { functions: { foo: { image: { name: 'baseimage', uri: '000000000000.dkr.ecr.sa-east-1.amazonaws.com/test-lambda-docker@sha256:6bb600b4d6e1d7cf521097177dd0c4e9ea373edb91984a505333be8ac9455d38', }, }, }, }, }) ).to.be.eventually.rejected.and.have.property( 'code', 'FUNCTION_IMAGE_BOTH_URI_AND_NAME_DEFINED_ERROR' ); }); it('should fail if neither uri nor name is provided for an image', async () => { await expect( runServerless({ fixture: 'ecr', command: 'package', shouldStubSpawn: true, configExt: { functions: { foo: { image: {}, }, }, }, }) ).to.be.eventually.rejected.and.have.property( 'code', 'FUNCTION_IMAGE_NEITHER_URI_NOR_NAME_DEFINED_ERROR' ); }); const findVersionCfConfig = (cfResources, fnLogicalId) => Object.values(cfResources).find( (resource) => resource.Type === 'AWS::Lambda::Version' && resource.Properties.FunctionName.Ref === fnLogicalId ).Properties; describe('with `functions[].image` referencing existing images', () => { let cfResources; let naming; let serviceConfig; const imageSha = '6bb600b4d6e1d7cf521097177dd0c4e9ea373edb91984a505333be8ac9455d38'; const imageWithSha = `000000000000.dkr.ecr.sa-east-1.amazonaws.com/test-lambda-docker@sha256:${imageSha}`; const imageDigestFromECR = 'sha256:2e6b10a4b1ca0f6d3563a8a1f034dde7c4d7c93b50aa91f24311765d0822186b'; const describeImagesStub = sinon .stub() .resolves({ imageDetails: [{ imageDigest: imageDigestFromECR }] }); const awsRequestStubMap = { ECR: { describeImages: describeImagesStub, }, }; before(async () => { const { awsNaming, cfTemplate, fixtureData } = await runServerless({ fixture: 'function', command: 'package', configExt: { provider: { ecr: { images: { imagewithexplicituri: { uri: imageWithSha, }, imagewithimplicituri: imageWithSha, }, }, }, functions: { fnImage: { image: imageWithSha, }, fnImageWithTag: { image: '000000000000.dkr.ecr.sa-east-1.amazonaws.com/test-lambda-docker:stable', }, fnImageWithTagAndRepoWithSlashes: { image: '000000000000.dkr.ecr.sa-east-1.amazonaws.com/test-lambda/repo-docker:stable', }, fnImageWithExplicitUri: { image: { uri: imageWithSha, }, }, fnProviderImageWithExplicitUri: { image: 'imagewithexplicituri', }, fnProviderImageWithImplicitUri: { image: 'imagewithimplicituri', }, }, }, awsRequestStubMap, }); cfResources = cfTemplate.Resources; naming = awsNaming; serviceConfig = fixtureData.serviceConfig; }); it('should support `functions[].image` with implicit uri with sha', () => { const functionServiceConfig = serviceConfig.functions.fnImage; const functionCfLogicalId = naming.getLambdaLogicalId('fnImage'); const functionCfConfig = cfResources[functionCfLogicalId].Properties; expect(functionCfConfig.Code).to.deep.equal({ ImageUri: functionServiceConfig.image }); expect(functionCfConfig).to.not.have.property('Handler'); expect(functionCfConfig).to.not.have.property('Runtime'); const imageDigest = functionServiceConfig.image.slice( functionServiceConfig.image.lastIndexOf('@') + 1 ); expect(imageDigest).to.match(/^sha256:[a-f0-9]{64}$/); const imageDigestSha = imageDigest.slice('sha256:'.length); const versionCfConfig = findVersionCfConfig(cfResources, functionCfLogicalId); expect(versionCfConfig.CodeSha256).to.equal(imageDigestSha); }); it('should support `functions[].image` with explicit uri with sha', () => { const functionServiceConfig = serviceConfig.functions.fnImageWithExplicitUri; const functionCfLogicalId = naming.getLambdaLogicalId('fnImageWithExplicitUri'); const functionCfConfig = cfResources[functionCfLogicalId].Properties; expect(functionCfConfig.Code).to.deep.equal({ ImageUri: functionServiceConfig.image.uri }); expect(functionCfConfig).to.not.have.property('Handler'); expect(functionCfConfig).to.not.have.property('Runtime'); const imageDigest = functionServiceConfig.image.uri.slice( functionServiceConfig.image.uri.lastIndexOf('@') + 1 ); expect(imageDigest).to.match(/^sha256:[a-f0-9]{64}$/); const imageDigestSha = imageDigest.slice('sha256:'.length); const versionCfConfig = findVersionCfConfig(cfResources, functionCfLogicalId); expect(versionCfConfig.CodeSha256).to.equal(imageDigestSha); }); it('should support `functions[].image` with tag', () => { const functionServiceConfig = serviceConfig.functions.fnImageWithTag; const functionCfLogicalId = naming.getLambdaLogicalId('fnImageWithTag'); const functionCfConfig = cfResources[functionCfLogicalId].Properties; expect(functionCfConfig.Code).to.deep.equal({ ImageUri: `${functionServiceConfig.image.split(':')[0]}@${imageDigestFromECR}`, }); expect(functionCfConfig).to.not.have.property('Handler'); expect(functionCfConfig).to.not.have.property('Runtime'); const versionCfConfig = findVersionCfConfig(cfResources, functionCfLogicalId); expect(versionCfConfig.CodeSha256).to.equal(imageDigestFromECR.slice('sha256:'.length)); expect(describeImagesStub).to.be.calledWith({ imageIds: [{ imageTag: 'stable' }], registryId: '000000000000', repositoryName: 'test-lambda-docker', }); }); it('should support `functions[].image` with tag and repository name with slash', () => { const functionServiceConfig = serviceConfig.functions.fnImageWithTagAndRepoWithSlashes; const functionCfLogicalId = naming.getLambdaLogicalId('fnImageWithTagAndRepoWithSlashes'); const functionCfConfig = cfResources[functionCfLogicalId].Properties; expect(functionCfConfig.Code).to.deep.equal({ ImageUri: `${functionServiceConfig.image.split(':')[0]}@${imageDigestFromECR}`, }); expect(functionCfConfig).to.not.have.property('Handler'); expect(functionCfConfig).to.not.have.property('Runtime'); const versionCfConfig = findVersionCfConfig(cfResources, functionCfLogicalId); expect(versionCfConfig.CodeSha256).to.equal(imageDigestFromECR.slice('sha256:'.length)); expect(describeImagesStub).to.be.calledWith({ imageIds: [{ imageTag: 'stable' }], registryId: '000000000000', repositoryName: 'test-lambda/repo-docker', }); }); it('should support `functions[].image` that references provider.ecr.images defined with explicit uri', () => { const functionCfLogicalId = naming.getLambdaLogicalId('fnProviderImageWithExplicitUri'); const functionCfConfig = cfResources[functionCfLogicalId].Properties; expect(functionCfConfig.Code).to.deep.equal({ ImageUri: imageWithSha, }); expect(functionCfConfig).to.not.have.property('Handler'); expect(functionCfConfig).to.not.have.property('Runtime'); const versionCfConfig = findVersionCfConfig(cfResources, functionCfLogicalId); expect(versionCfConfig.CodeSha256).to.equal(imageSha); }); it('should support `functions[].image` that references provider.ecr.images defined with implicit uri', () => { const functionCfLogicalId = naming.getLambdaLogicalId('fnProviderImageWithImplicitUri'); const functionCfConfig = cfResources[functionCfLogicalId].Properties; expect(functionCfConfig.Code).to.deep.equal({ ImageUri: imageWithSha, }); expect(functionCfConfig).to.not.have.property('Handler'); expect(functionCfConfig).to.not.have.property('Runtime'); const versionCfConfig = findVersionCfConfig(cfResources, functionCfLogicalId); expect(versionCfConfig.CodeSha256).to.equal(imageSha); }); }); describe('with `functions[].image` referencing images that require building', () => { const imageSha = '6bb600b4d6e1d7cf521097177dd0c4e9ea373edb91984a505333be8ac9455d38'; const repositoryUri = '999999999999.dkr.ecr.sa-east-1.amazonaws.com/test-lambda-docker'; const authorizationToken = 'YXdzOmRvY2tlcmF1dGh0b2tlbg=='; const proxyEndpoint = `https://${repositoryUri}`; const describeRepositoriesStub = sinon.stub(); const createRepositoryStub = sinon.stub(); const baseAwsRequestStubMap = { STS: { getCallerIdentity: { ResponseMetadata: { RequestId: 'ffffffff-ffff-ffff-ffff-ffffffffffff' }, UserId: 'XXXXXXXXXXXXXXXXXXXXX', Account: '999999999999', Arn: 'arn:aws:iam::999999999999:user/test', }, }, ECR: { describeRepositories: { repositories: [{ repositoryUri }], }, getAuthorizationToken: { authorizationData: [ { proxyEndpoint, authorizationToken, }, ], }, }, }; const spawnExtStub = sinon.stub().returns({ stdBuffer: `digest: sha256:${imageSha} size: 1787`, }); const modulesCacheStub = { 'child-process-ext/spawn': spawnExtStub, './lib/utils/telemetry/generatePayload.js': async () => ({}), }; beforeEach(() => { describeRepositoriesStub.reset(); createRepositoryStub.reset(); spawnExtStub.resetHistory(); }); it('should work correctly when repository exists beforehand', async () => { const awsRequestStubMap = { ...baseAwsRequestStubMap, ECR: { ...baseAwsRequestStubMap.ECR, describeRepositories: describeRepositoriesStub.resolves({ repositories: [{ repositoryUri }], }), createRepository: createRepositoryStub, }, }; const { awsNaming, cfTemplate, fixtureData: { servicePath: serviceDir }, } = await runServerless({ fixture: 'ecr', command: 'package', awsRequestStubMap, modulesCacheStub, }); const functionCfLogicalId = awsNaming.getLambdaLogicalId('foo'); const functionCfConfig = cfTemplate.Resources[functionCfLogicalId].Properties; const versionCfConfig = findVersionCfConfig(cfTemplate.Resources, functionCfLogicalId); expect(functionCfConfig.Code.ImageUri).to.deep.equal(`${repositoryUri}@sha256:${imageSha}`); expect(versionCfConfig.CodeSha256).to.equal(imageSha); expect(describeRepositoriesStub).to.be.calledOnce; expect(createRepositoryStub.notCalled).to.be.true; expect(spawnExtStub).to.be.calledWith('docker', ['--version']); expect(spawnExtStub).not.to.be.calledWith('docker', [ 'login', '--username', 'AWS', '--password', 'dockerauthtoken', proxyEndpoint, ]); expect(spawnExtStub).to.be.calledWith('docker', [ 'build', '-t', `${awsNaming.getEcrRepositoryName()}:baseimage`, '-f', path.join(serviceDir, 'Dockerfile'), './', ]); expect(spawnExtStub).to.be.calledWith('docker', [ 'tag', `${awsNaming.getEcrRepositoryName()}:baseimage`, `${repositoryUri}:baseimage`, ]); expect(spawnExtStub).to.be.calledWith('docker', ['push', `${repositoryUri}:baseimage`]); }); it('should work correctly when repository does not exist beforehand', async () => { const awsRequestStubMap = { ...baseAwsRequestStubMap, ECR: { ...baseAwsRequestStubMap.ECR, describeRepositories: describeRepositoriesStub.throws({ providerError: { code: 'RepositoryNotFoundException' }, }), createRepository: createRepositoryStub.resolves({ repository: { repositoryUri } }), }, }; const { awsNaming, cfTemplate } = await runServerless({ fixture: 'ecr', command: 'package', awsRequestStubMap, modulesCacheStub, }); const functionCfLogicalId = awsNaming.getLambdaLogicalId('foo'); const functionCfConfig = cfTemplate.Resources[functionCfLogicalId].Properties; const versionCfConfig = findVersionCfConfig(cfTemplate.Resources, functionCfLogicalId); expect(functionCfConfig.Code.ImageUri).to.deep.equal(`${repositoryUri}@sha256:${imageSha}`); expect(versionCfConfig.CodeSha256).to.equal(imageSha); expect(describeRepositoriesStub).to.be.calledOnce; expect(createRepositoryStub).to.be.calledOnce; }); it('should login and retry when docker push fails with no basic auth credentials error', async () => { const awsRequestStubMap = { ...baseAwsRequestStubMap, ECR: { ...baseAwsRequestStubMap.ECR, describeRepositories: describeRepositoriesStub.resolves({ repositories: [{ repositoryUri }], }), createRepository: createRepositoryStub, }, }; const innerSpawnExtStub = sinon .stub() .returns({ stdBuffer: `digest: sha256:${imageSha} size: 1787`, }) .onCall(3) .throws({ stdBuffer: 'no basic auth credentials' }); const { awsNaming, cfTemplate, fixtureData: { servicePath: serviceDir }, } = await runServerless({ fixture: 'ecr', command: 'package', awsRequestStubMap, modulesCacheStub: { ...modulesCacheStub, 'child-process-ext/spawn': innerSpawnExtStub, }, }); const functionCfLogicalId = awsNaming.getLambdaLogicalId('foo'); const functionCfConfig = cfTemplate.Resources[functionCfLogicalId].Properties; const versionCfConfig = findVersionCfConfig(cfTemplate.Resources, functionCfLogicalId); expect(functionCfConfig.Code.ImageUri).to.deep.equal(`${repositoryUri}@sha256:${imageSha}`); expect(versionCfConfig.CodeSha256).to.equal(imageSha); expect(describeRepositoriesStub).to.be.calledOnce; expect(createRepositoryStub.notCalled).to.be.true; expect(innerSpawnExtStub).to.be.calledWith('docker', ['--version']); expect(innerSpawnExtStub).to.be.calledWith('docker', [ 'build', '-t', `${awsNaming.getEcrRepositoryName()}:baseimage`, '-f', path.join(serviceDir, 'Dockerfile'), './', ]); expect(innerSpawnExtStub).to.be.calledWith('docker', [ 'tag', `${awsNaming.getEcrRepositoryName()}:baseimage`, `${repositoryUri}:baseimage`, ]); expect(innerSpawnExtStub).to.be.calledWith('docker', [ 'push', `${repositoryUri}:baseimage`, ]); expect(innerSpawnExtStub).to.be.calledWith('docker', [ 'login', '--username', 'AWS', '--password', 'dockerauthtoken', proxyEndpoint, ]); }); it('should login and retry when docker push fails with token has expired error', async () => { const awsRequestStubMap = { ...baseAwsRequestStubMap, ECR: { ...baseAwsRequestStubMap.ECR, describeRepositories: describeRepositoriesStub.resolves({ repositories: [{ repositoryUri }], }), createRepository: createRepositoryStub, }, }; const innerSpawnExtStub = sinon .stub() .returns({ stdBuffer: `digest: sha256:${imageSha} size: 1787`, }) .onCall(3) .throws({ stdBuffer: 'authorization token has expired' }); await runServerless({ fixture: 'ecr', command: 'package', awsRequestStubMap, modulesCacheStub: { ...modulesCacheStub, 'child-process-ext/spawn': innerSpawnExtStub, }, }); expect(innerSpawnExtStub).to.be.calledWith('docker', [ 'push', `${repositoryUri}:baseimage`, ]); expect(innerSpawnExtStub).to.be.calledWith('docker', [ 'login', '--username', 'AWS', '--password', 'dockerauthtoken', proxyEndpoint, ]); }); it('should emit warning if docker login stores unencrypted credentials', async () => { const awsRequestStubMap = { ...baseAwsRequestStubMap, ECR: { ...baseAwsRequestStubMap.ECR, describeRepositories: describeRepositoriesStub.resolves({ repositories: [{ repositoryUri }], }), createRepository: createRepositoryStub, }, }; const { stdoutData } = await runServerless({ fixture: 'ecr', command: 'package', awsRequestStubMap, modulesCacheStub: { ...modulesCacheStub, 'child-process-ext/spawn': sinon .stub() .returns({ stdBuffer: `digest: sha256:${imageSha} size: 1787`, }) .onCall(3) .throws({ stdBuffer: 'no basic auth credentials' }) .onCall(4) .returns({ stdBuffer: 'your password will be stored unencrypted' }), }, }); expect(stdoutData).to.include( 'WARNING: Docker authentication token will be stored unencrypted in docker config.' ); }); it('should work correctly when image is defined with implicit path in provider', async () => { const awsRequestStubMap = { ...baseAwsRequestStubMap, ECR: { ...baseAwsRequestStubMap.ECR, describeRepositories: describeRepositoriesStub.resolves({ repositories: [{ repositoryUri }], }), createRepository: createRepositoryStub, }, }; const { awsNaming, cfTemplate } = await runServerless({ fixture: 'ecr', command: 'package', awsRequestStubMap, modulesCacheStub, configExt: { provider: { ecr: { images: { baseimage: './', }, }, }, }, }); const functionCfLogicalId = awsNaming.getLambdaLogicalId('foo'); const functionCfConfig = cfTemplate.Resources[functionCfLogicalId].Properties; const versionCfConfig = Object.values(cfTemplate.Resources).find( (resource) => resource.Type === 'AWS::Lambda::Version' && resource.Properties.FunctionName.Ref === functionCfLogicalId ).Properties; expect(functionCfConfig.Code.ImageUri).to.deep.equal(`${repositoryUri}@sha256:${imageSha}`); expect(versionCfConfig.CodeSha256).to.equal(imageSha); expect(describeRepositoriesStub).to.be.calledOnce; expect(createRepositoryStub.notCalled).to.be.true; }); it('should work correctly when image is defined with `file` set', async () => { const awsRequestStubMap = { ...baseAwsRequestStubMap, ECR: { ...baseAwsRequestStubMap.ECR, describeRepositories: describeRepositoriesStub.resolves({ repositories: [{ repositoryUri }], }), createRepository: createRepositoryStub, }, }; const { awsNaming, cfTemplate, fixtureData: { servicePath: serviceDir }, } = await runServerless({ fixture: 'ecr', command: 'package', awsRequestStubMap, modulesCacheStub, configExt: { provider: { ecr: { images: { baseimage: { path: './', file: 'Dockerfile.dev', }, }, }, }, }, }); const functionCfLogicalId = awsNaming.getLambdaLogicalId('foo'); const functionCfConfig = cfTemplate.Resources[functionCfLogicalId].Properties; const versionCfConfig = Object.values(cfTemplate.Resources).find( (resource) => resource.Type === 'AWS::Lambda::Version' && resource.Properties.FunctionName.Ref === functionCfLogicalId ).Properties; expect(functionCfConfig.Code.ImageUri).to.deep.equal(`${repositoryUri}@sha256:${imageSha}`); expect(versionCfConfig.CodeSha256).to.equal(imageSha); expect(describeRepositoriesStub).to.be.calledOnce; expect(createRepositoryStub.notCalled).to.be.true; expect(spawnExtStub).to.be.calledWith('docker', [ 'build', '-t', `${awsNaming.getEcrRepositoryName()}:baseimage`, '-f', path.join(serviceDir, 'Dockerfile.dev'), './', ]); }); it('should work correctly when image is defined with `cacheFrom` set', async () => { const awsRequestStubMap = { ...baseAwsRequestStubMap, ECR: { ...baseAwsRequestStubMap.ECR, describeRepositories: describeRepositoriesStub.resolves({ repositories: [{ repositoryUri }], }), createRepository: createRepositoryStub, }, }; const { awsNaming, cfTemplate, fixtureData: { servicePath: serviceDir }, } = await runServerless({ fixture: 'ecr', command: 'package', awsRequestStubMap, modulesCacheStub, configExt: { provider: { ecr: { images: { baseimage: { path: './', file: 'Dockerfile.dev', cacheFrom: ['my-image:latest'], }, }, }, }, }, }); const functionCfLogicalId = awsNaming.getLambdaLogicalId('foo'); const functionCfConfig = cfTemplate.Resources[functionCfLogicalId].Properties; const versionCfConfig = Object.values(cfTemplate.Resources).find( (resource) => resource.Type === 'AWS::Lambda::Version' && resource.Properties.FunctionName.Ref === functionCfLogicalId ).Properties; expect(functionCfConfig.Code.ImageUri).to.deep.equal(`${repositoryUri}@sha256:${imageSha}`); expect(versionCfConfig.CodeSha256).to.equal(imageSha); expect(describeRepositoriesStub).to.be.calledOnce; expect(createRepositoryStub.notCalled).to.be.true; expect(spawnExtStub).to.be.calledWith('docker', [ 'build', '-t', `${awsNaming.getEcrRepositoryName()}:baseimage`, '-f', path.join(serviceDir, 'Dockerfile.dev'), '--cache-from', 'my-image:latest', './', ]); }); it('should work correctly when image is defined with `buildArgs` set', async () => { const awsRequestStubMap = { ...baseAwsRequestStubMap, ECR: { ...baseAwsRequestStubMap.ECR, describeRepositories: describeRepositoriesStub.resolves({ repositories: [{ repositoryUri }], }), createRepository: createRepositoryStub, }, }; const { awsNaming, cfTemplate, fixtureData: { servicePath: serviceDir }, } = await runServerless({ fixture: 'ecr', command: 'package', awsRequestStubMap, modulesCacheStub, configExt: { provider: { ecr: { images: { baseimage: { path: './', file: 'Dockerfile.dev', buildArgs: { TESTKEY: 'TESTVAL', }, }, }, }, }, }, }); const functionCfLogicalId = awsNaming.getLambdaLogicalId('foo'); const functionCfConfig = cfTemplate.Resources[functionCfLogicalId].Properties; const versionCfConfig = Object.values(cfTemplate.Resources).find( (resource) => resource.Type === 'AWS::Lambda::Version' && resource.Properties.FunctionName.Ref === functionCfLogicalId ).Properties; expect(functionCfConfig.Code.ImageUri).to.deep.equal(`${repositoryUri}@sha256:${imageSha}`); expect(versionCfConfig.CodeSha256).to.equal(imageSha); expect(describeRepositoriesStub).to.be.calledOnce; expect(createRepositoryStub.notCalled).to.be.true; expect(spawnExtStub).to.be.calledWith('docker', [ 'build', '-t', `${awsNaming.getEcrRepositoryName()}:baseimage`, '-f', path.join(serviceDir, 'Dockerfile.dev'), '--build-arg', 'TESTKEY=TESTVAL', './', ]); }); it('should work correctly when `functions[].image` is defined with explicit name', async () => { const awsRequestStubMap = { ...baseAwsRequestStubMap, ECR: { ...baseAwsRequestStubMap.ECR, describeRepositories: describeRepositoriesStub.resolves({ repositories: [{ repositoryUri }], }), createRepository: createRepositoryStub, }, }; const { awsNaming, cfTemplate } = await runServerless({ fixture: 'ecr', command: 'package', awsRequestStubMap, modulesCacheStub, configExt: { provider: { ecr: { images: { baseimage: './', }, }, }, functions: { foo: { image: { name: 'baseimage', }, }, }, }, }); const functionCfLogicalId = awsNaming.getLambdaLogicalId('foo'); const functionCfConfig = cfTemplate.Resources[functionCfLogicalId].Properties; const versionCfConfig = findVersionCfConfig(cfTemplate.Resources, functionCfLogicalId); expect(functionCfConfig.Code.ImageUri).to.deep.equal(`${repositoryUri}@sha256:${imageSha}`); expect(versionCfConfig.CodeSha256).to.equal(imageSha); }); it('should fail when docker command is not available', async () => { await expect( runServerless({ fixture: 'ecr', command: 'package', awsRequestStubMap: baseAwsRequestStubMap, modulesCacheStub: { 'child-process-ext/spawn': sinon.stub().throws(), }, }) ).to.be.eventually.rejected.and.have.property('code', 'DOCKER_COMMAND_NOT_AVAILABLE'); }); it('should fail when docker build fails', async () => { await expect( runServerless({ fixture: 'ecr', command: 'package', awsRequestStubMap: baseAwsRequestStubMap, modulesCacheStub: { ...modulesCacheStub, 'child-process-ext/spawn': sinon.stub().returns({}).onSecondCall().throws(), }, }) ).to.be.eventually.rejected.and.have.property('code', 'DOCKER_BUILD_ERROR'); }); it('should fail when docker tag fails', async () => { await expect( runServerless({ fixture: 'ecr', command: 'package', awsRequestStubMap: baseAwsRequestStubMap, modulesCacheStub: { ...modulesCacheStub, 'child-process-ext/spawn': sinon.stub().returns({}).onCall(2).throws(), }, }) ).to.be.eventually.rejected.and.have.property('code', 'DOCKER_TAG_ERROR'); }); it('should fail when docker push fails', async () => { await expect( runServerless({ fixture: 'ecr', command: 'package', awsRequestStubMap: baseAwsRequestStubMap, modulesCacheStub: { ...modulesCacheStub, 'child-process-ext/spawn': sinon.stub().returns({}).onCall(3).throws(), }, }) ).to.be.eventually.rejected.and.have.property('code', 'DOCKER_PUSH_ERROR'); }); it('should fail when docker login fails', async () => { await expect( runServerless({ fixture: 'ecr', command: 'package', awsRequestStubMap: baseAwsRequestStubMap, modulesCacheStub: { ...modulesCacheStub, 'child-process-ext/spawn': sinon .stub() .returns({}) .onCall(3) .throws({ stdBuffer: 'no basic auth credentials' }) .onCall(4) .throws(), }, }) ).to.be.eventually.rejected.and.have.property('code', 'DOCKER_LOGIN_ERROR'); }); }); }); });