├── .eslintrc.yml ├── .github └── workflows │ └── test.yml ├── .gitignore ├── .npmignore ├── LICENSE.md ├── README.md ├── index.js ├── package-lock.json ├── package.json └── test └── index.js /.eslintrc.yml: -------------------------------------------------------------------------------- 1 | 2 | env: 3 | es6: true 4 | node: true 5 | extends: 'eslint:recommended' 6 | parserOptions: 7 | sourceType: module 8 | rules: 9 | indent: 10 | - error 11 | - 2 12 | linebreak-style: 13 | - error 14 | - unix 15 | quotes: 16 | - error 17 | - single 18 | semi: 19 | - error 20 | - always 21 | no-console: 0 22 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Node.js CI 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | lint: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v2 16 | 17 | - name: Setup Node.js 18 | uses: actions/setup-node@v2 19 | with: 20 | node-version: 16.x 21 | cache: 'npm' 22 | 23 | - name: Run eslint 24 | run: | 25 | npm ci 26 | npm run lint 27 | 28 | build: 29 | runs-on: ubuntu-latest 30 | 31 | strategy: 32 | matrix: 33 | node-version: [10.x, 12.x, 14.x, 16.x] 34 | 35 | steps: 36 | - name: Checkout 37 | uses: actions/checkout@v2 38 | 39 | - name: Setup Node.js ${{ matrix.node-version }} 40 | uses: actions/setup-node@v2 41 | with: 42 | node-version: ${{ matrix.node-version }} 43 | cache: 'npm' 44 | 45 | - name: Run tests 46 | run: | 47 | npm ci 48 | npm run cover 49 | 50 | - name: Publish coverage to Coveralls 51 | uses: coverallsapp/github-action@master 52 | with: 53 | github-token: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | coverage 2 | node_modules 3 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | coverage 2 | test 3 | .eslintrc.yml 4 | .travis.yml -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Clay Gregory 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # Serverless Prune Plugin 3 | 4 | Following deployment, the Serverless Framework does not purge previous versions of functions from AWS, so the number of deployed versions can grow out of hand rather quickly. This plugin allows pruning of all but the most recent version(s) of managed functions from AWS. This plugin is compatible with Serverless 1.x and higher. 5 | 6 | [![Serverless](http://public.serverless.com/badges/v3.svg)](http://www.serverless.com) 7 | [![Coverage Status](https://coveralls.io/repos/github/claygregory/serverless-prune-plugin/badge.svg?branch=master)](https://coveralls.io/github/claygregory/serverless-prune-plugin?branch=master) 8 | 9 | ## Installation 10 | 11 | Install with **npm**: 12 | ```sh 13 | npm install --save-dev serverless-prune-plugin 14 | ``` 15 | 16 | And then add the plugin to your `serverless.yml` file: 17 | ```yaml 18 | plugins: 19 | - serverless-prune-plugin 20 | ``` 21 | 22 | Alternatively, install with the Serverless **plugin command** (Serverless Framework 1.22 or higher): 23 | ```sh 24 | sls plugin install -n serverless-prune-plugin 25 | ``` 26 | 27 | ## Usage 28 | 29 | In the project root, run: 30 | ```sh 31 | sls prune -n 32 | ``` 33 | 34 | This will delete all but the `n`-most recent versions of each function deployed. Versions referenced by an alias are automatically preserved. 35 | 36 | ### Single Function 37 | 38 | A single function can be targeted for cleanup: 39 | ```sh 40 | sls prune -n -f helloWorld 41 | ``` 42 | 43 | ### Region/Stage 44 | 45 | The previous usage examples prune the default stage in the default region. Use `--stage` and `--region` to specify: 46 | ```sh 47 | sls prune -n --stage production --region eu-central-1 48 | ``` 49 | 50 | ### Automatic Pruning 51 | 52 | This plugin can also be configured to run automatically, following a deployment. Configuration of automatic pruning is within the `custom` property of `serverless.yml`. For example: 53 | 54 | ```yaml 55 | custom: 56 | prune: 57 | automatic: true 58 | number: 3 59 | ``` 60 | 61 | To run automatically, the `automatic` property of `prune` must be set to `true` and the `number` of versions to keep must be specified. 62 | It is possible to set `number` to `0`. In this case, the plugin will delete all the function versions (except $LATEST); this is useful when disabling function versioning for an already-deployed stack. 63 | 64 | ### Layers 65 | 66 | This plugin can also prune Lambda Layers in the same manner that it prunes functions. You can specify a Lambda Layer, or add the flag, `includeLayers`: 67 | 68 | ```yaml 69 | custom: 70 | prune: 71 | automatic: true 72 | includeLayers: true 73 | number: 3 74 | ``` 75 | 76 | ### Dry Run 77 | 78 | A dry-run will preview the deletion candidates, without actually performing the pruning operations: 79 | ```sh 80 | sls prune -n --dryRun 81 | ``` 82 | 83 | ### Additional Help 84 | 85 | See: 86 | ```sh 87 | sls prune --help 88 | ``` 89 | 90 | ## Permissions Required 91 | 92 | To run this plugin, the user will need to be allowed the following permissions in AWS: 93 | - `lambda:listAliases` 94 | - `lambda:listVersionsByFunction` 95 | - `lambda:deleteFunction` 96 | - `lambda:listLayerVersions` 97 | - `lambda:deleteLayerVersion` 98 | 99 | ## Common Questions 100 | 101 | **How do I set up different pruning configurations per region/stage?** 102 | 103 | Several suggestions are available in [this thread](https://github.com/claygregory/serverless-prune-plugin/issues/21#issuecomment-622651886). 104 | 105 | **Can I just disable versioning entirely?** 106 | 107 | Absolutely. While Serverless Framework has it enabled by default, [versioning can be disabled](https://www.serverless.com/framework/docs/providers/aws/guide/functions/#versioning-deployed-functions). 108 | 109 | ## License 110 | 111 | Copyright (c) 2017 [Clay Gregory](https://claygregory.com). See the included [LICENSE](LICENSE.md) for rights and limitations under the terms of the MIT license. 112 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const BbPromise = require('bluebird'); 4 | 5 | class Prune { 6 | constructor(serverless, options, { log, progress } = {}) { 7 | this.serverless = serverless; 8 | this.options = options || {}; 9 | this.provider = this.serverless.getProvider('aws'); 10 | this.log = log || serverless.cli.log.bind(serverless.cli); 11 | this.progress = progress; 12 | 13 | this.pluginCustom = this.loadCustom(this.serverless.service.custom); 14 | 15 | this.commands = { 16 | prune: { 17 | usage: 'Clean up deployed functions and/or layers by deleting older versions.', 18 | lifecycleEvents: ['prune'], 19 | options: { 20 | number: { 21 | usage: 'Number of previous versions to keep', 22 | shortcut: 'n', 23 | required: true, 24 | type: 'string' 25 | }, 26 | stage: { 27 | usage: 'Stage of the service', 28 | shortcut: 's', 29 | type: 'string' 30 | }, 31 | region: { 32 | usage: 'Region of the service', 33 | shortcut: 'r', 34 | type: 'string' 35 | }, 36 | function: { 37 | usage: 'Function name. Limits cleanup to the specified function', 38 | shortcut: 'f', 39 | required: false, 40 | type: 'string' 41 | }, 42 | layer: { 43 | usage: 'Layer name. Limits cleanup to the specified Lambda layer', 44 | shortcut: 'l', 45 | required: false, 46 | type: 'string' 47 | }, 48 | includeLayers: { 49 | usage: 'Boolean flag. Includes the pruning of Lambda layers.', 50 | shortcut: 'i', 51 | required: false, 52 | type: 'boolean' 53 | }, 54 | dryRun: { 55 | usage: 'Simulate pruning without executing delete actions. Deletion candidates are logged when used in conjunction with --verbose', 56 | shortcut: 'd', 57 | required: false, 58 | type: 'boolean' 59 | }, 60 | verbose: { 61 | usage: 'Enable detailed output during plugin execution', 62 | required: false, 63 | type: 'boolean' 64 | } 65 | } 66 | }, 67 | }; 68 | 69 | this.hooks = { 70 | 'prune:prune': this.cliPrune.bind(this), 71 | 'after:deploy:deploy': this.postDeploy.bind(this) 72 | }; 73 | } 74 | 75 | getNumber() { 76 | return this.options.number || this.pluginCustom.number; 77 | } 78 | 79 | loadCustom(custom) { 80 | const pluginCustom = {}; 81 | if (custom && custom.prune) { 82 | 83 | if (custom.prune.number != null) { 84 | const number = parseInt(custom.prune.number); 85 | if (!isNaN(number)) pluginCustom.number = number; 86 | } 87 | 88 | if (typeof custom.prune.automatic === 'boolean') { 89 | pluginCustom.automatic = custom.prune.automatic; 90 | } 91 | 92 | if (typeof custom.prune.includeLayers === 'boolean') { 93 | pluginCustom.includeLayers = custom.prune.includeLayers; 94 | } 95 | } 96 | 97 | return pluginCustom; 98 | } 99 | 100 | cliPrune() { 101 | if (this.options.dryRun) { 102 | this.logNotice('Dry-run enabled, no pruning actions will be performed.'); 103 | } 104 | 105 | if(this.options.includeLayers) { 106 | return BbPromise.all([ 107 | this.pruneFunctions(), 108 | this.pruneLayers() 109 | ]); 110 | } 111 | 112 | if (this.options.layer && !this.options.function) { 113 | return this.pruneLayers(); 114 | } else { 115 | return this.pruneFunctions(); 116 | } 117 | } 118 | 119 | postDeploy() { 120 | this.pluginCustom = this.loadCustom(this.serverless.service.custom); 121 | 122 | if (this.options.noDeploy === true) { 123 | return BbPromise.resolve(); 124 | } 125 | 126 | if (this.pluginCustom.automatic && 127 | this.pluginCustom.number !== undefined && this.pluginCustom.number >= 0) { 128 | 129 | if(this.pluginCustom.includeLayers) { 130 | return BbPromise.all([ 131 | this.pruneFunctions(), 132 | this.pruneLayers() 133 | ]); 134 | } 135 | 136 | return this.pruneFunctions(); 137 | } else { 138 | return BbPromise.resolve(); 139 | } 140 | } 141 | 142 | pruneLayers() { 143 | const selectedLayers = this.options.layer ? [this.options.layer] : this.serverless.service.getAllLayers(); 144 | const layerNames = selectedLayers.map(key => this.serverless.service.getLayer(key).name || key); 145 | 146 | this.createProgress( 147 | 'prune-plugin-prune-layers', 148 | 'Pruning layer versions' 149 | ); 150 | 151 | return BbPromise.mapSeries(layerNames, layerName => { 152 | 153 | return BbPromise.join( 154 | this.listVersionsForLayer(layerName), 155 | (versions) => ({ name: layerName, versions: versions }) 156 | ); 157 | 158 | }).each(({ name, versions }) => { 159 | if (!versions.length) { 160 | return BbPromise.resolve(); 161 | } 162 | 163 | const deletionCandidates = this.selectPruneVersionsForLayer(versions); 164 | if (deletionCandidates.length > 0) { 165 | this.updateProgress('prune-plugin-prune-layers', `Pruning layer versions (${name})`); 166 | } 167 | 168 | if (this.options.dryRun) { 169 | this.printPruningCandidates(name, deletionCandidates); 170 | return BbPromise.resolve(); 171 | } else { 172 | return this.deleteVersionsForLayer(name, deletionCandidates); 173 | } 174 | }).then(() => { 175 | this.clearProgress('prune-plugin-prune-layers'); 176 | this.logSuccess('Pruning of layers complete'); 177 | }); 178 | } 179 | 180 | pruneFunctions() { 181 | const selectedFunctions = this.options.function ? [this.options.function] : this.serverless.service.getAllFunctions(); 182 | const functionNames = selectedFunctions.map(key => this.serverless.service.getFunction(key).name); 183 | 184 | this.createProgress( 185 | 'prune-plugin-prune-functions', 186 | 'Pruning function versions' 187 | ); 188 | 189 | return BbPromise.mapSeries(functionNames, functionName => { 190 | 191 | return BbPromise.join( 192 | this.listVersionForFunction(functionName), 193 | this.listAliasesForFunction(functionName), 194 | (versions, aliases) => ( { name: functionName, versions: versions, aliases: aliases } ) 195 | ); 196 | 197 | }).each(({ name, versions, aliases }) => { 198 | if (!versions.length) { 199 | return BbPromise.resolve(); 200 | } 201 | 202 | const deletionCandidates = this.selectPruneVersionsForFunction(versions, aliases); 203 | if (deletionCandidates.length > 0) { 204 | this.updateProgress('prune-plugin-prune-functions', `Pruning function versions (${name})`); 205 | } 206 | 207 | if (this.options.dryRun) { 208 | this.printPruningCandidates(name, deletionCandidates); 209 | return BbPromise.resolve(); 210 | } else { 211 | return this.deleteVersionsForFunction(name, deletionCandidates); 212 | } 213 | }).then(() => { 214 | this.clearProgress('prune-plugin-prune-functions'); 215 | this.logSuccess('Pruning of functions complete'); 216 | }); 217 | } 218 | 219 | deleteVersionsForLayer(layerName, versions) { 220 | return BbPromise.each(versions, version => { 221 | this.logInfo(`Deleting layer version ${layerName}:${version}.`); 222 | 223 | const params = { 224 | LayerName: layerName, 225 | VersionNumber: version 226 | }; 227 | 228 | return BbPromise.resolve() 229 | .then(() => this.provider.request('Lambda', 'deleteLayerVersion', params)) 230 | .catch(e => { 231 | throw e; 232 | }); 233 | }); 234 | } 235 | 236 | deleteVersionsForFunction(functionName, versions) { 237 | return BbPromise.each(versions, version => { 238 | this.logInfo(`Deleting function version ${functionName}:${version}.`); 239 | 240 | const params = { 241 | FunctionName: functionName, 242 | Qualifier: version 243 | }; 244 | 245 | return BbPromise.resolve() 246 | .then(() => this.provider.request('Lambda', 'deleteFunction', params)) 247 | .catch(e => { 248 | //ignore if trying to delete replicated lambda edge function 249 | if (e.providerError && e.providerError.statusCode === 400 250 | && e.providerError.message.startsWith('Lambda was unable to delete') 251 | && e.providerError.message.indexOf('because it is a replicated function.') > -1) { 252 | this.logWarning(`Unable to delete replicated Lambda@Edge function version ${functionName}:${version}.`); 253 | } else { 254 | throw e; 255 | } 256 | }); 257 | }); 258 | } 259 | 260 | listAliasesForFunction(functionName) { 261 | const params = { 262 | FunctionName: functionName 263 | }; 264 | 265 | return this.makeLambdaRequest('listAliases', params, r => r.Aliases) 266 | .catch(e => { 267 | //ignore if function not deployed 268 | if (e.providerError && e.providerError.statusCode === 404) return []; 269 | else throw e; 270 | }); 271 | } 272 | 273 | listVersionForFunction(functionName) { 274 | const params = { 275 | FunctionName: functionName 276 | }; 277 | 278 | return this.makeLambdaRequest('listVersionsByFunction', params, r => r.Versions) 279 | .catch(e => { 280 | //ignore if function not deployed 281 | if (e.providerError && e.providerError.statusCode === 404) return []; 282 | else throw e; 283 | }); 284 | } 285 | 286 | listVersionsForLayer(layerName) { 287 | const params = { 288 | LayerName: layerName 289 | }; 290 | 291 | return this.makeLambdaRequest('listLayerVersions', params, r => r.LayerVersions) 292 | .catch(e => { 293 | // ignore if layer not deployed 294 | if (e.providerError && e.providerError.statusCode === 404) return []; 295 | else throw e; 296 | }); 297 | 298 | } 299 | 300 | makeLambdaRequest(action, params, responseMapping) { 301 | const results = []; 302 | const responseHandler = response => { 303 | Array.prototype.push.apply(results, responseMapping(response)); 304 | 305 | if (response.NextMarker) { 306 | return this.provider.request('Lambda', action, Object.assign({}, params, { Marker: response.NextMarker })) 307 | .then(responseHandler); 308 | } else { 309 | return BbPromise.resolve(results); 310 | } 311 | }; 312 | 313 | return this.provider.request('Lambda', action, params) 314 | .then(responseHandler); 315 | } 316 | 317 | selectPruneVersionsForFunction(versions, aliases) { 318 | const aliasedVersion = aliases.map(a => a.FunctionVersion); 319 | 320 | return versions 321 | .map(f => f.Version) 322 | .filter(v => v !== '$LATEST') //skip $LATEST 323 | .filter(v => aliasedVersion.indexOf(v) === -1) //skip aliased versions 324 | .sort((a, b) => parseInt(a) === parseInt(b) ? 0 : parseInt(a) > parseInt(b) ? -1 : 1) 325 | .slice(this.getNumber()); 326 | } 327 | 328 | selectPruneVersionsForLayer(versions) { 329 | return versions 330 | .map(f => f.Version) 331 | .sort((a, b) => parseInt(a) === parseInt(b) ? 0 : parseInt(a) > parseInt(b) ? -1 : 1) 332 | .slice(this.getNumber()); 333 | } 334 | 335 | printPruningCandidates(name, deletionCandidates) { 336 | deletionCandidates.forEach(version => this.logInfo(`${name}:${version} selected for deletion.`)); 337 | } 338 | 339 | // -- Compatibility with both Framework 2.x and 3.x logging --- 340 | 341 | logInfo(message) { 342 | if (this.log.info) this.log.info(message); 343 | else this.log(`Prune: ${message}`); 344 | } 345 | 346 | logNotice(message) { 347 | if (this.log.notice) this.log.notice(message); 348 | else this.log(`Prune: ${message}`); 349 | } 350 | 351 | logWarning(message) { 352 | if (this.log.warning) this.log.warning(message); 353 | else this.log(`Prune: ${message}`); 354 | } 355 | 356 | logSuccess(message) { 357 | if (this.log.success) this.log.success(message); 358 | else this.log(`Prune: ${message}`); 359 | } 360 | 361 | createProgress(name, message) { 362 | if (!this.progress) { 363 | this.log(`Prune: ${message}...`); 364 | } else { 365 | this.progress.create({ 366 | message, 367 | name 368 | }); 369 | } 370 | } 371 | 372 | updateProgress(name, message) { 373 | if (!this.progress) { 374 | this.log(`Prune: ${message}`); 375 | } else { 376 | this.progress.get(name).update(message); 377 | } 378 | } 379 | 380 | clearProgress(name) { 381 | if (this.progress) { 382 | this.progress.get(name).remove(); 383 | } 384 | } 385 | } 386 | 387 | module.exports = Prune; 388 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "serverless-prune-plugin", 3 | "version": "2.1.0", 4 | "description": "Serverless plugin to delete old versions of deployed functions from AWS", 5 | "author": "Clay Gregory (https://claygregory.com/)", 6 | "keywords": [ 7 | "serverless", 8 | "serverless-plugin", 9 | "aws", 10 | "aws-lambda", 11 | "lambda" 12 | ], 13 | "repository": "claygregory/serverless-prune-plugin", 14 | "homepage": "https://github.com/claygregory/serverless-prune-plugin", 15 | "license": "MIT", 16 | "main": "index.js", 17 | "scripts": { 18 | "lint": "eslint ./index.js", 19 | "test": "mocha", 20 | "cover": "istanbul cover _mocha", 21 | "coveralls": "npm run cover -- --report lcovonly && cat ./coverage/lcov.info | coveralls" 22 | }, 23 | "dependencies": { 24 | "bluebird": "^3.7.2" 25 | }, 26 | "devDependencies": { 27 | "coveralls": "^3.1.1", 28 | "eslint": "^8.2.0", 29 | "istanbul": "^0.4.5", 30 | "mocha": "^9.1.3", 31 | "mocha-lcov-reporter": "^1.3.0", 32 | "sinon": "^12.0.1" 33 | }, 34 | "peerDependencies": { 35 | "serverless": "1.x || 2.x || 3.x || 4.x" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* eslint-env node, mocha */ 4 | 5 | const assert = require('assert'); 6 | const sinon = require('sinon'); 7 | 8 | const PrunePlugin = require('../'); 9 | 10 | describe('Prune', function() { 11 | 12 | function createMockServerlessWithLayers(layers, serviceCustom) { 13 | let serverless = createMockServerless([], serviceCustom); 14 | const { service } = serverless; 15 | 16 | const withLayers = Object.assign( 17 | serverless.service, 18 | service, 19 | { 20 | getAllLayers: () => layers, 21 | getLayer: (key) => { return { name: `layer-${key}` }; }, 22 | } 23 | ); 24 | 25 | return Object.assign(serverless, withLayers); 26 | } 27 | 28 | function createMockServerless(functions, serviceCustom) { 29 | const serverless = { 30 | getProvider: sinon.stub(), 31 | cli: { log: sinon.stub() }, 32 | service: { 33 | getAllFunctions: () => functions, 34 | getFunction: (key) => { return { name:`service-${key}` }; }, 35 | custom: serviceCustom 36 | } 37 | }; 38 | const provider = { request: sinon.stub() }; 39 | serverless.getProvider.withArgs('aws').returns(provider); 40 | 41 | return serverless; 42 | } 43 | 44 | function createAliasResponse(versions) { 45 | 46 | const resp = { }; 47 | resp.Aliases = versions.concat(['$LATEST']).map(v => { 48 | return { 49 | FunctionVersion: '' + v, 50 | Description: `Alias v${v}` 51 | }; 52 | }); 53 | 54 | return Promise.resolve(resp); 55 | } 56 | 57 | function createVersionsResponse(aliasedVersions) { 58 | 59 | const resp = {}; 60 | resp.Versions = aliasedVersions.map(v => { 61 | return { 62 | Version: '' + v, 63 | Description: `Alias v${v}` 64 | }; 65 | }); 66 | 67 | return Promise.resolve(resp); 68 | } 69 | 70 | function createLayerVersionsResponse(versions) { 71 | const resp = {}; 72 | 73 | resp.LayerVersions = versions.map(v => { 74 | return { 75 | Version: '' + v 76 | }; 77 | }); 78 | 79 | return Promise.resolve(resp); 80 | } 81 | 82 | describe('constructor', function() { 83 | 84 | it('should assign correct properties', function() { 85 | 86 | const serverlessStub = createMockServerless([], null); 87 | 88 | const provider = { aws: 'provider' }; 89 | const options = { option: 'a' }; 90 | serverlessStub.getProvider.withArgs('aws').returns(provider); 91 | 92 | const plugin = new PrunePlugin(serverlessStub, options); 93 | 94 | assert.strictEqual(serverlessStub, plugin.serverless); 95 | assert.strictEqual(options, plugin.options); 96 | 97 | assert(serverlessStub.getProvider.calledOnce); 98 | assert(serverlessStub.getProvider.calledWithExactly('aws')); 99 | }); 100 | 101 | it('should assign any serverless.yml configured options', function() { 102 | 103 | const serverlessStub = createMockServerless([], { 104 | prune: { 105 | automatic: true, 106 | number: 5 107 | } 108 | }); 109 | 110 | const plugin = new PrunePlugin(serverlessStub, {}); 111 | 112 | assert(plugin.pluginCustom); 113 | assert.equal(5, plugin.pluginCustom.number); 114 | assert.equal(true, plugin.pluginCustom.automatic); 115 | 116 | assert.equal(5, plugin.getNumber()); 117 | }); 118 | 119 | it('should set up event hooks', function() { 120 | 121 | const serverlessStub = createMockServerless([], null); 122 | 123 | const plugin = new PrunePlugin(serverlessStub, {}); 124 | 125 | assert(plugin.commands.prune); 126 | assert(plugin.commands.prune.lifecycleEvents.indexOf('prune') >= 0); 127 | assert.equal('function', typeof plugin.hooks['prune:prune']); 128 | assert.equal('function', typeof plugin.hooks['after:deploy:deploy']); 129 | }); 130 | 131 | it('should prioritize CLI provided n over serverless.yml value', function() { 132 | 133 | const serverlessStub = createMockServerless([], { 134 | prune: { automatic: true, number: 5 } 135 | }); 136 | 137 | const plugin = new PrunePlugin(serverlessStub, { number: 7 }); 138 | 139 | assert(plugin.pluginCustom); 140 | assert.equal(5, plugin.pluginCustom.number); 141 | assert.equal(7, plugin.getNumber()); 142 | }); 143 | 144 | }); 145 | 146 | describe('deleteVersionsForFunction', function() { 147 | 148 | let serverless; 149 | let plugin; 150 | beforeEach(function() { 151 | serverless = createMockServerless(); 152 | plugin = new PrunePlugin(serverless, {}); 153 | }); 154 | 155 | it('should request deletions for each provided version of function', function() { 156 | const versionMatcher = (ver) => sinon.match({ 157 | FunctionName: 'MyFunction', 158 | Qualifier: ver 159 | }); 160 | 161 | return plugin.deleteVersionsForFunction('MyFunction', ['1', '2', '3']).then(() => { 162 | sinon.assert.callCount(plugin.provider.request, 3); 163 | sinon.assert.calledWith(plugin.provider.request, 'Lambda', 'deleteFunction', versionMatcher('1')); 164 | sinon.assert.calledWith(plugin.provider.request, 'Lambda', 'deleteFunction', versionMatcher('2')); 165 | sinon.assert.calledWith(plugin.provider.request, 'Lambda', 'deleteFunction', versionMatcher('3')); 166 | }); 167 | 168 | }); 169 | 170 | it('should not request deletions if provided versions array is empty', function() { 171 | 172 | return plugin.deleteVersionsForFunction('MyFunction', []).then(() => { 173 | sinon.assert.notCalled(plugin.provider.request); 174 | }); 175 | 176 | }); 177 | }); 178 | 179 | describe('deleteVersionsForFunction - Lambda@Edge', function() { 180 | 181 | let serverless; 182 | let plugin; 183 | beforeEach(function() { 184 | serverless = createMockServerless(); 185 | plugin = new PrunePlugin(serverless, {}); 186 | }); 187 | 188 | it('should ignore failure while deleting Lambda@Edge function', function(done) { 189 | 190 | plugin.provider.request.withArgs('Lambda', 'deleteFunction', sinon.match.any) 191 | .rejects({ providerError: { statusCode: 400, message: 'Lambda was unable to delete arn:aws:lambda:REGION:ACCOUNT_ID:function:FUNCTION_NAME:FUNCTION_VERSION because it is a replicated function. Please see our documentation for Deleting Lambda@Edge Functions and Replicas.' }}); 192 | 193 | plugin.deleteVersionsForFunction('MyEdgeFunction', [1]) 194 | .then(() => done()) 195 | .catch(() => done(new Error('shouldn\'t fail'))); 196 | 197 | }); 198 | 199 | it('should fail when error while deleting regular lambda function', function(done) { 200 | 201 | plugin.provider.request.withArgs('Lambda', 'deleteFunction', sinon.match.any) 202 | .rejects({ providerError: { statusCode: 400, message: 'Some Error' }}); 203 | 204 | plugin.deleteVersionsForFunction('MyFunction', [1]) 205 | .then(() => done(new Error('should fail'))) 206 | .catch(() => done()); 207 | 208 | }); 209 | }); 210 | 211 | describe('pruneFunctions', function() { 212 | 213 | const functionMatcher = (name) => sinon.match.has('FunctionName', name); 214 | const versionMatcher = (ver) => sinon.match.has('Qualifier', ver); 215 | 216 | it('should delete old versions of functions', function() { 217 | 218 | const serverless = createMockServerless(['FunctionA', 'FunctionB']); 219 | const plugin = new PrunePlugin(serverless, { number: 2 }); 220 | 221 | plugin.provider.request.withArgs('Lambda', 'listVersionsByFunction', sinon.match.any) 222 | .returns(createVersionsResponse([1, 2, 3, 4, 5])); 223 | 224 | plugin.provider.request.withArgs('Lambda', 'listAliases', sinon.match.any) 225 | .returns(createAliasResponse([])); 226 | 227 | return plugin.pruneFunctions().then(() => { 228 | sinon.assert.calledWith(plugin.provider.request, 'Lambda', 'deleteFunction', versionMatcher('1')); 229 | sinon.assert.calledWith(plugin.provider.request, 'Lambda', 'deleteFunction', versionMatcher('2')); 230 | sinon.assert.calledWith(plugin.provider.request, 'Lambda', 'deleteFunction', versionMatcher('3')); 231 | }); 232 | 233 | }); 234 | 235 | it('should keep requested number of version', function() { 236 | 237 | const serverless = createMockServerless(['FunctionA'], { 238 | prune: { automatic: true, number: 5 } 239 | }); 240 | const plugin = new PrunePlugin(serverless, { number: 3 }); 241 | 242 | plugin.provider.request.withArgs('Lambda', 'listVersionsByFunction', sinon.match.any) 243 | .returns(createVersionsResponse([1, 2, 3, 4])); 244 | 245 | plugin.provider.request.withArgs('Lambda', 'listAliases', sinon.match.any) 246 | .returns(createAliasResponse([])); 247 | 248 | return plugin.pruneFunctions().then(() => { 249 | sinon.assert.calledWith(plugin.provider.request, 'Lambda', 'deleteFunction', versionMatcher('1')); 250 | sinon.assert.neverCalledWith(plugin.provider.request, 'Lambda', 'deleteFunction', versionMatcher('2')); 251 | sinon.assert.neverCalledWith(plugin.provider.request, 'Lambda', 'deleteFunction', versionMatcher('3')); 252 | sinon.assert.neverCalledWith(plugin.provider.request, 'Lambda', 'deleteFunction', versionMatcher('4')); 253 | }); 254 | 255 | }); 256 | 257 | it('should not delete $LATEST version', function() { 258 | 259 | const serverless = createMockServerless(['FunctionA']); 260 | const plugin = new PrunePlugin(serverless, { number: 2 }); 261 | 262 | plugin.provider.request.withArgs('Lambda', 'listVersionsByFunction', sinon.match.any) 263 | .returns(createVersionsResponse([1, 2, 3, 4, 5])); 264 | 265 | plugin.provider.request.withArgs('Lambda', 'listAliases', sinon.match.any) 266 | .returns(createAliasResponse([1, 3])); 267 | 268 | return plugin.pruneFunctions().then(() => { 269 | sinon.assert.neverCalledWith(plugin.provider.request, 'Lambda', 'deleteFunction', versionMatcher('$LATEST')); 270 | }); 271 | 272 | }); 273 | 274 | it('should not delete aliased versions', function() { 275 | 276 | const serverless = createMockServerless(['FunctionA']); 277 | const plugin = new PrunePlugin(serverless, { number: 2 }); 278 | 279 | plugin.provider.request.withArgs('Lambda', 'listVersionsByFunction', sinon.match.any) 280 | .returns(createVersionsResponse([1, 2, 3, 4, 5])); 281 | 282 | plugin.provider.request.withArgs('Lambda', 'listAliases', sinon.match.any) 283 | .returns(createAliasResponse([1, 3, 4])); 284 | 285 | return plugin.pruneFunctions().then(() => { 286 | sinon.assert.neverCalledWith(plugin.provider.request, 'Lambda', 'deleteFunction', versionMatcher('1')); 287 | sinon.assert.neverCalledWith(plugin.provider.request, 'Lambda', 'deleteFunction', versionMatcher('3')); 288 | sinon.assert.neverCalledWith(plugin.provider.request, 'Lambda', 'deleteFunction', versionMatcher('5')); 289 | }); 290 | 291 | }); 292 | 293 | it('should always match delete requests to correct function', function() { 294 | 295 | const serverless = createMockServerless(['FunctionA', 'FunctionB']); 296 | const plugin = new PrunePlugin(serverless, { number: 2 }); 297 | 298 | plugin.provider.request.withArgs('Lambda', 'listVersionsByFunction', functionMatcher('service-FunctionA')) 299 | .returns(createVersionsResponse([1])); 300 | 301 | plugin.provider.request.withArgs('Lambda', 'listVersionsByFunction', functionMatcher('service-FunctionB')) 302 | .returns(createVersionsResponse([1, 2, 3, 4, 5])); 303 | 304 | plugin.provider.request.withArgs('Lambda', 'listAliases', sinon.match.any) 305 | .returns(createAliasResponse([])); 306 | 307 | return plugin.pruneFunctions().then(() => { 308 | sinon.assert.neverCalledWith(plugin.provider.request, 'Lambda', 'deleteFunction', functionMatcher('service-FunctionA')); 309 | sinon.assert.calledWith(plugin.provider.request, 'Lambda', 'deleteFunction', functionMatcher('service-FunctionB')); 310 | }); 311 | 312 | }); 313 | 314 | it('should ignore functions that are not deployed', function() { 315 | 316 | const serverless = createMockServerless(['FunctionA', 'FunctionB']); 317 | const plugin = new PrunePlugin(serverless, { number: 1 }); 318 | 319 | plugin.provider.request.withArgs('Lambda', 'listVersionsByFunction', functionMatcher('service-FunctionA')) 320 | .rejects({ providerError: { statusCode: 404 }}); 321 | 322 | plugin.provider.request.withArgs('Lambda', 'listAliases', functionMatcher('service-FunctionA')) 323 | .rejects({ providerError: { statusCode: 404 }}); 324 | 325 | plugin.provider.request.withArgs('Lambda', 'listVersionsByFunction', functionMatcher('service-FunctionB')) 326 | .returns(createVersionsResponse([1, 2, 3])); 327 | 328 | plugin.provider.request.withArgs('Lambda', 'listAliases', sinon.match.any) 329 | .returns(createAliasResponse([])); 330 | 331 | return plugin.pruneFunctions().then(() => { 332 | sinon.assert.neverCalledWith(plugin.provider.request, 'Lambda', 'deleteFunction', functionMatcher('service-FunctionA')); 333 | sinon.assert.calledWith(plugin.provider.request, 'Lambda', 'deleteFunction', functionMatcher('service-FunctionB')); 334 | }); 335 | 336 | }); 337 | 338 | it('should only operate on target function if specified from CLI', function() { 339 | 340 | const serverless = createMockServerless(['FunctionA', 'FunctionB', 'FunctionC']); 341 | const plugin = new PrunePlugin(serverless, { function: 'FunctionA', number: 1 }); 342 | 343 | plugin.provider.request.withArgs('Lambda', 'listVersionsByFunction', sinon.match.any) 344 | .returns(createVersionsResponse([1, 2, 3, 4, 5])); 345 | 346 | plugin.provider.request.withArgs('Lambda', 'listAliases', sinon.match.any) 347 | .returns(createAliasResponse([])); 348 | 349 | return plugin.pruneFunctions().then(() => { 350 | sinon.assert.calledWith(plugin.provider.request, 'Lambda', 'deleteFunction', functionMatcher('service-FunctionA')); 351 | sinon.assert.neverCalledWith(plugin.provider.request, 'Lambda', 'deleteFunction', functionMatcher('service-FunctionB')); 352 | }); 353 | 354 | }); 355 | 356 | it('should not perform any deletions if dryRun flag is set', function() { 357 | 358 | const serverless = createMockServerless(['FunctionA', 'FunctionB', 'FunctionC']); 359 | const plugin = new PrunePlugin(serverless, { number: 1, dryRun: true }); 360 | sinon.spy(plugin, 'deleteVersionsForFunction'); 361 | 362 | plugin.provider.request.withArgs('Lambda', 'listVersionsByFunction', sinon.match.any) 363 | .returns(createVersionsResponse([1, 2, 3, 4, 5])); 364 | 365 | plugin.provider.request.withArgs('Lambda', 'listAliases', sinon.match.any) 366 | .returns(createAliasResponse([])); 367 | 368 | return plugin.pruneFunctions().then(() => { 369 | sinon.assert.notCalled(plugin.deleteVersionsForFunction); 370 | }); 371 | 372 | }); 373 | 374 | }); 375 | 376 | describe('pruneLayers', function() { 377 | const layerMatcher = (name) => sinon.match.has('LayerName', name); 378 | const versionMatcher = (ver) => sinon.match.has('VersionNumber', ver); 379 | 380 | it('should delete old versions of layers', function () { 381 | 382 | const serverless = createMockServerlessWithLayers(['LayerA', 'LayerB'], { prune: { includeLayers: true }}); 383 | const plugin = new PrunePlugin(serverless, { number: 2, includeLayers: true }); 384 | 385 | plugin.provider.request.withArgs('Lambda', 'listLayerVersions', sinon.match.any) 386 | .returns(createLayerVersionsResponse([1, 2, 3, 4, 5])); 387 | 388 | return plugin.pruneLayers().then(() => { 389 | sinon.assert.calledWith(plugin.provider.request, 'Lambda', 'deleteLayerVersion', versionMatcher('1')); 390 | sinon.assert.calledWith(plugin.provider.request, 'Lambda', 'deleteLayerVersion', versionMatcher('2')); 391 | sinon.assert.calledWith(plugin.provider.request, 'Lambda', 'deleteLayerVersion', versionMatcher('3')); 392 | }); 393 | 394 | }); 395 | 396 | it('should keep requested number of version', function () { 397 | 398 | const serverless = createMockServerlessWithLayers(['LayerA'], { 399 | prune: { automatic: true, number: 5, includeLayers: true }, 400 | }); 401 | const plugin = new PrunePlugin(serverless, { number: 3 }); 402 | 403 | plugin.provider.request.withArgs('Lambda', 'listLayerVersions', sinon.match.any) 404 | .returns(createLayerVersionsResponse([1, 2, 3, 4])); 405 | 406 | return plugin.pruneLayers().then(() => { 407 | sinon.assert.calledWith(plugin.provider.request, 'Lambda', 'deleteLayerVersion', versionMatcher('1')); 408 | sinon.assert.neverCalledWith(plugin.provider.request, 'Lambda', 'deleteLayerVersion', versionMatcher('2')); 409 | sinon.assert.neverCalledWith(plugin.provider.request, 'Lambda', 'deleteLayerVersion', versionMatcher('3')); 410 | sinon.assert.neverCalledWith(plugin.provider.request, 'Lambda', 'deleteLayerVersion', versionMatcher('4')); 411 | }); 412 | 413 | }); 414 | 415 | it('should always match delete requests to correct layer', function () { 416 | 417 | const serverless = createMockServerlessWithLayers(['LayerA', 'LayerB']); 418 | const plugin = new PrunePlugin(serverless, { number: 1, includeLayers: true }); 419 | 420 | plugin.provider.request.withArgs('Lambda', 'listLayerVersions', layerMatcher('layer-LayerA')) 421 | .returns(createLayerVersionsResponse([1])); 422 | 423 | plugin.provider.request.withArgs('Lambda', 'listLayerVersions', layerMatcher('layer-LayerB')) 424 | .returns(createLayerVersionsResponse([1, 2, 3])); 425 | 426 | return plugin.pruneLayers().then(() => { 427 | sinon.assert.neverCalledWith(plugin.provider.request, 'Lambda', 'deleteLayerVersion', layerMatcher('layer-LayerA')); 428 | sinon.assert.calledWith(plugin.provider.request, 'Lambda', 'deleteLayerVersion', layerMatcher('layer-LayerB')); 429 | }); 430 | 431 | }); 432 | 433 | it('should ignore layers that are not deployed', function () { 434 | 435 | const serverless = createMockServerlessWithLayers(['LayerA', 'LayerB']); 436 | const plugin = new PrunePlugin(serverless, { number: 1, includeLayers: true }); 437 | 438 | plugin.provider.request.withArgs('Lambda', 'listLayerVersions', layerMatcher('layer-LayerA')) 439 | .rejects({ providerError: { statusCode: 404 }}); 440 | 441 | plugin.provider.request.withArgs('Lambda', 'listLayerVersions', layerMatcher('layer-LayerB')) 442 | .returns(createLayerVersionsResponse([1, 2, 3])); 443 | 444 | return plugin.pruneLayers().then(() => { 445 | sinon.assert.neverCalledWith(plugin.provider.request, 'Lambda', 'deleteLayerVersion', layerMatcher('layer-LayerA')); 446 | sinon.assert.calledWith(plugin.provider.request, 'Lambda', 'deleteLayerVersion', layerMatcher('layer-LayerB')); 447 | }); 448 | 449 | }); 450 | 451 | it('should only operate on target layer if specified from CLI', function () { 452 | 453 | const serverless = createMockServerlessWithLayers(['LayerA', 'LayerB', 'LayerC']); 454 | const plugin = new PrunePlugin(serverless, { layer: 'LayerA', includeLayers: true, number: 1 }); 455 | 456 | plugin.provider.request.withArgs('Lambda', 'listLayerVersions', sinon.match.any) 457 | .returns(createLayerVersionsResponse([1, 2, 3, 4, 5])); 458 | 459 | return plugin.pruneLayers().then(() => { 460 | sinon.assert.calledWith(plugin.provider.request, 'Lambda', 'deleteLayerVersion', layerMatcher('layer-LayerA')); 461 | sinon.assert.neverCalledWith(plugin.provider.request, 'Lambda', 'deleteLayerVersion', layerMatcher('layer-LayerB')); 462 | sinon.assert.neverCalledWith(plugin.provider.request, 'Lambda', 'deleteLayerVersion', layerMatcher('layer-LayerC')); 463 | }); 464 | 465 | }); 466 | 467 | it('should not perform any layer deletions if dryRun flag is set', function () { 468 | 469 | const serverless = createMockServerlessWithLayers(['LayerA', 'LayerB', 'LayerC']); 470 | const plugin = new PrunePlugin(serverless, { number: 1, dryRun: true, includeLayers: true }); 471 | sinon.spy(plugin, 'deleteVersionsForLayer'); 472 | 473 | plugin.provider.request.withArgs('Lambda', 'listLayerVersions', sinon.match.any) 474 | .returns(createLayerVersionsResponse([1, 2, 3, 4, 5])); 475 | 476 | return plugin.pruneLayers().then(() => { 477 | sinon.assert.notCalled(plugin.deleteVersionsForLayer); 478 | }); 479 | 480 | }); 481 | }); 482 | 483 | describe('postDeploy', function() { 484 | 485 | it('should prune functions if automatic option is configured', function() { 486 | 487 | const custom = { 488 | prune: { automatic: true, number: 10 } 489 | }; 490 | const serverlessStub = createMockServerless([], custom); 491 | 492 | const plugin = new PrunePlugin(serverlessStub, {}); 493 | sinon.spy(plugin, 'pruneFunctions'); 494 | 495 | return plugin.postDeploy().then(() => { 496 | sinon.assert.calledOnce(plugin.pruneFunctions); 497 | }); 498 | }); 499 | 500 | it('should prune all functions if automatic option is configured and number is 0', function() { 501 | 502 | const custom = { 503 | prune: { automatic: true, number: 0 } 504 | }; 505 | const serverlessStub = createMockServerless([], custom); 506 | 507 | const plugin = new PrunePlugin(serverlessStub, {}); 508 | sinon.spy(plugin, 'pruneFunctions'); 509 | 510 | return plugin.postDeploy().then(() => { 511 | sinon.assert.calledOnce(plugin.pruneFunctions); 512 | }); 513 | }); 514 | 515 | it('should not prune functions if automatic option is configured without a number', function() { 516 | 517 | const custom = { 518 | prune: { automatic: true } 519 | }; 520 | const serverlessStub = createMockServerless([], custom); 521 | 522 | const plugin = new PrunePlugin(serverlessStub, {}); 523 | sinon.spy(plugin, 'pruneFunctions'); 524 | 525 | return plugin.postDeploy().then(() => { 526 | sinon.assert.notCalled(plugin.pruneFunctions); 527 | }); 528 | }); 529 | 530 | it('should not prune functions if noDeploy flag is set', function() { 531 | 532 | const serverlessStub = createMockServerless([], null); 533 | 534 | const options = { noDeploy: true }; 535 | const plugin = new PrunePlugin(serverlessStub, options); 536 | sinon.spy(plugin, 'pruneFunctions'); 537 | 538 | return plugin.postDeploy().then(() => { 539 | sinon.assert.notCalled(plugin.pruneFunctions); 540 | }); 541 | }); 542 | 543 | it('should not prune layers if no option is set', function() { 544 | const serverlessStub = createMockServerlessWithLayers([], null); 545 | 546 | const options = { automatic: true }; 547 | const plugin = new PrunePlugin(serverlessStub, options); 548 | sinon.spy(plugin, 'pruneLayers'); 549 | 550 | return plugin.postDeploy().then(() => { 551 | sinon.assert.notCalled(plugin.pruneLayers); 552 | }); 553 | }); 554 | 555 | }); 556 | 557 | describe('cliPrune', function() { 558 | 559 | it('should only prune functions if no additional options are provided', function() { 560 | const serverlessStub = createMockServerless([], null); 561 | 562 | const plugin = new PrunePlugin(serverlessStub, {}); 563 | sinon.spy(plugin, 'pruneFunctions'); 564 | sinon.spy(plugin, 'pruneLayers'); 565 | 566 | return plugin.cliPrune().then(() => { 567 | sinon.assert.calledOnce(plugin.pruneFunctions); 568 | sinon.assert.notCalled(plugin.pruneLayers); 569 | }); 570 | }); 571 | 572 | it('should prune functions and layers if includeLayers flag is provided', function() { 573 | const serverlessStub = createMockServerlessWithLayers([], null); 574 | 575 | const plugin = new PrunePlugin(serverlessStub, { includeLayers: true }); 576 | sinon.spy(plugin, 'pruneFunctions'); 577 | sinon.spy(plugin, 'pruneLayers'); 578 | 579 | return plugin.cliPrune().then(() => { 580 | sinon.assert.calledOnce(plugin.pruneFunctions); 581 | sinon.assert.calledOnce(plugin.pruneLayers); 582 | }); 583 | }); 584 | 585 | }); 586 | 587 | describe('logInfo', function() { 588 | 589 | it('should call the Serverless 3.x logging API if available', function() { 590 | const serverlessStub = createMockServerless([], null); 591 | 592 | const log = { 593 | info: sinon.stub() 594 | }; 595 | const plugin = new PrunePlugin(serverlessStub, {}, { log }); 596 | 597 | const msg = 'verbose message'; 598 | plugin.logInfo(msg); 599 | sinon.assert.calledWith(log.info, msg); 600 | }); 601 | 602 | }); 603 | 604 | describe('logWarning', function() { 605 | 606 | it('should call the Serverless 3.x logging API if available', function() { 607 | const serverlessStub = createMockServerless([], null); 608 | 609 | const log = { 610 | warning: sinon.stub() 611 | }; 612 | const plugin = new PrunePlugin(serverlessStub, {}, { log }); 613 | 614 | const msg = 'warn message'; 615 | plugin.logWarning(msg); 616 | sinon.assert.calledWith(log.warning, msg); 617 | }); 618 | 619 | }); 620 | 621 | describe('logSuccess', function() { 622 | 623 | it('should call the Serverless 3.x logging API if available', function() { 624 | const serverlessStub = createMockServerless([], null); 625 | 626 | const log = { 627 | success: sinon.stub() 628 | }; 629 | const plugin = new PrunePlugin(serverlessStub, {}, { log }); 630 | 631 | const msg = 'success message'; 632 | plugin.logSuccess(msg); 633 | sinon.assert.calledWith(log.success, msg); 634 | }); 635 | 636 | }); 637 | 638 | }); --------------------------------------------------------------------------------