diff --git a/README.md b/README.md index 200e72e..c67908d 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,13 @@ Cron job for deleting old, unused versions of your Function. This [post](https://lumigo.io/blog/a-serverless-application-to-clean-up-old-deployment-packages/) explains the problem and why we created this app. +## No-Op (no operation, or, dry run) mode + +To do dry runs of this app, the environment variable NOOP can be set using the +`NoOp` parameter. NoOp will be considered `true` (no active run) if `NoOp` is: + * a string that is in the array `["true", "t", "yes", "y"]` when lower-cased + * a string that casts to an int >= 1 + ## Safeguards To guard against deleting live versions, some safeguards are in place: @@ -39,6 +46,7 @@ AutoDeployMyAwesomeLambdaLayer: SemanticVersion: Parameters: VersionsToKeep: + NoOp: ``` To do the same via CloudFormation or the Serverless framework, you need to first add the following `Transform`: diff --git a/functions/clean.js b/functions/clean.js index f480beb..23297f3 100644 --- a/functions/clean.js +++ b/functions/clean.js @@ -10,6 +10,16 @@ module.exports.handler = async () => { log.debug("all done"); }; +const isEnvTrue = (envVal) => { + if (!envVal){return false;} + const parsedI = parseInt(envVal, 10); + if (! isNaN(parsedI)) {return parsedI >= 1;} + if (["t", "true", "y", "yes"].includes(envVal.toLowerCase())){ + return true; + } + return false; +}; + const clean = async () => { if (functions.length === 0) { functions = await Lambda.listFunctions(); @@ -38,6 +48,7 @@ const cleanFunc = async (funcArn) => { versions = _.orderBy(versions, v => parseInt(v), "desc"); const versionsToKeep = parseInt(process.env.VERSIONS_TO_KEEP || "3"); + const noop = isEnvTrue(process.env.NOOP); // drop the most recent N versions log.debug(`keeping the most recent ${versionsToKeep} versions`); @@ -45,7 +56,11 @@ const cleanFunc = async (funcArn) => { for (const version of versions) { if (!aliasedVersions.includes(version)) { - await Lambda.deleteVersion(funcArn, version); + if (noop) { + console.log(`NOOP: would have attempted to delete function ${funcArn} version ${version}`); + } else { + await Lambda.deleteVersion(funcArn, version); + } } } }; diff --git a/functions/clean.test.js b/functions/clean.test.js index 60227fc..6547dbd 100644 --- a/functions/clean.test.js +++ b/functions/clean.test.js @@ -1,3 +1,4 @@ +const { isNull } = require("lodash"); const Lambda = require("./lib/lambda"); console.log = jest.fn(); @@ -21,8 +22,13 @@ afterEach(() => { mockDeleteVersion.mockClear(); }); -const requireHandler = (versionsToKeep) => { +const requireHandler = (versionsToKeep, noop=null) => { process.env.VERSIONS_TO_KEEP = versionsToKeep.toString(); + if (! isNull(noop)) { + process.env.NOOP = noop.toString(); + } else { + delete process.env.NOOP; + } return require("./clean").handler; }; @@ -37,6 +43,45 @@ test("when there are no functions, it does nothing", async () => { expect(mockDeleteVersion).not.toBeCalled(); }); +const noopCases = [ + {env: "True", run_expected: false}, + {env: "true", run_expected: false}, + {env: "yes", run_expected: false}, + {env: "T", run_expected: false}, + {env: "t", run_expected: false}, + {env: "Y", run_expected: false}, + {env: "y", run_expected: false}, + {env: "1", run_expected: false}, + {env: "13", run_expected: false}, + {env: "False", run_expected: true}, + {env: "Ture", run_expected: true}, + {env: "", run_expected: true}, + {env: "0", run_expected: true}, + {env: "zzzzzz09ijasdflk23l", run_expected: true} +]; + +test.each(noopCases)( + "Noop prevents deletion %s", + async (test_case) => { + + mockListFunctions.mockResolvedValueOnce(["a"]); + mockListVersions.mockResolvedValueOnce(["1", "2", "3"]); + mockListAliasedVersions.mockResolvedValueOnce([]); + + const handler = requireHandler(1, test_case.env); + await handler(); + + if (test_case.run_expected) { + expect(mockDeleteVersion).toHaveBeenCalledTimes(2); + expect(mockDeleteVersion).toBeCalledWith("a", "1"); + expect(mockDeleteVersion).toBeCalledWith("a", "2"); + } else { + expect(mockDeleteVersion).not.toBeCalled(); + } + } + +); + test("all unaliased versions of a function is deleted", async () => { mockListFunctions.mockResolvedValueOnce(["a"]); mockListVersions.mockResolvedValueOnce(["1", "2", "3"]); diff --git a/functions/lib/lambda.js b/functions/lib/lambda.js index 3ae0e9a..cc66b15 100644 --- a/functions/lib/lambda.js +++ b/functions/lib/lambda.js @@ -139,10 +139,14 @@ const deleteVersion = async (funcArn, version) => { }; await retry( - (bail) => lambda - .deleteFunction(params) - .promise() - .catch(bailIfErrorNotRetryable(bail)), + (bail) => { + const res = lambda + .deleteFunction(params) + .promise() + .catch(bailIfErrorNotRetryable(bail)); + log.info("deleted Lambda function version", {function: funcArn,}); + return res; + }, getRetryConfig((err) => { log.warn("retrying deleteFunction after error...", { function: funcArn, version }, err); })); diff --git a/template.yml b/template.yml index 1a2b6c2..24d3c51 100644 --- a/template.yml +++ b/template.yml @@ -26,6 +26,8 @@ Resources: LOG_LEVEL: INFO VERSIONS_TO_KEEP: Ref: VersionsToKeep + NOOP: + Ref: NoOp AWS_NODEJS_CONNECTION_REUSE_ENABLED: 1 Policies: - Statement: @@ -52,3 +54,7 @@ Parameters: How many versions to keep, even if they are not aliased. Default: 3 MinValue: 0 # don't keep anything except $Latest + NoOp: + Type: String + Description: Set to `TRUE`, `YES`, or `1` to perform no-operation runs + Default: ""