From 5861c0ce5c4c3e7cdfd21fb46ec34e5d66780b37 Mon Sep 17 00:00:00 2001 From: Joni Harker Date: Wed, 17 Apr 2024 13:24:10 -0700 Subject: [PATCH] [IT-1729] A lambda to stop or terminate all EC2 instances in an account (#1) Stop or terminate all instances in the same account as this lambda. Instances less than an hour old will be ignored, and instances can also be ignored based on a tag value. --- .coveragerc | 2 +- .github/workflows/test.yaml | 12 +- .pre-commit-config.yaml | 8 +- Pipfile | 5 +- Pipfile.lock | 191 ++++++------ README.md | 72 +++-- {hello_world => ec2_terminator}/__init__.py | 0 ec2_terminator/app.py | 184 +++++++++++ events/event.json | 62 ---- hello_world/app.py | 42 --- template.yaml | 77 +++-- tests/unit/test_handler.py | 325 ++++++++++++++++---- 12 files changed, 655 insertions(+), 325 deletions(-) rename {hello_world => ec2_terminator}/__init__.py (100%) create mode 100644 ec2_terminator/app.py delete mode 100644 events/event.json delete mode 100644 hello_world/app.py diff --git a/.coveragerc b/.coveragerc index e0df967..2964f65 100644 --- a/.coveragerc +++ b/.coveragerc @@ -2,4 +2,4 @@ relative_files = True # Use 'source' instead of 'omit' in order to ignore 'tests/unit/__init__.py' -source = hello_world +source = ec2_terminator diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index cfbef8c..d0fda52 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -9,19 +9,19 @@ jobs: pre-commit: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: 3.9 - - uses: pre-commit/action@v3.0.0 + - uses: pre-commit/action@v3.0.1 pytest: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: 3.9 - run: pip install -U pipenv @@ -33,6 +33,6 @@ jobs: sam-build-and-lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: ./.github/actions/sam-build - run: sam validate --lint --template .aws-sam/build/template.yaml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ac371d5..0466339 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.3.0 + rev: v4.6.0 hooks: # On Windows, git will convert all CRLF to LF, but only after all hooks are done executing. # yamllint will fail before git has a chance to convert line endings, so line endings must be explicitly converted before yamllint @@ -11,16 +11,16 @@ repos: - id: trailing-whitespace - id: check-ast - repo: https://github.com/adrienverge/yamllint - rev: v1.27.1 + rev: v1.35.1 hooks: - id: yamllint - repo: https://github.com/awslabs/cfn-python-lint - rev: v0.63.2 + rev: v0.86.2 hooks: - id: cfn-python-lint files: template\.(json|yml|yaml)$ - repo: https://github.com/Lucas-C/pre-commit-hooks - rev: v1.3.1 + rev: v1.5.5 hooks: - id: remove-tabs - repo: https://github.com/aristanetworks/j2lint.git diff --git a/Pipfile b/Pipfile index d11ca0c..7a42f61 100644 --- a/Pipfile +++ b/Pipfile @@ -4,13 +4,10 @@ url = "https://pypi.org/simple" verify_ssl = true [dev-packages] -pytest = "~=7.1" +pytest = "~=8.1" pytest-mock = "~=3.8" boto3 = "~=1.24" coverage = "~=7.3" -[packages] -crhelper = "~=2.0" - [requires] python_version = "3.9" diff --git a/Pipfile.lock b/Pipfile.lock index 88fab3a..8a907b3 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "28f91f2ef755c20ea49979be1052af8b372c53fbef9926e51ca1bb350ad76256" + "sha256": "62a3386378a43341ce0181286ca3c46f2aad4b31f8336d1c8fb2e6bde152b828" }, "pipfile-spec": 6, "requires": { @@ -15,100 +15,91 @@ } ] }, - "default": { - "crhelper": { - "hashes": [ - "sha256:0c1f703a830722379d205d58ca4f0da768c0b10670ddce46af31ba9661bf2d5a", - "sha256:da9efe4fb57d86f0567fddc999ae1c242ea9602c95b165b09e00d435c3845ef0" - ], - "index": "pypi", - "version": "==2.0.11" - } - }, + "default": {}, "develop": { "boto3": { "hashes": [ - "sha256:2680c0e36167e672777110ccef5303d59fa4a6a4f10086f9c14158c5cb008d5c", - "sha256:2ceb644b1df7c3c8907913ab367a9900af79e271b4cfca37b542ec1fa142faf8" + "sha256:004dad209d37b3d2df88f41da13b7ad702a751904a335fac095897ff7a19f82b", + "sha256:18224d206a8a775bcaa562d22ed3d07854934699190e12b52fcde87aac76a80e" ], "index": "pypi", - "markers": "python_version >= '3.7'", - "version": "==1.28.55" + "markers": "python_version >= '3.8'", + "version": "==1.34.81" }, "botocore": { "hashes": [ - "sha256:159f637300206a0b37b49c1bee61265650843f591e9cb62e9adcb3d1c2afec91", - "sha256:6485a700744c60fcbf4bba4fcacb22067f601e79fb0c27fae04cf07b03c5e8f9" + "sha256:85f6fd7c5715eeef7a236c50947de00f57d72e7439daed1125491014b70fab01", + "sha256:f79bf122566cc1f09d71cc9ac9fcf52d47ba48b761cbc3f064017b36a3c40eb8" ], - "markers": "python_version >= '3.7'", - "version": "==1.31.59" + "markers": "python_version >= '3.8'", + "version": "==1.34.81" }, "coverage": { "hashes": [ - "sha256:025ded371f1ca280c035d91b43252adbb04d2aea4c7105252d3cbc227f03b375", - "sha256:04312b036580ec505f2b77cbbdfb15137d5efdfade09156961f5277149f5e344", - "sha256:0575c37e207bb9b98b6cf72fdaaa18ac909fb3d153083400c2d48e2e6d28bd8e", - "sha256:07d156269718670d00a3b06db2288b48527fc5f36859425ff7cec07c6b367745", - "sha256:1f111a7d85658ea52ffad7084088277135ec5f368457275fc57f11cebb15607f", - "sha256:220eb51f5fb38dfdb7e5d54284ca4d0cd70ddac047d750111a68ab1798945194", - "sha256:229c0dd2ccf956bf5aeede7e3131ca48b65beacde2029f0361b54bf93d36f45a", - "sha256:245c5a99254e83875c7fed8b8b2536f040997a9b76ac4c1da5bff398c06e860f", - "sha256:2829c65c8faaf55b868ed7af3c7477b76b1c6ebeee99a28f59a2cb5907a45760", - "sha256:4aba512a15a3e1e4fdbfed2f5392ec221434a614cc68100ca99dcad7af29f3f8", - "sha256:4c96dd7798d83b960afc6c1feb9e5af537fc4908852ef025600374ff1a017392", - "sha256:50dd1e2dd13dbbd856ffef69196781edff26c800a74f070d3b3e3389cab2600d", - "sha256:5289490dd1c3bb86de4730a92261ae66ea8d44b79ed3cc26464f4c2cde581fbc", - "sha256:53669b79f3d599da95a0afbef039ac0fadbb236532feb042c534fbb81b1a4e40", - "sha256:553d7094cb27db58ea91332e8b5681bac107e7242c23f7629ab1316ee73c4981", - "sha256:586649ada7cf139445da386ab6f8ef00e6172f11a939fc3b2b7e7c9082052fa0", - "sha256:5ae4c6da8b3d123500f9525b50bf0168023313963e0e2e814badf9000dd6ef92", - "sha256:5b4ee7080878077af0afa7238df1b967f00dc10763f6e1b66f5cced4abebb0a3", - "sha256:5d991e13ad2ed3aced177f524e4d670f304c8233edad3210e02c465351f785a0", - "sha256:614f1f98b84eb256e4f35e726bfe5ca82349f8dfa576faabf8a49ca09e630086", - "sha256:636a8ac0b044cfeccae76a36f3b18264edcc810a76a49884b96dd744613ec0b7", - "sha256:6407424621f40205bbe6325686417e5e552f6b2dba3535dd1f90afc88a61d465", - "sha256:6bc6f3f4692d806831c136c5acad5ccedd0262aa44c087c46b7101c77e139140", - "sha256:6cb7fe1581deb67b782c153136541e20901aa312ceedaf1467dcb35255787952", - "sha256:74bb470399dc1989b535cb41f5ca7ab2af561e40def22d7e188e0a445e7639e3", - "sha256:75c8f0df9dfd8ff745bccff75867d63ef336e57cc22b2908ee725cc552689ec8", - "sha256:770f143980cc16eb601ccfd571846e89a5fe4c03b4193f2e485268f224ab602f", - "sha256:7eb0b188f30e41ddd659a529e385470aa6782f3b412f860ce22b2491c89b8593", - "sha256:7eb3cd48d54b9bd0e73026dedce44773214064be93611deab0b6a43158c3d5a0", - "sha256:87d38444efffd5b056fcc026c1e8d862191881143c3aa80bb11fcf9dca9ae204", - "sha256:8a07b692129b8a14ad7a37941a3029c291254feb7a4237f245cfae2de78de037", - "sha256:966f10df9b2b2115da87f50f6a248e313c72a668248be1b9060ce935c871f276", - "sha256:a6191b3a6ad3e09b6cfd75b45c6aeeffe7e3b0ad46b268345d159b8df8d835f9", - "sha256:aab8e9464c00da5cb9c536150b7fbcd8850d376d1151741dd0d16dfe1ba4fd26", - "sha256:ac3c5b7e75acac31e490b7851595212ed951889918d398b7afa12736c85e13ce", - "sha256:ac9ad38204887349853d7c313f53a7b1c210ce138c73859e925bc4e5d8fc18e7", - "sha256:b9c0c19f70d30219113b18fe07e372b244fb2a773d4afde29d5a2f7930765136", - "sha256:c397c70cd20f6df7d2a52283857af622d5f23300c4ca8e5bd8c7a543825baa5a", - "sha256:c6601a60318f9c3945be6ea0f2a80571f4299b6801716f8a6e4846892737ebe4", - "sha256:c6f55d38818ca9596dc9019eae19a47410d5322408140d9a0076001a3dcb938c", - "sha256:ca70466ca3a17460e8fc9cea7123c8cbef5ada4be3140a1ef8f7b63f2f37108f", - "sha256:ca833941ec701fda15414be400c3259479bfde7ae6d806b69e63b3dc423b1832", - "sha256:cd0f7429ecfd1ff597389907045ff209c8fdb5b013d38cfa7c60728cb484b6e3", - "sha256:cd694e19c031733e446c8024dedd12a00cda87e1c10bd7b8539a87963685e969", - "sha256:cdd088c00c39a27cfa5329349cc763a48761fdc785879220d54eb785c8a38520", - "sha256:de30c1aa80f30af0f6b2058a91505ea6e36d6535d437520067f525f7df123887", - "sha256:defbbb51121189722420a208957e26e49809feafca6afeef325df66c39c4fdb3", - "sha256:f09195dda68d94a53123883de75bb97b0e35f5f6f9f3aa5bf6e496da718f0cb6", - "sha256:f12d8b11a54f32688b165fd1a788c408f927b0960984b899be7e4c190ae758f1", - "sha256:f1a317fdf5c122ad642db8a97964733ab7c3cf6009e1a8ae8821089993f175ff", - "sha256:f2781fd3cabc28278dc982a352f50c81c09a1a500cc2086dc4249853ea96b981", - "sha256:f4f456590eefb6e1b3c9ea6328c1e9fa0f1006e7481179d749b3376fc793478e" + "sha256:00838a35b882694afda09f85e469c96367daa3f3f2b097d846a7216993d37f4c", + "sha256:0513b9508b93da4e1716744ef6ebc507aff016ba115ffe8ecff744d1322a7b63", + "sha256:09c3255458533cb76ef55da8cc49ffab9e33f083739c8bd4f58e79fecfe288f7", + "sha256:09ef9199ed6653989ebbcaacc9b62b514bb63ea2f90256e71fea3ed74bd8ff6f", + "sha256:09fa497a8ab37784fbb20ab699c246053ac294d13fc7eb40ec007a5043ec91f8", + "sha256:0f9f50e7ef2a71e2fae92774c99170eb8304e3fdf9c8c3c7ae9bab3e7229c5cf", + "sha256:137eb07173141545e07403cca94ab625cc1cc6bc4c1e97b6e3846270e7e1fea0", + "sha256:1f384c3cc76aeedce208643697fb3e8437604b512255de6d18dae3f27655a384", + "sha256:201bef2eea65e0e9c56343115ba3814e896afe6d36ffd37bab783261db430f76", + "sha256:38dd60d7bf242c4ed5b38e094baf6401faa114fc09e9e6632374388a404f98e7", + "sha256:3b799445b9f7ee8bf299cfaed6f5b226c0037b74886a4e11515e569b36fe310d", + "sha256:3ea79bb50e805cd6ac058dfa3b5c8f6c040cb87fe83de10845857f5535d1db70", + "sha256:40209e141059b9370a2657c9b15607815359ab3ef9918f0196b6fccce8d3230f", + "sha256:41c9c5f3de16b903b610d09650e5e27adbfa7f500302718c9ffd1c12cf9d6818", + "sha256:54eb8d1bf7cacfbf2a3186019bcf01d11c666bd495ed18717162f7eb1e9dd00b", + "sha256:598825b51b81c808cb6f078dcb972f96af96b078faa47af7dfcdf282835baa8d", + "sha256:5fc1de20b2d4a061b3df27ab9b7c7111e9a710f10dc2b84d33a4ab25065994ec", + "sha256:623512f8ba53c422fcfb2ce68362c97945095b864cda94a92edbaf5994201083", + "sha256:690db6517f09336559dc0b5f55342df62370a48f5469fabf502db2c6d1cffcd2", + "sha256:69eb372f7e2ece89f14751fbcbe470295d73ed41ecd37ca36ed2eb47512a6ab9", + "sha256:73bfb9c09951125d06ee473bed216e2c3742f530fc5acc1383883125de76d9cd", + "sha256:742a76a12aa45b44d236815d282b03cfb1de3b4323f3e4ec933acfae08e54ade", + "sha256:7c95949560050d04d46b919301826525597f07b33beba6187d04fa64d47ac82e", + "sha256:8130a2aa2acb8788e0b56938786c33c7c98562697bf9f4c7d6e8e5e3a0501e4a", + "sha256:8a2b2b78c78293782fd3767d53e6474582f62443d0504b1554370bde86cc8227", + "sha256:8ce1415194b4a6bd0cdcc3a1dfbf58b63f910dcb7330fe15bdff542c56949f87", + "sha256:9ca28a302acb19b6af89e90f33ee3e1906961f94b54ea37de6737b7ca9d8827c", + "sha256:a4cdc86d54b5da0df6d3d3a2f0b710949286094c3a6700c21e9015932b81447e", + "sha256:aa5b1c1bfc28384f1f53b69a023d789f72b2e0ab1b3787aae16992a7ca21056c", + "sha256:aadacf9a2f407a4688d700e4ebab33a7e2e408f2ca04dbf4aef17585389eff3e", + "sha256:ae71e7ddb7a413dd60052e90528f2f65270aad4b509563af6d03d53e979feafd", + "sha256:b14706df8b2de49869ae03a5ccbc211f4041750cd4a66f698df89d44f4bd30ec", + "sha256:b1a93009cb80730c9bca5d6d4665494b725b6e8e157c1cb7f2db5b4b122ea562", + "sha256:b2991665420a803495e0b90a79233c1433d6ed77ef282e8e152a324bbbc5e0c8", + "sha256:b2c5edc4ac10a7ef6605a966c58929ec6c1bd0917fb8c15cb3363f65aa40e677", + "sha256:b4d33f418f46362995f1e9d4f3a35a1b6322cb959c31d88ae56b0298e1c22357", + "sha256:b91cbc4b195444e7e258ba27ac33769c41b94967919f10037e6355e998af255c", + "sha256:c74880fc64d4958159fbd537a091d2a585448a8f8508bf248d72112723974cbd", + "sha256:c901df83d097649e257e803be22592aedfd5182f07b3cc87d640bbb9afd50f49", + "sha256:cac99918c7bba15302a2d81f0312c08054a3359eaa1929c7e4b26ebe41e9b286", + "sha256:cc4f1358cb0c78edef3ed237ef2c86056206bb8d9140e73b6b89fbcfcbdd40e1", + "sha256:ccd341521be3d1b3daeb41960ae94a5e87abe2f46f17224ba5d6f2b8398016cf", + "sha256:ce4b94265ca988c3f8e479e741693d143026632672e3ff924f25fab50518dd51", + "sha256:cf271892d13e43bc2b51e6908ec9a6a5094a4df1d8af0bfc360088ee6c684409", + "sha256:d5ae728ff3b5401cc320d792866987e7e7e880e6ebd24433b70a33b643bb0384", + "sha256:d71eec7d83298f1af3326ce0ff1d0ea83c7cb98f72b577097f9083b20bdaf05e", + "sha256:d898fe162d26929b5960e4e138651f7427048e72c853607f2b200909794ed978", + "sha256:d89d7b2974cae412400e88f35d86af72208e1ede1a541954af5d944a8ba46c57", + "sha256:dfa8fe35a0bb90382837b238fff375de15f0dcdb9ae68ff85f7a63649c98527e", + "sha256:e0be5efd5127542ef31f165de269f77560d6cdef525fffa446de6f7e9186cfb2", + "sha256:fdfafb32984684eb03c2d83e1e51f64f0906b11e64482df3c5db936ce3839d48", + "sha256:ff7687ca3d7028d8a5f0ebae95a6e4827c5616b31a4ee1192bdfde697db110d4" ], "index": "pypi", "markers": "python_version >= '3.8'", - "version": "==7.3.1" + "version": "==7.4.4" }, "exceptiongroup": { "hashes": [ - "sha256:097acd85d473d75af5bb98e41b61ff7fe35efe6675e4f9370ec6ec5126d160e9", - "sha256:343280667a4585d195ca1cf9cef84a4e178c4b6cf2274caef9859782b567d5e3" + "sha256:4bfd3996ac73b41e9b9628b04e079f193850720ea5945fc96a08633c66912f14", + "sha256:91f5c769735f051a4290d52edd0858999b57e5876e9f85937691bd4c9fa3ed68" ], "markers": "python_version < '3.11'", - "version": "==1.1.3" + "version": "==1.2.0" }, "iniconfig": { "hashes": [ @@ -128,53 +119,53 @@ }, "packaging": { "hashes": [ - "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5", - "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7" + "sha256:2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5", + "sha256:eb82c5e3e56209074766e6885bb04b8c38a0c015d0a30036ebe7ece34c9989e9" ], "markers": "python_version >= '3.7'", - "version": "==23.2" + "version": "==24.0" }, "pluggy": { "hashes": [ - "sha256:cf61ae8f126ac6f7c451172cf30e3e43d3ca77615509771b3a984a0730651e12", - "sha256:d89c696a773f8bd377d18e5ecda92b7a3793cbe66c87060a6fb58c7b6e1061f7" + "sha256:7db9f7b503d67d1c5b95f59773ebb58a8c1c288129a88665838012cfb07b8981", + "sha256:8c85c2876142a764e5b7548e7d9a0e0ddb46f5185161049a79b7e974454223be" ], "markers": "python_version >= '3.8'", - "version": "==1.3.0" + "version": "==1.4.0" }, "pytest": { "hashes": [ - "sha256:1d881c6124e08ff0a1bb75ba3ec0bfd8b5354a01c194ddd5a0a870a48d99b002", - "sha256:a766259cfab564a2ad52cb1aae1b881a75c3eb7e34ca3779697c23ed47c47069" + "sha256:2a8386cfc11fa9d2c50ee7b2a57e7d898ef90470a7a34c4b949ff59662bb78b7", + "sha256:ac978141a75948948817d360297b7aae0fcb9d6ff6bc9ec6d514b85d5a65c044" ], "index": "pypi", - "markers": "python_version >= '3.7'", - "version": "==7.4.2" + "markers": "python_version >= '3.8'", + "version": "==8.1.1" }, "pytest-mock": { "hashes": [ - "sha256:21c279fff83d70763b05f8874cc9cfb3fcacd6d354247a976f9529d19f9acf39", - "sha256:7f6b125602ac6d743e523ae0bfa71e1a697a2f5534064528c6ff84c2f7c2fc7f" + "sha256:0b72c38033392a5f4621342fe11e9219ac11ec9d375f8e2a0c164539e0d70f6f", + "sha256:2719255a1efeceadbc056d6bf3df3d1c5015530fb40cf347c0f9afac88410bd0" ], "index": "pypi", - "markers": "python_version >= '3.7'", - "version": "==3.11.1" + "markers": "python_version >= '3.8'", + "version": "==3.14.0" }, "python-dateutil": { "hashes": [ - "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86", - "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9" + "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", + "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==2.8.2" + "version": "==2.9.0.post0" }, "s3transfer": { "hashes": [ - "sha256:10d6923c6359175f264811ef4bf6161a3156ce8e350e705396a7557d6293c33a", - "sha256:fd3889a66f5fe17299fe75b82eae6cf722554edca744ca5d5fe308b104883d2e" + "sha256:5683916b4c724f799e600f41dd9e10a9ff19871bf87623cc8f491cb4f5fa0a19", + "sha256:ceb252b11bcf87080fb7850a224fb6e05c8a776bab8f2b64b7f25b969464839d" ], - "markers": "python_version >= '3.7'", - "version": "==0.7.0" + "markers": "python_version >= '3.8'", + "version": "==0.10.1" }, "six": { "hashes": [ @@ -194,12 +185,12 @@ }, "urllib3": { "hashes": [ - "sha256:24d6a242c28d29af46c3fae832c36db3bbebcc533dd1bb549172cd739c82df21", - "sha256:94a757d178c9be92ef5539b8840d48dc9cf1b2709c9d6b588232a055c524458b" + "sha256:34b97092d7e0a3a8cf7cd10e386f401b3737364026c45e622aa02903dffe0f07", + "sha256:f8ecc1bba5667413457c529ab955bf8c67b45db799d159066261719e328580a0" ], "index": "pypi", - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'", - "version": "==1.26.17" + "markers": "python_version < '3.10'", + "version": "==1.26.18" } } } diff --git a/README.md b/README.md index d02edfa..28a275e 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,50 @@ -# lambda-template -A GitHub template for quickly starting a new AWS lambda project. +# lambda-ec2-terminator -## Naming -Naming conventions: -* for a vanilla Lambda: `lambda-` -* for a Cloudformation Transform macro: `cfn-macro-` -* for a Cloudformation Custom Resource: `cfn-cr-` +An AWS lambda to stop or terminate all EC2 instances in the current account. + +## Operation + +This lambda will scan the account it is deployed to for running or stopped EC2 +instances and will stop or terminate them. Stop Protection or Termination +Protection should be used to prevent instances from being effected. + +### Parameters + +| Parameter Name | Default Value | Allowed Values | +|----------------|--------------------|-----------------------| +| Ec2Action | "STOP" | "STOP" or "TERMINATE" | +| Schedule | cron(0 2 * * ? \*) | EventBridge schedule | + +#### Ec2Action + +The EC2 action to take on running instances: either stop or terminate. + +#### Schedule + +When to run the lambda, the default is daily at 2am UTC. Must be an EventBridge schedule. +Format details: https://docs.aws.amazon.com/scheduler/latest/UserGuide/schedule-types.html + +### Running + +This lambda is triggered by a scheduled CloudWatch event at 2am UTC (8pm PST). ## Development ### Contributions + Contributions are welcome. ### Setup Development Environment Install the following applications: -* [AWS CLI](https://github.com/aws/aws-cli) -* [AWS SAM CLI](https://github.com/aws/aws-sam-cli) -* [pre-commit](https://github.com/pre-commit/pre-commit) -* [pipenv](https://github.com/pypa/pipenv) + +- [AWS CLI](https://github.com/aws/aws-cli) +- [AWS SAM CLI](https://github.com/aws/aws-sam-cli) +- [pre-commit](https://github.com/pre-commit/pre-commit) +- [pipenv](https://github.com/pypa/pipenv) ### Install Requirements + Run `pipenv install --dev` to install both production and development requirements, and `pipenv shell` to activate the virtual environment. For more information see the [pipenv docs](https://pipenv.pypa.io/en/latest/). @@ -29,6 +53,7 @@ After activating the virtual environment, run `pre-commit install` to install the [pre-commit](https://pre-commit.com/) git hook. ### Update Requirements + First, make any needed updates to the base requirements in `Pipfile`, then use `pipenv` to regenerate both `Pipfile.lock` and `requirements.txt`. We use `pipenv` to control versions in testing, @@ -49,17 +74,21 @@ $ pipenv requirements > requirements.txt ``` Additionally, `pre-commit` manages its own requirements. + ```shell script $ pre-commit autoupdate ``` ### Create a local build + Use a Lambda-like docker container to build the Lambda artifact + ```shell script $ sam build --use-container ``` ### Run unit tests + Tests are defined in the `tests` folder in this project, and dependencies are managed with `pipenv`. Install the development dependencies and run the tests using `coverage`. @@ -71,16 +100,18 @@ $ pipenv run coverage run -m pytest tests/ -vv Automated testing will upload coverage results to [Coveralls](coveralls.io). ### Run integration tests + Running integration tests [requires docker](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/sam-cli-command-reference-sam-local-start-api.html) ```shell script -$ sam local invoke HelloWorldFunction --event events/event.json +$ sam local invoke TerminatorFunction --event events/event.json ``` ## Deployment ### Deploy Lambda to S3 + Deployments are sent to the [Sage cloudformation repository](https://bootstrap-awss3cloudformationbucket-19qromfd235z9.s3.amazonaws.com/index.html) which requires permissions to upload to Sage @@ -98,6 +129,7 @@ aws s3 cp .aws-sam/build/lambda-template.yaml s3://bootstrap-awss3cloudformation ## Publish Lambda ### Private access + Publishing the lambda makes it available in your AWS account. It will be accessible in the [serverless application repository](https://console.aws.amazon.com/serverlessrepo). @@ -106,6 +138,7 @@ sam publish --template .aws-sam/build/lambda-template.yaml ``` ### Public access + Making the lambda publicly accessible makes it available in the [global AWS serverless application repository](https://serverlessrepo.aws.amazon.com/applications) @@ -118,6 +151,7 @@ aws serverlessrepo put-application-policy \ ## Install Lambda into AWS ### Sceptre + Create the following [sceptre](https://github.com/Sceptre/sceptre) file config/prod/lambda-template.yaml @@ -133,20 +167,22 @@ stack_tags: ``` Install the lambda using sceptre: + ```shell script sceptre --var "profile=my-profile" --var "region=us-east-1" launch prod/lambda-template.yaml ``` ### AWS Console + Steps to deploy from AWS console. 1. Login to AWS -2. Access the -[serverless application repository](https://console.aws.amazon.com/serverlessrepo) --> Available Applications -3. Select application to install -4. Enter Application settings -5. Click Deploy +1. Access the + [serverless application repository](https://console.aws.amazon.com/serverlessrepo) + -> Available Applications +1. Select application to install +1. Enter Application settings +1. Click Deploy ## Releasing diff --git a/hello_world/__init__.py b/ec2_terminator/__init__.py similarity index 100% rename from hello_world/__init__.py rename to ec2_terminator/__init__.py diff --git a/ec2_terminator/app.py b/ec2_terminator/app.py new file mode 100644 index 0000000..a86d21a --- /dev/null +++ b/ec2_terminator/app.py @@ -0,0 +1,184 @@ +import datetime +import json +import logging +import os + +import boto3 + + +LOG = logging.getLogger(__name__) +LOG.setLevel(logging.DEBUG) + +for lib in ["botocore", "urllib3"]: + log = logging.getLogger(lib) + log.setLevel(logging.WARNING) + + +IGNORE_TAG_KEY = 'ec2-terminator:ignore' +IGNORE_TAG_VALUE= 'ignore' + + +def list_regions(): + client = boto3.client('ec2') + + region_desc = client.describe_regions() + regions = [region['RegionName'] for region in region_desc['Regions']] + + LOG.debug(f"Regions: {regions}") + return regions + + +def list_instances(region): + """Return a list of instance IDs in the given region to stop or terminate""" + client = boto3.client('ec2', region_name=region) + + # This intentionally reprocesses stopped instances for debugging + state_filters = [{ + 'Name': 'instance-state-name', + 'Values': ['running', 'stopping', 'stopped'], + }] + + instances = [] + + pager = client.get_paginator('describe_instances') + for page in pager.paginate(Filters=state_filters): + for rsvp in page['Reservations']: + for ec2 in rsvp['Instances']: + ec2_id = ec2['InstanceId'] + LOG.debug(f"EC2: {ec2}") + + # check for ignore tag + ignore = False + if 'Tags' in ec2: + for tag in ec2['Tags']: + if tag['Key'] == IGNORE_TAG_KEY \ + and tag['Value'] == IGNORE_TAG_VALUE: + ignore = True + break + + # also skip instances less than an hour old + if not ignore and 'LaunchTime' in ec2: + launch = ec2['LaunchTime'] + now = datetime.datetime.now(launch.tzinfo) + age = now - launch + if age < datetime.timedelta(hours=1): + ignore = True + + if ignore: + LOG.debug(f"Ignoring instance {ec2_id}") + continue + + instances.append(ec2_id) + + LOG.debug(f"Instances found: {instances}") + return instances + + +def set_instance_state(instances, state, region): + client = boto3.client('ec2', region_name=region) + + action = '' + resp_key = None + function = None + processed = [] + + _state = state.lower() + LOG.debug(f"State action: {_state}") + + if _state == 'stop': + function = client.stop_instances + resp_key = 'StoppingInstances' + action = 'stopped' + + elif _state == 'terminate': + function = client.terminate_instances + resp_key = 'TerminatingInstances' + action = 'terminated' + + else: + raise ValueError(f"Unknown instance state: {state}") + + resp = function(InstanceIds=instances) + if resp[resp_key]: + processed = [i['InstanceId'] for i in resp[resp_key]] + + message = f"Instances {action}: {processed}" + LOG.debug(message) + + return processed, message + + +def lambda_handler(event, context): + """Lambda function to stop or terminate all EC2 instances in the current account. + + Parameters + ---------- + event: dict, required + API Gateway Lambda Proxy Input Format + + Event doc: https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html#api-gateway-simple-proxy-for-lambda-input-format + + context: object, required + Lambda Context runtime methods and attributes + + Context doc: https://docs.aws.amazon.com/lambda/latest/dg/python-context-object.html + + Returns + ------ + API Gateway Lambda Proxy Output Format: dict + + Return doc: https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html + """ + + try: + # Are we stopping or terminating instances? + ec2_action = os.environ.get('EC2_ACTION', '') + if ec2_action == '': + ec2_action = 'stop' + + # Get the list of regions + regions = list_regions() + if not regions: + raise ValueError("No available regions") + + # List of stopped or terminated instances + found = 0 + processed = [] + message = 'No running or stopped instances found' + + # Iterate over every region + for region in regions: + LOG.info(f"Region: {region}") + ec2_instances = list_instances(region) + + # Stop or terminate any instances found + if ec2_instances: + found = len(ec2_instances) + _processed, message = set_instance_state(ec2_instances, + ec2_action, + region) + processed.extend(_processed) + else: + LOG.debug("No instances found") + + # Raise an error if some instances failed to stop or terminate + if found > 0 and found > len(processed): + raise RuntimeError("Some instances failed to stop or terminate") + + # Report results + LOG.info(message) + return { + "statusCode": 200, + "body": json.dumps({ + "message": message, + }) + } + + except Exception as exc: + LOG.exception(exc) + return { + "statusCode": 500, + "body": json.dumps({ + "message": str(exc) + }), + } diff --git a/events/event.json b/events/event.json deleted file mode 100644 index 070ad8e..0000000 --- a/events/event.json +++ /dev/null @@ -1,62 +0,0 @@ -{ - "body": "{\"message\": \"hello world\"}", - "resource": "/{proxy+}", - "path": "/path/to/resource", - "httpMethod": "POST", - "isBase64Encoded": false, - "queryStringParameters": { - "foo": "bar" - }, - "pathParameters": { - "proxy": "/path/to/resource" - }, - "stageVariables": { - "baz": "qux" - }, - "headers": { - "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", - "Accept-Encoding": "gzip, deflate, sdch", - "Accept-Language": "en-US,en;q=0.8", - "Cache-Control": "max-age=0", - "CloudFront-Forwarded-Proto": "https", - "CloudFront-Is-Desktop-Viewer": "true", - "CloudFront-Is-Mobile-Viewer": "false", - "CloudFront-Is-SmartTV-Viewer": "false", - "CloudFront-Is-Tablet-Viewer": "false", - "CloudFront-Viewer-Country": "US", - "Host": "1234567890.execute-api.us-east-1.amazonaws.com", - "Upgrade-Insecure-Requests": "1", - "User-Agent": "Custom User Agent String", - "Via": "1.1 08f323deadbeefa7af34d5feb414ce27.cloudfront.net (CloudFront)", - "X-Amz-Cf-Id": "cDehVQoZnx43VYQb9j2-nvCh-9z396Uhbp027Y2JvkCPNLmGJHqlaA==", - "X-Forwarded-For": "127.0.0.1, 127.0.0.2", - "X-Forwarded-Port": "443", - "X-Forwarded-Proto": "https" - }, - "requestContext": { - "accountId": "123456789012", - "resourceId": "123456", - "stage": "prod", - "requestId": "c6af9ac6-7b61-11e6-9a41-93e8deadbeef", - "requestTime": "09/Apr/2015:12:34:56 +0000", - "requestTimeEpoch": 1428582896000, - "identity": { - "cognitoIdentityPoolId": null, - "accountId": null, - "cognitoIdentityId": null, - "caller": null, - "accessKey": null, - "sourceIp": "127.0.0.1", - "cognitoAuthenticationType": null, - "cognitoAuthenticationProvider": null, - "userArn": null, - "userAgent": "Custom User Agent String", - "user": null - }, - "path": "/prod/path/to/resource", - "resourcePath": "/{proxy+}", - "httpMethod": "POST", - "apiId": "1234567890", - "protocol": "HTTP/1.1" - } -} diff --git a/hello_world/app.py b/hello_world/app.py deleted file mode 100644 index 0930620..0000000 --- a/hello_world/app.py +++ /dev/null @@ -1,42 +0,0 @@ -import json - -# import requests - - -def lambda_handler(event, context): - """Sample pure Lambda function - - Parameters - ---------- - event: dict, required - API Gateway Lambda Proxy Input Format - - Event doc: https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html#api-gateway-simple-proxy-for-lambda-input-format - - context: object, required - Lambda Context runtime methods and attributes - - Context doc: https://docs.aws.amazon.com/lambda/latest/dg/python-context-object.html - - Returns - ------ - API Gateway Lambda Proxy Output Format: dict - - Return doc: https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html - """ - - # try: - # ip = requests.get("http://checkip.amazonaws.com/") - # except requests.RequestException as e: - # # Send some context about this error to Lambda Logs - # print(e) - - # raise e - - return { - "statusCode": 200, - "body": json.dumps({ - "message": "hello world", - # "location": ip.text.replace("\n", "") - }), - } diff --git a/template.yaml b/template.yaml index a60faad..a3e57f2 100644 --- a/template.yaml +++ b/template.yaml @@ -1,41 +1,59 @@ AWSTemplateFormatVersion: '2010-09-09' Transform: AWS::Serverless-2016-10-31 Description: > - Hello World lambda - -Metadata: - AWS::ServerlessRepo::Application: - Name: "lambda-template" - Description: "A GH template for quickly starting a new AWS lambda project." - Author: "Sage-Bionetworks" - SpdxLicenseId: "Apache-2.0" - # paths are relative to .aws-sam/build directory - LicenseUrl: "LICENSE" - ReadmeUrl: "README.md" - Labels: ["serverless", "template", "github", "quick-start"] - HomePageUrl: "https://github.com/Sage-Bionetworks-IT/lambda-template" - SemanticVersion: "0.0.3" - SourceCodeUrl: "https://github.com/Sage-Bionetworks-IT/lambda-template/tree/0.0.3" + Lambda to stop or terminate all instances. # More info about Globals: https://github.com/awslabs/serverless-application-model/blob/master/docs/globals.rst Globals: Function: Timeout: 30 +Parameters: + Schedule: + Description: > + Schedule to execute the lambda, can be a rate or a cron schedule. Format at + https://docs.aws.amazon.com/AmazonCloudWatch/latest/events/ScheduledEvents.html + Type: String + Default: cron(0 2 * * ? *) # Run at 2am UTC (8pm PST) every night + Ec2Action: + Type: String + Description: "Action to take on running instances" + Default: "STOP" + AllowedValues: + - "STOP" + - "TERMINATE" + Resources: - HelloWorldFunction: + TerminatorFunction: Type: AWS::Serverless::Function # More info about Function Resource: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#awsserverlessfunction Properties: CodeUri: . - Handler: hello_world/app.lambda_handler + Handler: ec2_terminator/app.lambda_handler Runtime: python3.9 Role: !GetAtt FunctionRole.Arn + Environment: + Variables: + EC2_ACTION: !Ref Ec2Action Events: - HelloWorld: - Type: Api # More info about API Event Source: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#api + Nightly: + Type: Schedule # More info about Schedule Event Source: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#schedule Properties: - Path: /hello - Method: get + Schedule: !Ref Schedule + + FunctionPolicy: # policy to allow scanning and stopping/terminating instances + Type: AWS::IAM::ManagedPolicy + Properties: + PolicyDocument: + Version: 2012-10-17 + Statement: + - Sid: ManageInstances + Effect: Allow + Resource: "*" + Action: + - ec2:Describe* + - ec2:StopInstances + - ec2:TerminateInstances + FunctionRole: # execute lambda function with this role Type: AWS::IAM::Role @@ -51,17 +69,12 @@ Resources: - sts:AssumeRole ManagedPolicyArns: - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole + - !Ref FunctionPolicy Outputs: - # ServerlessRestApi is an implicit API created out of Events key under Serverless::Function - # Find out more about other implicit resources you can reference within SAM - # https://github.com/awslabs/serverless-application-model/blob/master/docs/internals/generated_resources.rst#api - HelloWorldApi: - Description: "API Gateway endpoint URL for Prod stage for Hello World function" - Value: !Sub "https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/hello/" - HelloWorldFunctionArn: - Description: "Hello World Lambda Function ARN" - Value: !GetAtt HelloWorldFunction.Arn - HelloWorldFunctionRoleArn: - Description: "Implicit IAM Role created for Hello World function" + TerminatorFunctionArn: + Description: "Lambda Function ARN" + Value: !GetAtt TerminatorFunction.Arn + TerminatorFunctionRoleArn: + Description: "Implicit IAM Role created for function" Value: !GetAtt FunctionRole.Arn diff --git a/tests/unit/test_handler.py b/tests/unit/test_handler.py index 09588d3..b42362a 100644 --- a/tests/unit/test_handler.py +++ b/tests/unit/test_handler.py @@ -1,73 +1,286 @@ +import datetime import json +import os import pytest +import boto3 +from botocore.stub import Stubber -from hello_world import app +from ec2_terminator import app @pytest.fixture() -def apigw_event(): - """ Generates API GW Event""" +def stub_ec2_client(mocker): + """A single client for all stubbers""" + env_vars = { + 'AWS_DEFAULT_REGION': 'test-region', + } + mocker.patch.dict(os.environ, env_vars) + client = boto3.client('ec2') + return client + + +@pytest.fixture() +def mock_region_response(): + return { + 'Regions': [ + { + 'RegionName': 'test-region', + } + ] + } + +@pytest.fixture() +def mock_instance_response(): + return { + 'Reservations': [ + { + 'Instances': [ + { + 'InstanceId': 'test-instance', + 'State': {'Name': 'running'}, + } + ] + } + ] + } + + +@pytest.fixture() +def mock_ignore_tag_response(): return { - "body": '{ "test": "body"}', - "resource": "/{proxy+}", - "requestContext": { - "resourceId": "123456", - "apiId": "1234567890", - "resourcePath": "/{proxy+}", - "httpMethod": "POST", - "requestId": "c6af9ac6-7b61-11e6-9a41-93e8deadbeef", - "accountId": "123456789012", - "identity": { - "apiKey": "", - "userArn": "", - "cognitoAuthenticationType": "", - "caller": "", - "userAgent": "Custom User Agent String", - "user": "", - "cognitoIdentityPoolId": "", - "cognitoIdentityId": "", - "cognitoAuthenticationProvider": "", - "sourceIp": "127.0.0.1", - "accountId": "", - }, - "stage": "prod", - }, - "queryStringParameters": {"foo": "bar"}, - "headers": { - "Via": "1.1 08f323deadbeefa7af34d5feb414ce27.cloudfront.net (CloudFront)", - "Accept-Language": "en-US,en;q=0.8", - "CloudFront-Is-Desktop-Viewer": "true", - "CloudFront-Is-SmartTV-Viewer": "false", - "CloudFront-Is-Mobile-Viewer": "false", - "X-Forwarded-For": "127.0.0.1, 127.0.0.2", - "CloudFront-Viewer-Country": "US", - "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", - "Upgrade-Insecure-Requests": "1", - "X-Forwarded-Port": "443", - "Host": "1234567890.execute-api.us-east-1.amazonaws.com", - "X-Forwarded-Proto": "https", - "X-Amz-Cf-Id": "aaaaaaaaaae3VYQb9jd-nvCd-de396Uhbp027Y2JvkCPNLmGJHqlaA==", - "CloudFront-Is-Tablet-Viewer": "false", - "Cache-Control": "max-age=0", - "User-Agent": "Custom User Agent String", - "CloudFront-Forwarded-Proto": "https", - "Accept-Encoding": "gzip, deflate, sdch", - }, - "pathParameters": {"proxy": "/examplepath"}, - "httpMethod": "POST", - "stageVariables": {"baz": "qux"}, - "path": "/examplepath", + 'Reservations': [ + { + 'Instances': [ + { + 'InstanceId': 'ignore-instance', + 'State': {'Name': 'running'}, + 'Tags': [{ + 'Key': app.IGNORE_TAG_KEY, + 'Value': app.IGNORE_TAG_VALUE, + }] + } + ] + } + ] + } + + + +@pytest.fixture() +def mock_ignore_age_response(): + return { + 'Reservations': [ + { + 'Instances': [ + { + 'InstanceId': 'ignore-instance', + 'State': {'Name': 'running'}, + 'LaunchTime': datetime.datetime.now() + } + ] + } + ] + } + + +@pytest.fixture() +def mock_no_instances_response(): + return { + 'Reservations': [], + } + + +@pytest.fixture() +def mock_stop_response(): + return { + 'StoppingInstances': [ + { + 'InstanceId': 'test-instance', + } + ] + } + + +@pytest.fixture() +def mock_terminate_response(): + return { + 'TerminatingInstances': [ + { + 'InstanceId': 'test-instance', + } + ] + } + + +@pytest.fixture() +def mock_terminate_failed_response(): + return { + 'TerminatingInstances': [], + } + + +def test_stop(mocker, + stub_ec2_client, + mock_region_response, + mock_instance_response, + mock_stop_response): + """Test stopping instances""" + + env_vars = { + 'EC2_ACTION': '', + } + mocker.patch.dict(os.environ, env_vars) + + magic_client = mocker.MagicMock(return_value=stub_ec2_client) + mocker.patch('boto3.client', magic_client) + + with Stubber(stub_ec2_client) as stubber: + stubber.add_response('describe_regions', mock_region_response) + stubber.add_response('describe_instances', mock_instance_response) + stubber.add_response('stop_instances', mock_stop_response) + + ret = app.lambda_handler(None, None) + data = json.loads(ret["body"]) + + assert ret["statusCode"] == 200 + assert "message" in ret["body"] + assert data["message"] == "Instances stopped: ['test-instance']" + + +def test_terminate(mocker, + stub_ec2_client, + mock_region_response, + mock_instance_response, + mock_terminate_response): + """Test terminating instances""" + + env_vars = { + 'EC2_ACTION': 'TeRmInAtE', } + mocker.patch.dict(os.environ, env_vars) + magic_client = mocker.MagicMock(return_value=stub_ec2_client) + mocker.patch('boto3.client', magic_client) -def test_lambda_handler(apigw_event, mocker): + with Stubber(stub_ec2_client) as stubber: + stubber.add_response('describe_regions', mock_region_response) + stubber.add_response('describe_instances', mock_instance_response) + stubber.add_response('terminate_instances', mock_terminate_response) - ret = app.lambda_handler(apigw_event, "") + ret = app.lambda_handler(None, None) data = json.loads(ret["body"]) assert ret["statusCode"] == 200 assert "message" in ret["body"] - assert data["message"] == "hello world" - # assert "location" in data.dict_keys() + assert data["message"] == "Instances terminated: ['test-instance']" + + +def test_no_instances(mocker, + stub_ec2_client, + mock_region_response, + mock_no_instances_response): + magic_client = mocker.MagicMock(return_value=stub_ec2_client) + mocker.patch('boto3.client', magic_client) + + with Stubber(stub_ec2_client) as stubber: + stubber.add_response('describe_regions', mock_region_response) + stubber.add_response('describe_instances', mock_no_instances_response) + ret = app.lambda_handler(None, None) + data = json.loads(ret["body"]) + + assert ret["statusCode"] == 200 + assert "message" in ret["body"] + assert data["message"] == "No running or stopped instances found" + + +def test_ignore_tag_instance(mocker, + stub_ec2_client, + mock_region_response, + mock_ignore_tag_response): + magic_client = mocker.MagicMock(return_value=stub_ec2_client) + mocker.patch('boto3.client', magic_client) + + with Stubber(stub_ec2_client) as stubber: + stubber.add_response('describe_regions', mock_region_response) + stubber.add_response('describe_instances', mock_ignore_tag_response) + ret = app.lambda_handler(None, None) + data = json.loads(ret["body"]) + + assert ret["statusCode"] == 200 + assert "message" in ret["body"] + assert data["message"] == "No running or stopped instances found" + + +def test_ignore_age_instance(mocker, + stub_ec2_client, + mock_region_response, + mock_ignore_age_response): + magic_client = mocker.MagicMock(return_value=stub_ec2_client) + mocker.patch('boto3.client', magic_client) + + with Stubber(stub_ec2_client) as stubber: + stubber.add_response('describe_regions', mock_region_response) + stubber.add_response('describe_instances', mock_ignore_age_response) + ret = app.lambda_handler(None, None) + data = json.loads(ret["body"]) + + assert ret["statusCode"] == 200 + assert "message" in ret["body"] + assert data["message"] == "No running or stopped instances found" + + +def test_terminate_failed(mocker, + stub_ec2_client, + mock_region_response, + mock_instance_response, + mock_terminate_failed_response): + + env_vars = { + 'EC2_ACTION': 'TERMINATE', + } + mocker.patch.dict(os.environ, env_vars) + + magic_client = mocker.MagicMock(return_value=stub_ec2_client) + mocker.patch('boto3.client', magic_client) + + with Stubber(stub_ec2_client) as stubber: + stubber.add_response('describe_regions', mock_region_response) + stubber.add_response('describe_instances', mock_instance_response) + stubber.add_response('terminate_instances', mock_terminate_failed_response) + ret = app.lambda_handler(None, None) + data = json.loads(ret["body"]) + + assert ret["statusCode"] == 500 + assert "message" in ret["body"] + assert data["message"] == "Some instances failed to stop or terminate" + + +def test_client_exception(mocker): + mocker.patch("boto3.client", side_effect=Exception('TestClientException')) + + ret = app.lambda_handler(None, None) + data = json.loads(ret["body"]) + + assert ret["statusCode"] == 500 + assert "message" in ret["body"] + assert data["message"] == "TestClientException" + + +def test_regions_exception(mocker, stub_ec2_client): + magic_client = mocker.MagicMock(return_value=stub_ec2_client) + mocker.patch('boto3.client', magic_client) + + no_regions = { + "Regions": [], + } + + with Stubber(stub_ec2_client) as stubber: + stubber.add_response('describe_regions', no_regions) + ret = app.lambda_handler(None, None) + data = json.loads(ret["body"]) + + assert ret["statusCode"] == 500 + assert "message" in ret["body"] + assert data["message"] == "No available regions"