From 775595fe6d5c6df5e0328254990d7411da083553 Mon Sep 17 00:00:00 2001 From: Henning Jacobs Date: Mon, 23 Dec 2019 17:15:25 +0100 Subject: [PATCH] Switch to poetry & black (#49) * Pipenv -> poetry * format code with black * fix types for mypy * ignore mypy cache * use poetry --- .flake8 | 3 + .gitignore | 3 +- .travis.yml | 5 +- Dockerfile | 22 +- Makefile | 14 +- Pipfile | 18 - Pipfile.lock | 299 ------------- README.rst | 4 +- kube_janitor/__init__.py | 2 +- kube_janitor/cmd.py | 65 ++- kube_janitor/helper.py | 40 +- kube_janitor/janitor.py | 253 +++++++---- kube_janitor/main.py | 61 ++- kube_janitor/resources.py | 44 +- kube_janitor/rules.py | 37 +- pipenv-install.py | 15 - poetry.lock | 696 ++++++++++++++++++++++++++++++ pyproject.toml | 20 + tests/test_clean_up.py | 524 ++++++++++++++-------- tests/test_cmd.py | 2 +- tests/test_delete_notification.py | 264 +++++++++--- tests/test_format_duration.py | 16 +- tests/test_main.py | 30 +- tests/test_parse_expiry.py | 10 +- tests/test_parse_ttl.py | 18 +- tests/test_rules.py | 42 +- tests/test_shutdown.py | 2 +- tox.ini | 14 - 28 files changed, 1704 insertions(+), 819 deletions(-) create mode 100644 .flake8 delete mode 100644 Pipfile delete mode 100644 Pipfile.lock delete mode 100755 pipenv-install.py create mode 100644 poetry.lock create mode 100644 pyproject.toml delete mode 100644 tox.ini diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..a9a43a9 --- /dev/null +++ b/.flake8 @@ -0,0 +1,3 @@ +[flake8] +max-line-length=240 +ignore=E722,W503 diff --git a/.gitignore b/.gitignore index 9db1771..e33b0f0 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,5 @@ dist/ *.bak htmlcov/ .pytest_cache/ -.vscode \ No newline at end of file +.vscode +.mypy_cache diff --git a/.travis.yml b/.travis.yml index 1dd6bb2..ba1b999 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,4 +1,4 @@ -dist: xenial +dist: bionic sudo: yes language: python python: @@ -6,8 +6,7 @@ python: services: - docker install: - - pip install pipenv - - pipenv install --dev + - pip install poetry script: - make test docker after_success: diff --git a/Dockerfile b/Dockerfile index 643d2c2..a28e700 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,23 +1,27 @@ -FROM python:3.7-alpine3.10 -MAINTAINER Henning Jacobs +FROM python:3.8-slim WORKDIR / -COPY Pipfile.lock / -COPY pipenv-install.py / +RUN pip3 install poetry -RUN /pipenv-install.py && \ - rm -fr /usr/local/lib/python3.7/site-packages/pip && \ - rm -fr /usr/local/lib/python3.7/site-packages/setuptools +COPY poetry.lock / +COPY pyproject.toml / -FROM python:3.7-alpine3.10 +RUN poetry config virtualenvs.create false && \ + poetry install --no-interaction --no-dev --no-ansi + +FROM python:3.8-slim WORKDIR / -COPY --from=0 /usr/local/lib/python3.7/site-packages /usr/local/lib/python3.7/site-packages +# copy pre-built packages to this image +COPY --from=0 /usr/local/lib/python3.8/site-packages /usr/local/lib/python3.8/site-packages + +# now copy the actual code we will execute (poetry install above was just for dependencies) COPY kube_janitor /kube_janitor ARG VERSION=dev + RUN sed -i "s/__version__ = .*/__version__ = '${VERSION}'/" /kube_janitor/__init__.py ENTRYPOINT ["python3", "-m", "kube_janitor"] diff --git a/Makefile b/Makefile index be6d53c..e9f033e 100644 --- a/Makefile +++ b/Makefile @@ -6,10 +6,16 @@ TAG ?= $(VERSION) default: docker -test: - pipenv run flake8 - pipenv run coverage run --source=kube_janitor -m py.test - pipenv run coverage report +.PHONY: install +install: + poetry install + +test: install + poetry run flake8 + poetry run black --check kube_janitor + poetry run mypy --ignore-missing-imports kube_janitor + poetry run coverage run --source=kube_janitor -m py.test -v + poetry run coverage report docker: docker build --build-arg "VERSION=$(VERSION)" -t "$(IMAGE):$(TAG)" . diff --git a/Pipfile b/Pipfile deleted file mode 100644 index 469d67b..0000000 --- a/Pipfile +++ /dev/null @@ -1,18 +0,0 @@ -[[source]] -url = "https://pypi.org/simple" -verify_ssl = true -name = "pypi" - -[packages] -jmespath = "*" -pykube-ng = "*" - -[dev-packages] -flake8 = "*" -pytest-cov = "*" -pytest = "*" -coveralls = "*" -coverage = "*" - -[requires] -python_version = "3.7" diff --git a/Pipfile.lock b/Pipfile.lock deleted file mode 100644 index 4616bec..0000000 --- a/Pipfile.lock +++ /dev/null @@ -1,299 +0,0 @@ -{ - "_meta": { - "hash": { - "sha256": "f39a7e75c780774e2cf9885b076c01728e1cfda89def3d64989bbf7efc83fa36" - }, - "pipfile-spec": 6, - "requires": { - "python_version": "3.7" - }, - "sources": [ - { - "name": "pypi", - "url": "https://pypi.org/simple", - "verify_ssl": true - } - ] - }, - "default": { - "certifi": { - "hashes": [ - "sha256:017c25db2a153ce562900032d5bc68e9f191e44e9a0f762f373977de9df1fbb3", - "sha256:25b64c7da4cd7479594d035c08c2d809eb4aab3a26e5a990ea98cc450c320f1f" - ], - "version": "==2019.11.28" - }, - "chardet": { - "hashes": [ - "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", - "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691" - ], - "version": "==3.0.4" - }, - "idna": { - "hashes": [ - "sha256:c357b3f628cf53ae2c4c05627ecc484553142ca23264e593d327bcde5e9c3407", - "sha256:ea8b7f6188e6fa117537c3df7da9fc686d485087abf6ac197f9c46432f7e4a3c" - ], - "version": "==2.8" - }, - "jmespath": { - "hashes": [ - "sha256:3720a4b1bd659dd2eecad0666459b9788813e032b83e7ba58578e48254e0a0e6", - "sha256:bde2aef6f44302dfb30320115b17d030798de8c4110e28d5cf6cf91a7a31074c" - ], - "index": "pypi", - "version": "==0.9.4" - }, - "pykube-ng": { - "hashes": [ - "sha256:440b4183719e673c11b7cd68669d3ba0b710c192834d16bd7766dfb6df9737b2", - "sha256:bd872f0e6ad4a58cc6cb005a9d15decaba1363efc7d52ee75a64d16a5e986b87" - ], - "index": "pypi", - "version": "==19.10.0" - }, - "pyyaml": { - "hashes": [ - "sha256:0e7f69397d53155e55d10ff68fdfb2cf630a35e6daf65cf0bdeaf04f127c09dc", - "sha256:2e9f0b7c5914367b0916c3c104a024bb68f269a486b9d04a2e8ac6f6597b7803", - "sha256:35ace9b4147848cafac3db142795ee42deebe9d0dad885ce643928e88daebdcc", - "sha256:38a4f0d114101c58c0f3a88aeaa44d63efd588845c5a2df5290b73db8f246d15", - "sha256:483eb6a33b671408c8529106df3707270bfacb2447bf8ad856a4b4f57f6e3075", - "sha256:4b6be5edb9f6bb73680f5bf4ee08ff25416d1400fbd4535fe0069b2994da07cd", - "sha256:7f38e35c00e160db592091751d385cd7b3046d6d51f578b29943225178257b31", - "sha256:8100c896ecb361794d8bfdb9c11fce618c7cf83d624d73d5ab38aef3bc82d43f", - "sha256:c0ee8eca2c582d29c3c2ec6e2c4f703d1b7f1fb10bc72317355a746057e7346c", - "sha256:e4c015484ff0ff197564917b4b4246ca03f411b9bd7f16e02a2f586eb48b6d04", - "sha256:ebc4ed52dcc93eeebeae5cf5deb2ae4347b3a81c3fa12b0b8c976544829396a4" - ], - "version": "==5.2" - }, - "requests": { - "hashes": [ - "sha256:11e007a8a2aa0323f5a921e9e6a2d7e4e67d9877e85773fba9ba6419025cbeb4", - "sha256:9cf5292fcd0f598c671cfc1e0d7d1a7f13bb8085e9a590f48c010551dc6c4b31" - ], - "version": "==2.22.0" - }, - "urllib3": { - "hashes": [ - "sha256:a8a318824cc77d1fd4b2bec2ded92646630d7fe8619497b142c84a9e6f5a7293", - "sha256:f3c5fd51747d450d4dcf6f923c81f78f811aab8205fda64b0aba34a4e48b0745" - ], - "version": "==1.25.7" - } - }, - "develop": { - "attrs": { - "hashes": [ - "sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c", - "sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72" - ], - "version": "==19.3.0" - }, - "certifi": { - "hashes": [ - "sha256:017c25db2a153ce562900032d5bc68e9f191e44e9a0f762f373977de9df1fbb3", - "sha256:25b64c7da4cd7479594d035c08c2d809eb4aab3a26e5a990ea98cc450c320f1f" - ], - "version": "==2019.11.28" - }, - "chardet": { - "hashes": [ - "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", - "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691" - ], - "version": "==3.0.4" - }, - "coverage": { - "hashes": [ - "sha256:08907593569fe59baca0bf152c43f3863201efb6113ecb38ce7e97ce339805a6", - "sha256:0be0f1ed45fc0c185cfd4ecc19a1d6532d72f86a2bac9de7e24541febad72650", - "sha256:141f08ed3c4b1847015e2cd62ec06d35e67a3ac185c26f7635f4406b90afa9c5", - "sha256:19e4df788a0581238e9390c85a7a09af39c7b539b29f25c89209e6c3e371270d", - "sha256:23cc09ed395b03424d1ae30dcc292615c1372bfba7141eb85e11e50efaa6b351", - "sha256:245388cda02af78276b479f299bbf3783ef0a6a6273037d7c60dc73b8d8d7755", - "sha256:331cb5115673a20fb131dadd22f5bcaf7677ef758741312bee4937d71a14b2ef", - "sha256:386e2e4090f0bc5df274e720105c342263423e77ee8826002dcffe0c9533dbca", - "sha256:3a794ce50daee01c74a494919d5ebdc23d58873747fa0e288318728533a3e1ca", - "sha256:60851187677b24c6085248f0a0b9b98d49cba7ecc7ec60ba6b9d2e5574ac1ee9", - "sha256:63a9a5fc43b58735f65ed63d2cf43508f462dc49857da70b8980ad78d41d52fc", - "sha256:6b62544bb68106e3f00b21c8930e83e584fdca005d4fffd29bb39fb3ffa03cb5", - "sha256:6ba744056423ef8d450cf627289166da65903885272055fb4b5e113137cfa14f", - "sha256:7494b0b0274c5072bddbfd5b4a6c6f18fbbe1ab1d22a41e99cd2d00c8f96ecfe", - "sha256:826f32b9547c8091679ff292a82aca9c7b9650f9fda3e2ca6bf2ac905b7ce888", - "sha256:93715dffbcd0678057f947f496484e906bf9509f5c1c38fc9ba3922893cda5f5", - "sha256:9a334d6c83dfeadae576b4d633a71620d40d1c379129d587faa42ee3e2a85cce", - "sha256:af7ed8a8aa6957aac47b4268631fa1df984643f07ef00acd374e456364b373f5", - "sha256:bf0a7aed7f5521c7ca67febd57db473af4762b9622254291fbcbb8cd0ba5e33e", - "sha256:bf1ef9eb901113a9805287e090452c05547578eaab1b62e4ad456fcc049a9b7e", - "sha256:c0afd27bc0e307a1ffc04ca5ec010a290e49e3afbe841c5cafc5c5a80ecd81c9", - "sha256:dd579709a87092c6dbee09d1b7cfa81831040705ffa12a1b248935274aee0437", - "sha256:df6712284b2e44a065097846488f66840445eb987eb81b3cc6e4149e7b6982e1", - "sha256:e07d9f1a23e9e93ab5c62902833bf3e4b1f65502927379148b6622686223125c", - "sha256:e2ede7c1d45e65e209d6093b762e98e8318ddeff95317d07a27a2140b80cfd24", - "sha256:e4ef9c164eb55123c62411f5936b5c2e521b12356037b6e1c2617cef45523d47", - "sha256:eca2b7343524e7ba246cab8ff00cab47a2d6d54ada3b02772e908a45675722e2", - "sha256:eee64c616adeff7db37cc37da4180a3a5b6177f5c46b187894e633f088fb5b28", - "sha256:ef824cad1f980d27f26166f86856efe11eff9912c4fed97d3804820d43fa550c", - "sha256:efc89291bd5a08855829a3c522df16d856455297cf35ae827a37edac45f466a7", - "sha256:fa964bae817babece5aa2e8c1af841bebb6d0b9add8e637548809d040443fee0", - "sha256:ff37757e068ae606659c28c3bd0d923f9d29a85de79bf25b2b34b148473b5025" - ], - "index": "pypi", - "version": "==4.5.4" - }, - "coveralls": { - "hashes": [ - "sha256:25522a50cdf720d956601ca6ef480786e655ae2f0c94270c77e1a23d742de558", - "sha256:8e3315e8620bb6b3c6f3179a75f498e7179c93b3ddc440352404f941b1f70524" - ], - "index": "pypi", - "version": "==1.9.2" - }, - "docopt": { - "hashes": [ - "sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491" - ], - "version": "==0.6.2" - }, - "entrypoints": { - "hashes": [ - "sha256:589f874b313739ad35be6e0cd7efde2a4e9b6fea91edcc34e58ecbb8dbe56d19", - "sha256:c70dd71abe5a8c85e55e12c19bd91ccfeec11a6e99044204511f9ed547d48451" - ], - "version": "==0.3" - }, - "flake8": { - "hashes": [ - "sha256:45681a117ecc81e870cbf1262835ae4af5e7a8b08e40b944a8a6e6b895914cfb", - "sha256:49356e766643ad15072a789a20915d3c91dc89fd313ccd71802303fd67e4deca" - ], - "index": "pypi", - "version": "==3.7.9" - }, - "idna": { - "hashes": [ - "sha256:c357b3f628cf53ae2c4c05627ecc484553142ca23264e593d327bcde5e9c3407", - "sha256:ea8b7f6188e6fa117537c3df7da9fc686d485087abf6ac197f9c46432f7e4a3c" - ], - "version": "==2.8" - }, - "importlib-metadata": { - "hashes": [ - "sha256:073a852570f92da5f744a3472af1b61e28e9f78ccf0c9117658dc32b15de7b45", - "sha256:d95141fbfa7ef2ec65cfd945e2af7e5a6ddbd7c8d9a25e66ff3be8e3daf9f60f" - ], - "markers": "python_version < '3.8'", - "version": "==1.3.0" - }, - "mccabe": { - "hashes": [ - "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42", - "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f" - ], - "version": "==0.6.1" - }, - "more-itertools": { - "hashes": [ - "sha256:b84b238cce0d9adad5ed87e745778d20a3f8487d0f0cb8b8a586816c7496458d", - "sha256:c833ef592a0324bcc6a60e48440da07645063c453880c9477ceb22490aec1564" - ], - "version": "==8.0.2" - }, - "packaging": { - "hashes": [ - "sha256:28b924174df7a2fa32c1953825ff29c61e2f5e082343165438812f00d3a7fc47", - "sha256:d9551545c6d761f3def1677baf08ab2a3ca17c56879e70fecba2fc4dde4ed108" - ], - "version": "==19.2" - }, - "pluggy": { - "hashes": [ - "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0", - "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d" - ], - "version": "==0.13.1" - }, - "py": { - "hashes": [ - "sha256:64f65755aee5b381cea27766a3a147c3f15b9b6b9ac88676de66ba2ae36793fa", - "sha256:dc639b046a6e2cff5bbe40194ad65936d6ba360b52b3c3fe1d08a82dd50b5e53" - ], - "version": "==1.8.0" - }, - "pycodestyle": { - "hashes": [ - "sha256:95a2219d12372f05704562a14ec30bc76b05a5b297b21a5dfe3f6fac3491ae56", - "sha256:e40a936c9a450ad81df37f549d676d127b1b66000a6c500caa2b085bc0ca976c" - ], - "version": "==2.5.0" - }, - "pyflakes": { - "hashes": [ - "sha256:17dbeb2e3f4d772725c777fabc446d5634d1038f234e77343108ce445ea69ce0", - "sha256:d976835886f8c5b31d47970ed689944a0262b5f3afa00a5a7b4dc81e5449f8a2" - ], - "version": "==2.1.1" - }, - "pyparsing": { - "hashes": [ - "sha256:20f995ecd72f2a1f4bf6b072b63b22e2eb457836601e76d6e5dfcd75436acc1f", - "sha256:4ca62001be367f01bd3e92ecbb79070272a9d4964dce6a48a82ff0b8bc7e683a" - ], - "version": "==2.4.5" - }, - "pytest": { - "hashes": [ - "sha256:63344a2e3bce2e4d522fd62b4fdebb647c019f1f9e4ca075debbd13219db4418", - "sha256:f67403f33b2b1d25a6756184077394167fe5e2f9d8bdaab30707d19ccec35427" - ], - "index": "pypi", - "version": "==5.3.1" - }, - "pytest-cov": { - "hashes": [ - "sha256:cc6742d8bac45070217169f5f72ceee1e0e55b0221f54bcf24845972d3a47f2b", - "sha256:cdbdef4f870408ebdbfeb44e63e07eb18bb4619fae852f6e760645fa36172626" - ], - "index": "pypi", - "version": "==2.8.1" - }, - "requests": { - "hashes": [ - "sha256:11e007a8a2aa0323f5a921e9e6a2d7e4e67d9877e85773fba9ba6419025cbeb4", - "sha256:9cf5292fcd0f598c671cfc1e0d7d1a7f13bb8085e9a590f48c010551dc6c4b31" - ], - "version": "==2.22.0" - }, - "six": { - "hashes": [ - "sha256:1f1b7d42e254082a9db6279deae68afb421ceba6158efa6131de7b3003ee93fd", - "sha256:30f610279e8b2578cab6db20741130331735c781b56053c59c4076da27f06b66" - ], - "version": "==1.13.0" - }, - "urllib3": { - "hashes": [ - "sha256:a8a318824cc77d1fd4b2bec2ded92646630d7fe8619497b142c84a9e6f5a7293", - "sha256:f3c5fd51747d450d4dcf6f923c81f78f811aab8205fda64b0aba34a4e48b0745" - ], - "version": "==1.25.7" - }, - "wcwidth": { - "hashes": [ - "sha256:3df37372226d6e63e1b1e1eda15c594bca98a22d33a23832a90998faa96bc65e", - "sha256:f4ebe71925af7b40a864553f761ed559b43544f8f71746c2d756c7fe788ade7c" - ], - "version": "==0.1.7" - }, - "zipp": { - "hashes": [ - "sha256:3718b1cbcd963c7d4c5511a8240812904164b7f381b647143a89d3b98f9bcd8e", - "sha256:f06903e9f1f43b12d371004b4ac7b06ab39a44adc747266928ae6debfa7b3335" - ], - "version": "==0.6.0" - } - } -} diff --git a/README.rst b/README.rst index 81f714b..7fdb7e0 100644 --- a/README.rst +++ b/README.rst @@ -193,8 +193,8 @@ You can run Kubernetes Janitor against your current kubeconfig context, e.g. loc .. code-block:: bash - $ pipenv install --dev - $ pipenv shell + $ poetry install + $ poetry shell $ python3 -m kube_janitor --dry-run --debug --once To run PEP8 (flake8) checks and unit tests including coverage report: diff --git a/kube_janitor/__init__.py b/kube_janitor/__init__.py index 50f5402..5f6c1a7 100644 --- a/kube_janitor/__init__.py +++ b/kube_janitor/__init__.py @@ -1 +1 @@ -__version__ = '2019.2.1dev0' # will be replaced during build +__version__ = "2019.2.1dev0" # will be replaced during build diff --git a/kube_janitor/cmd.py b/kube_janitor/cmd.py index a70d5fe..1b5a7b8 100644 --- a/kube_janitor/cmd.py +++ b/kube_janitor/cmd.py @@ -2,26 +2,55 @@ import argparse -DEFAULT_EXCLUDE_RESOURCES = 'events,controllerrevisions' -DEFAULT_EXCLUDE_NAMESPACES = 'kube-system' +DEFAULT_EXCLUDE_RESOURCES = "events,controllerrevisions" +DEFAULT_EXCLUDE_NAMESPACES = "kube-system" def get_parser(): parser = argparse.ArgumentParser() - parser.add_argument('--dry-run', help='Dry run mode: do not change anything, just print what would be done', - action='store_true') - parser.add_argument('--debug', '-d', help='Debug mode: print more information', action='store_true') - parser.add_argument('--once', help='Run loop only once and exit', action='store_true') - parser.add_argument('--interval', type=int, help='Loop interval (default: 30s)', default=30) - parser.add_argument('--delete-notification', type=int, help='Send an event seconds before to warn of the deletion', required=False) - parser.add_argument('--include-resources', help='Resources to consider for clean up (default: all)', - default=os.getenv('INCLUDE_RESOURCES', 'all')) - parser.add_argument('--exclude-resources', help=f'Resources to exclude from clean up (default: {DEFAULT_EXCLUDE_RESOURCES})', - default=os.getenv('EXCLUDE_RESOURCES', DEFAULT_EXCLUDE_RESOURCES)) - parser.add_argument('--include-namespaces', help='Include namespaces for clean up (default: all)', - default=os.getenv('INCLUDE_NAMESPACES', 'all')) - parser.add_argument('--exclude-namespaces', help=f'Exclude namespaces from clean up (default: {DEFAULT_EXCLUDE_NAMESPACES})', - default=os.getenv('EXCLUDE_NAMESPACES', DEFAULT_EXCLUDE_NAMESPACES)) - parser.add_argument('--rules-file', help='Load TTL rules from given file path', - default=os.getenv('RULES_FILE')) + parser.add_argument( + "--dry-run", + help="Dry run mode: do not change anything, just print what would be done", + action="store_true", + ) + parser.add_argument( + "--debug", "-d", help="Debug mode: print more information", action="store_true" + ) + parser.add_argument( + "--once", help="Run loop only once and exit", action="store_true" + ) + parser.add_argument( + "--interval", type=int, help="Loop interval (default: 30s)", default=30 + ) + parser.add_argument( + "--delete-notification", + type=int, + help="Send an event seconds before to warn of the deletion", + required=False, + ) + parser.add_argument( + "--include-resources", + help="Resources to consider for clean up (default: all)", + default=os.getenv("INCLUDE_RESOURCES", "all"), + ) + parser.add_argument( + "--exclude-resources", + help=f"Resources to exclude from clean up (default: {DEFAULT_EXCLUDE_RESOURCES})", + default=os.getenv("EXCLUDE_RESOURCES", DEFAULT_EXCLUDE_RESOURCES), + ) + parser.add_argument( + "--include-namespaces", + help="Include namespaces for clean up (default: all)", + default=os.getenv("INCLUDE_NAMESPACES", "all"), + ) + parser.add_argument( + "--exclude-namespaces", + help=f"Exclude namespaces from clean up (default: {DEFAULT_EXCLUDE_NAMESPACES})", + default=os.getenv("EXCLUDE_NAMESPACES", DEFAULT_EXCLUDE_NAMESPACES), + ) + parser.add_argument( + "--rules-file", + help="Load TTL rules from given file path", + default=os.getenv("RULES_FILE"), + ) return parser diff --git a/kube_janitor/helper.py b/kube_janitor/helper.py index 77fb397..00bb7a0 100644 --- a/kube_janitor/helper.py +++ b/kube_janitor/helper.py @@ -6,18 +6,20 @@ TIME_UNIT_TO_SECONDS = { - 's': 1, - 'm': 60, - 'h': 60*60, - 'd': 60*60*24, - 'w': 60*60*24*7 + "s": 1, + "m": 60, + "h": 60 * 60, + "d": 60 * 60 * 24, + "w": 60 * 60 * 24 * 7, } -FACTOR_TO_TIME_UNIT = list(sorted([(v, k) for k, v in TIME_UNIT_TO_SECONDS.items()], reverse=True)) -TTL_PATTERN = re.compile(r'^(\d+)([smhdw])$') -DATETIME_PATTERNS = ['%Y-%m-%dT%H:%M:%SZ', '%Y-%m-%dT%H:%M', '%Y-%m-%d'] +FACTOR_TO_TIME_UNIT = list( + sorted([(v, k) for k, v in TIME_UNIT_TO_SECONDS.items()], reverse=True) +) +TTL_PATTERN = re.compile(r"^(\d+)([smhdw])$") +DATETIME_PATTERNS = ["%Y-%m-%dT%H:%M:%SZ", "%Y-%m-%dT%H:%M", "%Y-%m-%d"] -TTL_UNLIMITED = 'forever' +TTL_UNLIMITED = "forever" def parse_ttl(ttl: str) -> int: @@ -26,7 +28,8 @@ def parse_ttl(ttl: str) -> int: match = TTL_PATTERN.match(ttl) if not match: raise ValueError( - f'TTL value "{ttl}" does not match format (e.g. 60s, 5m, 8h, 7d, 2w)') + f'TTL value "{ttl}" does not match format (e.g. 60s, 5m, 8h, 7d, 2w)' + ) value = int(match.group(1)) unit = match.group(2) @@ -39,35 +42,36 @@ def parse_ttl(ttl: str) -> int: return value * multiplier -def parse_expiry(expiry: str) -> datetime: +def parse_expiry(expiry: str) -> datetime.datetime: for pattern in DATETIME_PATTERNS: try: return datetime.datetime.strptime(expiry, pattern).replace(tzinfo=None) except ValueError: pass raise ValueError( - f'expiry value "{expiry}" does not match format 2019-02-25T09:26:14Z, 2019-02-25T09:26, or 2019-02-25') + f'expiry value "{expiry}" does not match format 2019-02-25T09:26:14Z, 2019-02-25T09:26, or 2019-02-25' + ) def format_duration(seconds: int) -> str: - ''' + """ Print a given duration in seconds (positive integer) as human readable duration string >>> format_duration(3900) 1h5m - ''' + """ parts = [] if seconds < 0: # special handling for negative durations # use positive (absolute value) with divmod, but add negative sign - parts.append('-') + parts.append("-") remainder = abs(seconds) for factor, unit in FACTOR_TO_TIME_UNIT: value, remainder = divmod(remainder, factor) if value > 0 or (seconds == 0 and factor == 1): - parts.append(f'{value}{unit}') - return ''.join(parts) + parts.append(f"{value}{unit}") + return "".join(parts) def get_kube_api(): @@ -75,6 +79,6 @@ def get_kube_api(): config = pykube.KubeConfig.from_service_account() except FileNotFoundError: # local testing - config = pykube.KubeConfig.from_file(os.getenv('KUBECONFIG', '~/.kube/config')) + config = pykube.KubeConfig.from_file(os.getenv("KUBECONFIG", "~/.kube/config")) api = pykube.HTTPClient(config) return api diff --git a/kube_janitor/janitor.py b/kube_janitor/janitor.py index e262b2c..466986c 100644 --- a/kube_janitor/janitor.py +++ b/kube_janitor/janitor.py @@ -10,16 +10,21 @@ logger = logging.getLogger(__name__) -TTL_ANNOTATION = 'janitor/ttl' -EXPIRY_ANNOTATION = 'janitor/expires' -NOTIFIED_ANNOTATION = 'janitor/notified' +TTL_ANNOTATION = "janitor/ttl" +EXPIRY_ANNOTATION = "janitor/expires" +NOTIFIED_ANNOTATION = "janitor/notified" -def matches_resource_filter(resource, include_resources: frozenset, exclude_resources: frozenset, - include_namespaces: frozenset, exclude_namespaces: frozenset): +def matches_resource_filter( + resource, + include_resources: frozenset, + exclude_resources: frozenset, + include_namespaces: frozenset, + exclude_namespaces: frozenset, +): resource_type_plural = resource.endpoint - if resource.kind == 'Namespace': + if resource.kind == "Namespace": namespace = resource.name else: namespace = resource.namespace @@ -28,25 +33,38 @@ def matches_resource_filter(resource, include_resources: frozenset, exclude_reso # skip all non-namespaced resources return False - resource_included = 'all' in include_resources or resource_type_plural in include_resources - namespace_included = 'all' in include_namespaces or namespace in include_namespaces + resource_included = ( + "all" in include_resources or resource_type_plural in include_resources + ) + namespace_included = "all" in include_namespaces or namespace in include_namespaces resource_excluded = resource_type_plural in exclude_resources namespace_excluded = namespace in exclude_namespaces - return resource_included and not resource_excluded and namespace_included and not namespace_excluded - - -def get_ttl_expiry_time(resource, ttl_seconds: int) -> datetime: - creation_time = datetime.datetime.strptime(resource.metadata['creationTimestamp'], '%Y-%m-%dT%H:%M:%SZ') + return ( + resource_included + and not resource_excluded + and namespace_included + and not namespace_excluded + ) + + +def get_ttl_expiry_time(resource, ttl_seconds: int) -> datetime.datetime: + creation_time = datetime.datetime.strptime( + resource.metadata["creationTimestamp"], "%Y-%m-%dT%H:%M:%SZ" + ) return creation_time + datetime.timedelta(seconds=ttl_seconds) -def get_delete_notification_time(expiry_timestamp, delete_notification) -> datetime: +def get_delete_notification_time( + expiry_timestamp, delete_notification +) -> datetime.datetime: return expiry_timestamp - datetime.timedelta(seconds=delete_notification) def add_notification_flag(resource, dry_run: bool): if dry_run: - logger.info(f'**DRY-RUN**: {resource.kind} {resource.namespace}/{resource.name} would be annotated as janitor/notified: yes') + logger.info( + f"**DRY-RUN**: {resource.kind} {resource.namespace}/{resource.name} would be annotated as janitor/notified: yes" + ) else: resource.annotations[NOTIFIED_ANNOTATION] = "yes" resource.update() @@ -57,7 +75,9 @@ def was_notified(resource): def get_age(resource): - creation_time = datetime.datetime.strptime(resource.metadata['creationTimestamp'], '%Y-%m-%dT%H:%M:%SZ') + creation_time = datetime.datetime.strptime( + resource.metadata["creationTimestamp"], "%Y-%m-%dT%H:%M:%SZ" + ) now = utcnow() age = now - creation_time return age @@ -67,96 +87,117 @@ def utcnow(): return datetime.datetime.utcnow() -def send_delete_notification(resource, reason: str, expire: datetime, dry_run: bool): - formatted_expire_datetime = expire.strftime('%Y-%m-%dT%H:%M:%SZ') - message = f'{resource.kind} {resource.name} will be deleted at {formatted_expire_datetime} ({reason})' +def send_delete_notification( + resource, reason: str, expire: datetime.datetime, dry_run: bool +): + formatted_expire_datetime = expire.strftime("%Y-%m-%dT%H:%M:%SZ") + message = f"{resource.kind} {resource.name} will be deleted at {formatted_expire_datetime} ({reason})" logger.info(message) - create_event(resource, message, 'DeleteNotification', dry_run=dry_run) + create_event(resource, message, "DeleteNotification", dry_run=dry_run) add_notification_flag(resource, dry_run=dry_run) def create_event(resource, message: str, reason: str, dry_run: bool): now = utcnow() - timestamp = now.strftime('%Y-%m-%dT%H:%M:%SZ') - event = Event(resource.api, { - 'metadata': {'namespace': resource.namespace, 'generateName': 'kube-janitor-'}, - 'type': 'Normal', - 'count': 1, - 'firstTimestamp': timestamp, - 'lastTimestamp': timestamp, - 'reason': reason, - 'involvedObject': { - 'apiVersion': resource.version, - 'name': resource.name, - 'namespace': resource.namespace, - 'kind': resource.kind, - 'resourceVersion': resource.metadata.get('resourceVersion'), - # https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#uids - 'uid': resource.metadata.get('uid') + timestamp = now.strftime("%Y-%m-%dT%H:%M:%SZ") + event = Event( + resource.api, + { + "metadata": { + "namespace": resource.namespace, + "generateName": "kube-janitor-", + }, + "type": "Normal", + "count": 1, + "firstTimestamp": timestamp, + "lastTimestamp": timestamp, + "reason": reason, + "involvedObject": { + "apiVersion": resource.version, + "name": resource.name, + "namespace": resource.namespace, + "kind": resource.kind, + "resourceVersion": resource.metadata.get("resourceVersion"), + # https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#uids + "uid": resource.metadata.get("uid"), + }, + "message": message, + "source": {"component": "kube-janitor"}, }, - 'message': message, - 'source': { - 'component': 'kube-janitor' - } - - }) + ) if not dry_run: try: event.create() except Exception as e: - logger.error(f'Could not create event {event.obj}: {e}') + logger.error(f"Could not create event {event.obj}: {e}") def delete(resource, dry_run: bool): if dry_run: - logger.info(f'**DRY-RUN**: would delete {resource.kind} {resource.namespace}/{resource.name}') + logger.info( + f"**DRY-RUN**: would delete {resource.kind} {resource.namespace}/{resource.name}" + ) else: - logger.info(f'Deleting {resource.kind} {resource.namespace or ""}{"/" if resource.namespace else ""}{resource.name}..') + logger.info( + f'Deleting {resource.kind} {resource.namespace or ""}{"/" if resource.namespace else ""}{resource.name}..' + ) try: # force cascading delete also for older objects (e.g. extensions/v1beta1) # see https://kubernetes.io/docs/concepts/workloads/controllers/garbage-collection/#setting-the-cascading-deletion-policy - resource.delete(propagation_policy='Foreground') + resource.delete(propagation_policy="Foreground") except Exception as e: - logger.error(f'Could not delete {resource.kind} {resource.namespace}/{resource.name}: {e}') + logger.error( + f"Could not delete {resource.kind} {resource.namespace}/{resource.name}: {e}" + ) def handle_resource_on_ttl(resource, rules, delete_notification: int, dry_run: bool): - counter = {'resources-processed': 1} + counter = {"resources-processed": 1} ttl = resource.annotations.get(TTL_ANNOTATION) if ttl: - reason = f'annotation {TTL_ANNOTATION} is set' + reason = f"annotation {TTL_ANNOTATION} is set" else: for rule in rules: if rule.matches(resource): - logger.debug(f'Rule {rule.id} applies {rule.ttl} TTL to {resource.kind} {resource.namespace}/{resource.name}') + logger.debug( + f"Rule {rule.id} applies {rule.ttl} TTL to {resource.kind} {resource.namespace}/{resource.name}" + ) ttl = rule.ttl - reason = f'rule {rule.id} matches' - counter[f'rule-{rule.id}-matches'] = 1 + reason = f"rule {rule.id} matches" + counter[f"rule-{rule.id}-matches"] = 1 # first rule which matches break if ttl: try: ttl_seconds = parse_ttl(ttl) except ValueError as e: - logger.info(f'Ignoring invalid TTL on {resource.kind} {resource.name}: {e}') + logger.info(f"Ignoring invalid TTL on {resource.kind} {resource.name}: {e}") else: if ttl_seconds > 0: - counter[f'{resource.endpoint}-with-ttl'] = 1 + counter[f"{resource.endpoint}-with-ttl"] = 1 age = get_age(resource) age_formatted = format_duration(int(age.total_seconds())) - logger.debug(f'{resource.kind} {resource.name} with {ttl} TTL is {age_formatted} old') + logger.debug( + f"{resource.kind} {resource.name} with {ttl} TTL is {age_formatted} old" + ) if age.total_seconds() > ttl_seconds: - message = f'{resource.kind} {resource.name} with {ttl} TTL is {age_formatted} old and will be deleted ({reason})' + message = f"{resource.kind} {resource.name} with {ttl} TTL is {age_formatted} old and will be deleted ({reason})" logger.info(message) - create_event(resource, message, "TimeToLiveExpired", dry_run=dry_run) + create_event( + resource, message, "TimeToLiveExpired", dry_run=dry_run + ) delete(resource, dry_run=dry_run) - counter[f'{resource.endpoint}-deleted'] = 1 + counter[f"{resource.endpoint}-deleted"] = 1 elif delete_notification: expiry_time = get_ttl_expiry_time(resource, ttl_seconds) - notification_time = get_delete_notification_time(expiry_time, delete_notification) + notification_time = get_delete_notification_time( + expiry_time, delete_notification + ) if utcnow() > notification_time and not was_notified(resource): - send_delete_notification(resource, reason, expiry_time, dry_run=dry_run) + send_delete_notification( + resource, reason, expiry_time, dry_run=dry_run + ) return counter @@ -166,49 +207,71 @@ def handle_resource_on_expiry(resource, rules, delete_notification: int, dry_run expiry = resource.annotations.get(EXPIRY_ANNOTATION) if expiry: - reason = f'annotation {EXPIRY_ANNOTATION} is set' + reason = f"annotation {EXPIRY_ANNOTATION} is set" try: expiry_timestamp = parse_expiry(expiry) except ValueError as e: - logger.info(f'Ignoring invalid expiry date on {resource.kind} {resource.name}: {e}') + logger.info( + f"Ignoring invalid expiry date on {resource.kind} {resource.name}: {e}" + ) else: - counter[f'{resource.endpoint}-with-expiry'] = 1 + counter[f"{resource.endpoint}-with-expiry"] = 1 now = utcnow() if now > expiry_timestamp: - message = f'{resource.kind} {resource.name} expired on {expiry} and will be deleted ({reason})' + message = f"{resource.kind} {resource.name} expired on {expiry} and will be deleted ({reason})" logger.info(message) create_event(resource, message, "ExpiryTimeReached", dry_run=dry_run) delete(resource, dry_run=dry_run) - counter[f'{resource.endpoint}-deleted'] = 1 + counter[f"{resource.endpoint}-deleted"] = 1 else: - logging.debug(f'{resource.kind} {resource.name} will expire on {expiry}') + logging.debug( + f"{resource.kind} {resource.name} will expire on {expiry}" + ) if delete_notification: - notification_time = get_delete_notification_time(expiry_timestamp, delete_notification) + notification_time = get_delete_notification_time( + expiry_timestamp, delete_notification + ) if now > notification_time and not was_notified(resource): - send_delete_notification(resource, reason, expiry_timestamp, dry_run=dry_run) + send_delete_notification( + resource, reason, expiry_timestamp, dry_run=dry_run + ) return counter -def clean_up(api, - include_resources: frozenset, - exclude_resources: frozenset, - include_namespaces: frozenset, - exclude_namespaces: frozenset, - rules: list, - delete_notification: int, - dry_run: bool): +def clean_up( + api, + include_resources: frozenset, + exclude_resources: frozenset, + include_namespaces: frozenset, + exclude_namespaces: frozenset, + rules: list, + delete_notification: int, + dry_run: bool, +): - counter = Counter() + counter: Counter = Counter() for namespace in Namespace.objects(api): - if matches_resource_filter(namespace, include_resources, exclude_resources, include_namespaces, exclude_namespaces): - counter.update(handle_resource_on_ttl(namespace, rules, delete_notification, dry_run)) - counter.update(handle_resource_on_expiry(namespace, rules, delete_notification, dry_run)) + if matches_resource_filter( + namespace, + include_resources, + exclude_resources, + include_namespaces, + exclude_namespaces, + ): + counter.update( + handle_resource_on_ttl(namespace, rules, delete_notification, dry_run) + ) + counter.update( + handle_resource_on_expiry( + namespace, rules, delete_notification, dry_run + ) + ) else: - logger.debug(f'Skipping {namespace.kind} {namespace}') + logger.debug(f"Skipping {namespace.kind} {namespace}") - already_seen = set() + already_seen: set = set() filtered_resources = [] @@ -223,16 +286,28 @@ def clean_up(api, if object_id in already_seen: continue already_seen.add(object_id) - if matches_resource_filter(resource, include_resources, exclude_resources, include_namespaces, exclude_namespaces): + if matches_resource_filter( + resource, + include_resources, + exclude_resources, + include_namespaces, + exclude_namespaces, + ): filtered_resources.append(resource) else: - logger.debug(f'Skipping {resource.kind} {resource.namespace}/{resource.name}') + logger.debug( + f"Skipping {resource.kind} {resource.namespace}/{resource.name}" + ) except Exception as e: - logger.error(f'Could not list {_type.kind} objects: {e}') + logger.error(f"Could not list {_type.kind} objects: {e}") for resource in filtered_resources: - counter.update(handle_resource_on_ttl(resource, rules, delete_notification, dry_run)) - counter.update(handle_resource_on_expiry(resource, rules, delete_notification, dry_run)) - stats = ', '.join([f'{k}={v}' for k, v in counter.items()]) - logger.info(f'Clean up run completed: {stats}') + counter.update( + handle_resource_on_ttl(resource, rules, delete_notification, dry_run) + ) + counter.update( + handle_resource_on_expiry(resource, rules, delete_notification, dry_run) + ) + stats = ", ".join([f"{k}={v}" for k, v in counter.items()]) + logger.info(f"Clean up run completed: {stats}") return counter diff --git a/kube_janitor/main.py b/kube_janitor/main.py index 72e4a6e..e6d25a4 100644 --- a/kube_janitor/main.py +++ b/kube_janitor/main.py @@ -8,49 +8,70 @@ from kube_janitor.janitor import clean_up from kube_janitor.rules import load_rules_from_file -logger = logging.getLogger('janitor') +logger = logging.getLogger("janitor") def main(args=None): parser = cmd.get_parser() args = parser.parse_args(args) - logging.basicConfig(format='%(asctime)s %(levelname)s: %(message)s', - level=logging.DEBUG if args.debug else logging.INFO) + logging.basicConfig( + format="%(asctime)s %(levelname)s: %(message)s", + level=logging.DEBUG if args.debug else logging.INFO, + ) - config_str = ', '.join(f'{k}={v}' for k, v in sorted(vars(args).items())) - logger.info(f'Janitor v{__version__} started with {config_str}') + config_str = ", ".join(f"{k}={v}" for k, v in sorted(vars(args).items())) + logger.info(f"Janitor v{__version__} started with {config_str}") if args.dry_run: - logger.info('**DRY-RUN**: no deletions will be performed!') + logger.info("**DRY-RUN**: no deletions will be performed!") if args.rules_file: rules = load_rules_from_file(args.rules_file) - logger.info(f'Loaded {len(rules)} rules from file {args.rules_file}') + logger.info(f"Loaded {len(rules)} rules from file {args.rules_file}") else: rules = [] - return run_loop(args.once, args.include_resources, args.exclude_resources, args.include_namespaces, - args.exclude_namespaces, rules, args.interval, args.delete_notification, args.dry_run) + return run_loop( + args.once, + args.include_resources, + args.exclude_resources, + args.include_namespaces, + args.exclude_namespaces, + rules, + args.interval, + args.delete_notification, + args.dry_run, + ) -def run_loop(run_once, include_resources, exclude_resources, include_namespaces, exclude_namespaces, - rules, interval, delete_notification, dry_run): +def run_loop( + run_once, + include_resources, + exclude_resources, + include_namespaces, + exclude_namespaces, + rules, + interval, + delete_notification, + dry_run, +): handler = shutdown.GracefulShutdown() while True: try: api = get_kube_api() clean_up( - api, - include_resources=frozenset(include_resources.split(',')), - exclude_resources=frozenset(exclude_resources.split(',')), - include_namespaces=frozenset(include_namespaces.split(',')), - exclude_namespaces=frozenset(exclude_namespaces.split(',')), - rules=rules, - delete_notification=delete_notification, - dry_run=dry_run) + api, + include_resources=frozenset(include_resources.split(",")), + exclude_resources=frozenset(exclude_resources.split(",")), + include_namespaces=frozenset(include_namespaces.split(",")), + exclude_namespaces=frozenset(exclude_namespaces.split(",")), + rules=rules, + delete_notification=delete_notification, + dry_run=dry_run, + ) except Exception as e: - logger.exception('Failed to clean up: %s', e) + logger.exception("Failed to clean up: %s", e) if run_once or handler.shutdown_now: return with handler.safe_exit(): diff --git a/kube_janitor/resources.py b/kube_janitor/resources.py index 226a7ef..0b0b3d9 100644 --- a/kube_janitor/resources.py +++ b/kube_janitor/resources.py @@ -7,38 +7,50 @@ def namespaced_object_factory(kind: str, name: str, api_version: str): # https://github.com/kelproject/pykube/blob/master/pykube/objects.py#L138 - return type(kind, (NamespacedAPIObject,), { - 'version': api_version, - 'endpoint': name, - 'kind': kind - }) + return type( + kind, + (NamespacedAPIObject,), + {"version": api_version, "endpoint": name, "kind": kind}, + ) def discover_namespaced_api_resources(api): - core_version = 'v1' + core_version = "v1" r = api.get(version=core_version) r.raise_for_status() - for resource in r.json()['resources']: + for resource in r.json()["resources"]: # ignore subresources like pods/proxy - if resource['namespaced'] and 'delete' in resource['verbs'] and '/' not in resource['name']: + if ( + resource["namespaced"] + and "delete" in resource["verbs"] + and "/" not in resource["name"] + ): yield core_version, resource - r = api.get(version='/apis') + r = api.get(version="/apis") r.raise_for_status() - for group in r.json()['groups']: + for group in r.json()["groups"]: try: - pref_version = group['preferredVersion']['groupVersion'] - logger.debug(f'Collecting resources in API group {pref_version}..') + pref_version = group["preferredVersion"]["groupVersion"] + logger.debug(f"Collecting resources in API group {pref_version}..") r2 = api.get(version=pref_version) r2.raise_for_status() - for resource in r2.json()['resources']: - if resource['namespaced'] and 'delete' in resource['verbs'] and '/' not in resource['name']: + for resource in r2.json()["resources"]: + if ( + resource["namespaced"] + and "delete" in resource["verbs"] + and "/" not in resource["name"] + ): yield pref_version, resource except Exception as e: - logger.error(f'Could not collect resources in API group {pref_version}: {e}') + logger.error( + f"Could not collect resources in API group {pref_version}: {e}" + ) def get_namespaced_resource_types(api): for api_version, resource in discover_namespaced_api_resources(api): - clazz = namespaced_object_factory(resource['kind'], resource['name'], api_version) + clazz = namespaced_object_factory( + resource["kind"], resource["name"], api_version + ) yield clazz diff --git a/kube_janitor/rules.py b/kube_janitor/rules.py index 882c739..80d0d54 100644 --- a/kube_janitor/rules.py +++ b/kube_janitor/rules.py @@ -8,32 +8,37 @@ from .helper import parse_ttl -RULE_ID_PATTERN = re.compile(r'^[a-z][a-z0-9-]*$') +RULE_ID_PATTERN = re.compile(r"^[a-z][a-z0-9-]*$") logger = logging.getLogger(__name__) -class Rule(collections.namedtuple('Rule', ['id', 'resources', 'jmespath', 'ttl'])): - +class Rule(collections.namedtuple("Rule", ["id", "resources", "jmespath", "ttl"])): + @staticmethod def from_entry(entry: dict): - id_ = entry['id'] + id_ = entry["id"] if not RULE_ID_PATTERN.match(id_): - raise ValueError(f'Invalid rule ID "{id_}": it has to match ^[a-z][a-z0-9-]*$') + raise ValueError( + f'Invalid rule ID "{id_}": it has to match ^[a-z][a-z0-9-]*$' + ) # check whether TTL format is correct - parse_ttl(entry['ttl']) + parse_ttl(entry["ttl"]) return Rule( id=id_, - resources=frozenset(entry['resources']), - jmespath=jmespath.compile(entry['jmespath']), - ttl=entry['ttl']) + resources=frozenset(entry["resources"]), + jmespath=jmespath.compile(entry["jmespath"]), + ttl=entry["ttl"], + ) def matches(self, resource: NamespacedAPIObject): - if resource.endpoint not in self.resources and '*' not in self.resources: + if resource.endpoint not in self.resources and "*" not in self.resources: return False result = self.jmespath.search(resource.obj) - logger.debug(f'Rule {self.id} with JMESPath "{self.jmespath.expression}" evaluated for {resource.kind} {resource.namespace}/{resource.name}: {result}') + logger.debug( + f'Rule {self.id} with JMESPath "{self.jmespath.expression}" evaluated for {resource.kind} {resource.namespace}/{resource.name}: {result}' + ) return bool(result) @@ -42,20 +47,22 @@ def load_rules_from_file(filename: str): data = yaml.safe_load(fd) try: - entries = data['rules'] + entries = data["rules"] except (TypeError, KeyError): - raise KeyError('The rules YAML file must have a top-level mapping with the key "rules"') + raise KeyError( + 'The rules YAML file must have a top-level mapping with the key "rules"' + ) rules = [] for i, entry in enumerate(entries): try: if not isinstance(entry, dict): - raise TypeError('rule must be a mapping') + raise TypeError("rule must be a mapping") missing_keys = frozenset(Rule._fields) - entry.keys() if missing_keys: - raise ValueError(f'rule is missing required keys: {missing_keys}') + raise ValueError(f"rule is missing required keys: {missing_keys}") rule = Rule.from_entry(entry) rules.append(rule) diff --git a/pipenv-install.py b/pipenv-install.py deleted file mode 100755 index d790d61..0000000 --- a/pipenv-install.py +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/env python3 -""" -Helper script for Docker build to install packages from Pipfile.lock without installing Pipenv -""" -import json -import subprocess - -with open("Pipfile.lock") as fd: - data = json.load(fd) - -packages = [] -for k, v in data["default"].items(): - packages.append(k + v["version"]) - -subprocess.run(["pip3", "install"] + packages, check=True) diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..f406c58 --- /dev/null +++ b/poetry.lock @@ -0,0 +1,696 @@ +[[package]] +category = "dev" +description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." +name = "appdirs" +optional = false +python-versions = "*" +version = "1.4.3" + +[[package]] +category = "dev" +description = "Atomic file writes." +marker = "sys_platform == \"win32\"" +name = "atomicwrites" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "1.3.0" + +[[package]] +category = "dev" +description = "Classes Without Boilerplate" +name = "attrs" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "19.3.0" + +[package.extras] +azure-pipelines = ["coverage", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "pytest-azurepipelines"] +dev = ["coverage", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "sphinx", "pre-commit"] +docs = ["sphinx", "zope.interface"] +tests = ["coverage", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface"] + +[[package]] +category = "dev" +description = "The uncompromising code formatter." +name = "black" +optional = false +python-versions = ">=3.6" +version = "19.10b0" + +[package.dependencies] +appdirs = "*" +attrs = ">=18.1.0" +click = ">=6.5" +pathspec = ">=0.6,<1" +regex = "*" +toml = ">=0.9.4" +typed-ast = ">=1.4.0" + +[package.extras] +d = ["aiohttp (>=3.3.2)", "aiohttp-cors"] + +[[package]] +category = "main" +description = "Python package for providing Mozilla's CA Bundle." +name = "certifi" +optional = false +python-versions = "*" +version = "2019.11.28" + +[[package]] +category = "main" +description = "Universal encoding detector for Python 2 and 3" +name = "chardet" +optional = false +python-versions = "*" +version = "3.0.4" + +[[package]] +category = "dev" +description = "Composable command line interface toolkit" +name = "click" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "7.0" + +[[package]] +category = "dev" +description = "Cross-platform colored terminal text." +marker = "sys_platform == \"win32\"" +name = "colorama" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +version = "0.4.3" + +[[package]] +category = "dev" +description = "Code coverage measurement for Python" +name = "coverage" +optional = false +python-versions = "*" +version = "4.4.2" + +[[package]] +category = "dev" +description = "Show coverage stats online via coveralls.io" +name = "coveralls" +optional = false +python-versions = "*" +version = "1.9.2" + +[package.dependencies] +coverage = ">=3.6,<5.0" +docopt = ">=0.6.1" +requests = ">=1.0.0" + +[package.extras] +yaml = ["PyYAML (>=3.10)"] + +[[package]] +category = "dev" +description = "Pythonic argument parser, that will make you smile" +name = "docopt" +optional = false +python-versions = "*" +version = "0.6.2" + +[[package]] +category = "dev" +description = "Discover and load entry points from installed packages." +name = "entrypoints" +optional = false +python-versions = ">=2.7" +version = "0.3" + +[[package]] +category = "dev" +description = "the modular source code checker: pep8, pyflakes and co" +name = "flake8" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "3.7.9" + +[package.dependencies] +entrypoints = ">=0.3.0,<0.4.0" +mccabe = ">=0.6.0,<0.7.0" +pycodestyle = ">=2.5.0,<2.6.0" +pyflakes = ">=2.1.0,<2.2.0" + +[[package]] +category = "main" +description = "Internationalized Domain Names in Applications (IDNA)" +name = "idna" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "2.8" + +[[package]] +category = "dev" +description = "Read metadata from Python packages" +marker = "python_version < \"3.8\"" +name = "importlib-metadata" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" +version = "1.3.0" + +[package.dependencies] +zipp = ">=0.5" + +[package.extras] +docs = ["sphinx", "rst.linker"] +testing = ["packaging", "importlib-resources"] + +[[package]] +category = "main" +description = "JSON Matching Expressions" +name = "jmespath" +optional = false +python-versions = "*" +version = "0.9.4" + +[[package]] +category = "dev" +description = "McCabe checker, plugin for flake8" +name = "mccabe" +optional = false +python-versions = "*" +version = "0.6.1" + +[[package]] +category = "dev" +description = "More routines for operating on iterables, beyond itertools" +name = "more-itertools" +optional = false +python-versions = ">=3.5" +version = "8.0.2" + +[[package]] +category = "dev" +description = "Optional static typing for Python" +name = "mypy" +optional = false +python-versions = ">=3.5" +version = "0.761" + +[package.dependencies] +mypy-extensions = ">=0.4.3,<0.5.0" +typed-ast = ">=1.4.0,<1.5.0" +typing-extensions = ">=3.7.4" + +[package.extras] +dmypy = ["psutil (>=4.0)"] + +[[package]] +category = "dev" +description = "Experimental type system extensions for programs checked with the mypy typechecker." +name = "mypy-extensions" +optional = false +python-versions = "*" +version = "0.4.3" + +[[package]] +category = "dev" +description = "Core utilities for Python packages" +name = "packaging" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "19.2" + +[package.dependencies] +pyparsing = ">=2.0.2" +six = "*" + +[[package]] +category = "dev" +description = "Utility library for gitignore style pattern matching of file paths." +name = "pathspec" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "0.6.0" + +[[package]] +category = "dev" +description = "plugin and hook calling mechanisms for python" +name = "pluggy" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "0.13.1" + +[package.dependencies] +[package.dependencies.importlib-metadata] +python = "<3.8" +version = ">=0.12" + +[package.extras] +dev = ["pre-commit", "tox"] + +[[package]] +category = "dev" +description = "library with cross-python path, ini-parsing, io, code, log facilities" +name = "py" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "1.8.0" + +[[package]] +category = "dev" +description = "Python style guide checker" +name = "pycodestyle" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "2.5.0" + +[[package]] +category = "dev" +description = "passive checker of Python programs" +name = "pyflakes" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "2.1.1" + +[[package]] +category = "main" +description = "Python client library for Kubernetes" +name = "pykube-ng" +optional = false +python-versions = "*" +version = "19.12.0" + +[package.dependencies] +PyYAML = "*" +requests = ">=2.12" + +[package.extras] +gcp = ["google-auth", "jsonpath-ng"] + +[[package]] +category = "dev" +description = "Python parsing module" +name = "pyparsing" +optional = false +python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" +version = "2.4.5" + +[[package]] +category = "dev" +description = "pytest: simple powerful testing with Python" +name = "pytest" +optional = false +python-versions = ">=3.5" +version = "5.3.2" + +[package.dependencies] +atomicwrites = ">=1.0" +attrs = ">=17.4.0" +colorama = "*" +more-itertools = ">=4.0.0" +packaging = "*" +pluggy = ">=0.12,<1.0" +py = ">=1.5.0" +wcwidth = "*" + +[package.dependencies.importlib-metadata] +python = "<3.8" +version = ">=0.12" + +[package.extras] +testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"] + +[[package]] +category = "dev" +description = "Pytest plugin for measuring coverage." +name = "pytest-cov" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "2.8.1" + +[package.dependencies] +coverage = ">=4.4" +pytest = ">=3.6" + +[package.extras] +testing = ["fields", "hunter", "process-tests (2.0.2)", "six", "virtualenv"] + +[[package]] +category = "main" +description = "YAML parser and emitter for Python" +name = "pyyaml" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "5.2" + +[[package]] +category = "dev" +description = "Alternative regular expression module, to replace re." +name = "regex" +optional = false +python-versions = "*" +version = "2019.12.20" + +[[package]] +category = "main" +description = "Python HTTP for Humans." +name = "requests" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +version = "2.22.0" + +[package.dependencies] +certifi = ">=2017.4.17" +chardet = ">=3.0.2,<3.1.0" +idna = ">=2.5,<2.9" +urllib3 = ">=1.21.1,<1.25.0 || >1.25.0,<1.25.1 || >1.25.1,<1.26" + +[package.extras] +security = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)"] +socks = ["PySocks (>=1.5.6,<1.5.7 || >1.5.7)", "win-inet-pton"] + +[[package]] +category = "dev" +description = "Python 2 and 3 compatibility utilities" +name = "six" +optional = false +python-versions = ">=2.6, !=3.0.*, !=3.1.*" +version = "1.13.0" + +[[package]] +category = "dev" +description = "Python Library for Tom's Obvious, Minimal Language" +name = "toml" +optional = false +python-versions = "*" +version = "0.10.0" + +[[package]] +category = "dev" +description = "a fork of Python 2 and 3 ast modules with type comment support" +name = "typed-ast" +optional = false +python-versions = "*" +version = "1.4.0" + +[[package]] +category = "dev" +description = "Backported and Experimental Type Hints for Python 3.5+" +name = "typing-extensions" +optional = false +python-versions = "*" +version = "3.7.4.1" + +[[package]] +category = "main" +description = "HTTP library with thread-safe connection pooling, file post, and more." +name = "urllib3" +optional = false +python-versions = "*" +version = "1.22" + +[package.extras] +secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"] +socks = ["PySocks (>=1.5.6,<1.5.7 || >1.5.7,<2.0)"] + +[[package]] +category = "dev" +description = "Measures number of Terminal column cells of wide-character codes" +name = "wcwidth" +optional = false +python-versions = "*" +version = "0.1.7" + +[[package]] +category = "dev" +description = "Backport of pathlib-compatible object wrapper for zip files" +marker = "python_version < \"3.8\"" +name = "zipp" +optional = false +python-versions = ">=2.7" +version = "0.6.0" + +[package.dependencies] +more-itertools = "*" + +[package.extras] +docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"] +testing = ["pathlib2", "contextlib2", "unittest2"] + +[metadata] +content-hash = "de625ba922e49252743a977afaa57e5452a7c3367a1b16451b0f8a188c250fc6" +python-versions = ">=3.7" + +[metadata.files] +appdirs = [ + {file = "appdirs-1.4.3-py2.py3-none-any.whl", hash = "sha256:d8b24664561d0d34ddfaec54636d502d7cea6e29c3eaf68f3df6180863e2166e"}, + {file = "appdirs-1.4.3.tar.gz", hash = "sha256:9e5896d1372858f8dd3344faf4e5014d21849c756c8d5701f78f8a103b372d92"}, +] +atomicwrites = [ + {file = "atomicwrites-1.3.0-py2.py3-none-any.whl", hash = "sha256:03472c30eb2c5d1ba9227e4c2ca66ab8287fbfbbda3888aa93dc2e28fc6811b4"}, + {file = "atomicwrites-1.3.0.tar.gz", hash = "sha256:75a9445bac02d8d058d5e1fe689654ba5a6556a1dfd8ce6ec55a0ed79866cfa6"}, +] +attrs = [ + {file = "attrs-19.3.0-py2.py3-none-any.whl", hash = "sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c"}, + {file = "attrs-19.3.0.tar.gz", hash = "sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72"}, +] +black = [ + {file = "black-19.10b0-py36-none-any.whl", hash = "sha256:1b30e59be925fafc1ee4565e5e08abef6b03fe455102883820fe5ee2e4734e0b"}, + {file = "black-19.10b0.tar.gz", hash = "sha256:c2edb73a08e9e0e6f65a0e6af18b059b8b1cdd5bef997d7a0b181df93dc81539"}, +] +certifi = [ + {file = "certifi-2019.11.28-py2.py3-none-any.whl", hash = "sha256:017c25db2a153ce562900032d5bc68e9f191e44e9a0f762f373977de9df1fbb3"}, + {file = "certifi-2019.11.28.tar.gz", hash = "sha256:25b64c7da4cd7479594d035c08c2d809eb4aab3a26e5a990ea98cc450c320f1f"}, +] +chardet = [ + {file = "chardet-3.0.4-py2.py3-none-any.whl", hash = "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"}, + {file = "chardet-3.0.4.tar.gz", hash = "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae"}, +] +click = [ + {file = "Click-7.0-py2.py3-none-any.whl", hash = "sha256:2335065e6395b9e67ca716de5f7526736bfa6ceead690adf616d925bdc622b13"}, + {file = "Click-7.0.tar.gz", hash = "sha256:5b94b49521f6456670fdb30cd82a4eca9412788a93fa6dd6df72c94d5a8ff2d7"}, +] +colorama = [ + {file = "colorama-0.4.3-py2.py3-none-any.whl", hash = "sha256:7d73d2a99753107a36ac6b455ee49046802e59d9d076ef8e47b61499fa29afff"}, + {file = "colorama-0.4.3.tar.gz", hash = "sha256:e96da0d330793e2cb9485e9ddfd918d456036c7149416295932478192f4436a1"}, +] +coverage = [ + {file = "coverage-4.4.2-cp26-cp26m-macosx_10_10_x86_64.whl", hash = "sha256:d1ee76f560c3c3e8faada866a07a32485445e16ed2206ac8378bd90dadffb9f0"}, + {file = "coverage-4.4.2-cp26-cp26m-manylinux1_i686.whl", hash = "sha256:007eeef7e23f9473622f7d94a3e029a45d55a92a1f083f0f3512f5ab9a669b05"}, + {file = "coverage-4.4.2-cp26-cp26m-manylinux1_x86_64.whl", hash = "sha256:17307429935f96c986a1b1674f78079528833410750321d22b5fb35d1883828e"}, + {file = "coverage-4.4.2-cp26-cp26mu-manylinux1_i686.whl", hash = "sha256:845fddf89dca1e94abe168760a38271abfc2e31863fbb4ada7f9a99337d7c3dc"}, + {file = "coverage-4.4.2-cp26-cp26mu-manylinux1_x86_64.whl", hash = "sha256:3f4d0b3403d3e110d2588c275540649b1841725f5a11a7162620224155d00ba2"}, + {file = "coverage-4.4.2-cp27-cp27m-macosx_10_12_intel.whl", hash = "sha256:4c4f368ffe1c2e7602359c2c50233269f3abe1c48ca6b288dcd0fb1d1c679733"}, + {file = "coverage-4.4.2-cp27-cp27m-macosx_10_12_x86_64.whl", hash = "sha256:f8c55dd0f56d3d618dfacf129e010cbe5d5f94b6951c1b2f13ab1a2f79c284da"}, + {file = "coverage-4.4.2-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:cdd92dd9471e624cd1d8c1a2703d25f114b59b736b0f1f659a98414e535ffb3d"}, + {file = "coverage-4.4.2-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:2ad357d12971e77360034c1596011a03f50c0f9e1ecd12e081342b8d1aee2236"}, + {file = "coverage-4.4.2-cp27-cp27m-win32.whl", hash = "sha256:700d7579995044dc724847560b78ac786f0ca292867447afda7727a6fbaa082e"}, + {file = "coverage-4.4.2-cp27-cp27m-win_amd64.whl", hash = "sha256:66f393e10dd866be267deb3feca39babba08ae13763e0fc7a1063cbe1f8e49f6"}, + {file = "coverage-4.4.2-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:e9a0e1caed2a52f15c96507ab78a48f346c05681a49c5b003172f8073da6aa6b"}, + {file = "coverage-4.4.2-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:eea9135432428d3ca7ee9be86af27cb8e56243f73764a9b6c3e0bda1394916be"}, + {file = "coverage-4.4.2-cp33-cp33m-macosx_10_10_x86_64.whl", hash = "sha256:5ff16548492e8a12e65ff3d55857ccd818584ed587a6c2898a9ebbe09a880674"}, + {file = "coverage-4.4.2-cp33-cp33m-manylinux1_i686.whl", hash = "sha256:d00e29b78ff610d300b2c37049a41234d48ea4f2d2581759ebcf67caaf731c31"}, + {file = "coverage-4.4.2-cp33-cp33m-manylinux1_x86_64.whl", hash = "sha256:87d942863fe74b1c3be83a045996addf1639218c2cb89c5da18c06c0fe3917ea"}, + {file = "coverage-4.4.2-cp34-cp34m-macosx_10_10_x86_64.whl", hash = "sha256:358d635b1fc22a425444d52f26287ae5aea9e96e254ff3c59c407426f44574f4"}, + {file = "coverage-4.4.2-cp34-cp34m-manylinux1_i686.whl", hash = "sha256:81912cfe276e0069dca99e1e4e6be7b06b5fc8342641c6b472cb2fed7de7ae18"}, + {file = "coverage-4.4.2-cp34-cp34m-manylinux1_x86_64.whl", hash = "sha256:079248312838c4c8f3494934ab7382a42d42d5f365f0cf7516f938dbb3f53f3f"}, + {file = "coverage-4.4.2-cp34-cp34m-win32.whl", hash = "sha256:b0059630ca5c6b297690a6bf57bf2fdac1395c24b7935fd73ee64190276b743b"}, + {file = "coverage-4.4.2-cp34-cp34m-win_amd64.whl", hash = "sha256:493082f104b5ca920e97a485913de254cbe351900deed72d4264571c73464cd0"}, + {file = "coverage-4.4.2-cp35-cp35m-macosx_10_10_x86_64.whl", hash = "sha256:e3ba9b14607c23623cf38f90b23f5bed4a3be87cbfa96e2e9f4eabb975d1e98b"}, + {file = "coverage-4.4.2-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:82cbd3317320aa63c65555aa4894bf33a13fb3a77f079059eb5935eea415938d"}, + {file = "coverage-4.4.2-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:9721f1b7275d3112dc7ccf63f0553c769f09b5c25a26ee45872c7f5c09edf6c1"}, + {file = "coverage-4.4.2-cp35-cp35m-win32.whl", hash = "sha256:bd4800e32b4c8d99c3a2c943f1ac430cbf80658d884123d19639bcde90dad44a"}, + {file = "coverage-4.4.2-cp35-cp35m-win_amd64.whl", hash = "sha256:f29841e865590af72c4b90d7b5b8e93fd560f5dea436c1d5ee8053788f9285de"}, + {file = "coverage-4.4.2-cp36-cp36m-macosx_10_12_x86_64.whl", hash = "sha256:f3a5c6d054c531536a83521c00e5d4004f1e126e2e2556ce399bef4180fbe540"}, + {file = "coverage-4.4.2-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:dd707a21332615108b736ef0b8513d3edaf12d2a7d5fc26cd04a169a8ae9b526"}, + {file = "coverage-4.4.2-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:2e1a5c6adebb93c3b175103c2f855eda957283c10cf937d791d81bef8872d6ca"}, + {file = "coverage-4.4.2-cp36-cp36m-win32.whl", hash = "sha256:f87f522bde5540d8a4b11df80058281ac38c44b13ce29ced1e294963dd51a8f8"}, + {file = "coverage-4.4.2-cp36-cp36m-win_amd64.whl", hash = "sha256:a7cfaebd8f24c2b537fa6a271229b051cdac9c1734bb6f939ccfc7c055689baa"}, + {file = "coverage-4.4.2.tar.gz", hash = "sha256:309d91bd7a35063ec7a0e4d75645488bfab3f0b66373e7722f23da7f5b0f34cc"}, + {file = "coverage-4.4.2.win-amd64-py2.7.exe", hash = "sha256:b6cebae1502ce5b87d7c6f532fa90ab345cfbda62b95aeea4e431e164d498a3d"}, + {file = "coverage-4.4.2.win-amd64-py3.4.exe", hash = "sha256:a4497faa4f1c0fc365ba05eaecfb6b5d24e3c8c72e95938f9524e29dadb15e76"}, + {file = "coverage-4.4.2.win-amd64-py3.5.exe", hash = "sha256:2b4d7f03a8a6632598cbc5df15bbca9f778c43db7cf1a838f4fa2c8599a8691a"}, + {file = "coverage-4.4.2.win-amd64-py3.6.exe", hash = "sha256:1afccd7e27cac1b9617be8c769f6d8a6d363699c9b86820f40c74cfb3328921c"}, + {file = "coverage-4.4.2.win32-py2.7.exe", hash = "sha256:0388c12539372bb92d6dde68b4627f0300d948965bbb7fc104924d715fdc0965"}, + {file = "coverage-4.4.2.win32-py3.4.exe", hash = "sha256:ab3508df9a92c1d3362343d235420d08e2662969b83134f8a97dc1451cbe5e84"}, + {file = "coverage-4.4.2.win32-py3.5.exe", hash = "sha256:43a155eb76025c61fc20c3d03b89ca28efa6f5be572ab6110b2fb68eda96bfea"}, + {file = "coverage-4.4.2.win32-py3.6.exe", hash = "sha256:f98b461cb59f117887aa634a66022c0bd394278245ed51189f63a036516e32de"}, +] +coveralls = [ + {file = "coveralls-1.9.2-py2.py3-none-any.whl", hash = "sha256:25522a50cdf720d956601ca6ef480786e655ae2f0c94270c77e1a23d742de558"}, + {file = "coveralls-1.9.2.tar.gz", hash = "sha256:8e3315e8620bb6b3c6f3179a75f498e7179c93b3ddc440352404f941b1f70524"}, +] +docopt = [ + {file = "docopt-0.6.2.tar.gz", hash = "sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491"}, +] +entrypoints = [ + {file = "entrypoints-0.3-py2.py3-none-any.whl", hash = "sha256:589f874b313739ad35be6e0cd7efde2a4e9b6fea91edcc34e58ecbb8dbe56d19"}, + {file = "entrypoints-0.3.tar.gz", hash = "sha256:c70dd71abe5a8c85e55e12c19bd91ccfeec11a6e99044204511f9ed547d48451"}, +] +flake8 = [ + {file = "flake8-3.7.9-py2.py3-none-any.whl", hash = "sha256:49356e766643ad15072a789a20915d3c91dc89fd313ccd71802303fd67e4deca"}, + {file = "flake8-3.7.9.tar.gz", hash = "sha256:45681a117ecc81e870cbf1262835ae4af5e7a8b08e40b944a8a6e6b895914cfb"}, +] +idna = [ + {file = "idna-2.8-py2.py3-none-any.whl", hash = "sha256:ea8b7f6188e6fa117537c3df7da9fc686d485087abf6ac197f9c46432f7e4a3c"}, + {file = "idna-2.8.tar.gz", hash = "sha256:c357b3f628cf53ae2c4c05627ecc484553142ca23264e593d327bcde5e9c3407"}, +] +importlib-metadata = [ + {file = "importlib_metadata-1.3.0-py2.py3-none-any.whl", hash = "sha256:d95141fbfa7ef2ec65cfd945e2af7e5a6ddbd7c8d9a25e66ff3be8e3daf9f60f"}, + {file = "importlib_metadata-1.3.0.tar.gz", hash = "sha256:073a852570f92da5f744a3472af1b61e28e9f78ccf0c9117658dc32b15de7b45"}, +] +jmespath = [ + {file = "jmespath-0.9.4-py2.py3-none-any.whl", hash = "sha256:3720a4b1bd659dd2eecad0666459b9788813e032b83e7ba58578e48254e0a0e6"}, + {file = "jmespath-0.9.4.tar.gz", hash = "sha256:bde2aef6f44302dfb30320115b17d030798de8c4110e28d5cf6cf91a7a31074c"}, +] +mccabe = [ + {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"}, + {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"}, +] +more-itertools = [ + {file = "more-itertools-8.0.2.tar.gz", hash = "sha256:b84b238cce0d9adad5ed87e745778d20a3f8487d0f0cb8b8a586816c7496458d"}, + {file = "more_itertools-8.0.2-py3-none-any.whl", hash = "sha256:c833ef592a0324bcc6a60e48440da07645063c453880c9477ceb22490aec1564"}, +] +mypy = [ + {file = "mypy-0.761-cp35-cp35m-macosx_10_6_x86_64.whl", hash = "sha256:7f672d02fffcbace4db2b05369142e0506cdcde20cea0e07c7c2171c4fd11dd6"}, + {file = "mypy-0.761-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:87c556fb85d709dacd4b4cb6167eecc5bbb4f0a9864b69136a0d4640fdc76a36"}, + {file = "mypy-0.761-cp35-cp35m-win_amd64.whl", hash = "sha256:c6d27bd20c3ba60d5b02f20bd28e20091d6286a699174dfad515636cb09b5a72"}, + {file = "mypy-0.761-cp36-cp36m-macosx_10_6_x86_64.whl", hash = "sha256:4b9365ade157794cef9685791032521233729cb00ce76b0ddc78749abea463d2"}, + {file = "mypy-0.761-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:634aef60b4ff0f650d3e59d4374626ca6153fcaff96ec075b215b568e6ee3cb0"}, + {file = "mypy-0.761-cp36-cp36m-win_amd64.whl", hash = "sha256:53ea810ae3f83f9c9b452582261ea859828a9ed666f2e1ca840300b69322c474"}, + {file = "mypy-0.761-cp37-cp37m-macosx_10_6_x86_64.whl", hash = "sha256:0a9a45157e532da06fe56adcfef8a74629566b607fa2c1ac0122d1ff995c748a"}, + {file = "mypy-0.761-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:7eadc91af8270455e0d73565b8964da1642fe226665dd5c9560067cd64d56749"}, + {file = "mypy-0.761-cp37-cp37m-win_amd64.whl", hash = "sha256:e2bb577d10d09a2d8822a042a23b8d62bc3b269667c9eb8e60a6edfa000211b1"}, + {file = "mypy-0.761-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2c35cae79ceb20d47facfad51f952df16c2ae9f45db6cb38405a3da1cf8fc0a7"}, + {file = "mypy-0.761-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:f97a605d7c8bc2c6d1172c2f0d5a65b24142e11a58de689046e62c2d632ca8c1"}, + {file = "mypy-0.761-cp38-cp38-win_amd64.whl", hash = "sha256:a6bd44efee4dc8c3324c13785a9dc3519b3ee3a92cada42d2b57762b7053b49b"}, + {file = "mypy-0.761-py3-none-any.whl", hash = "sha256:7e396ce53cacd5596ff6d191b47ab0ea18f8e0ec04e15d69728d530e86d4c217"}, + {file = "mypy-0.761.tar.gz", hash = "sha256:85baab8d74ec601e86134afe2bcccd87820f79d2f8d5798c889507d1088287bf"}, +] +mypy-extensions = [ + {file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"}, + {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"}, +] +packaging = [ + {file = "packaging-19.2-py2.py3-none-any.whl", hash = "sha256:d9551545c6d761f3def1677baf08ab2a3ca17c56879e70fecba2fc4dde4ed108"}, + {file = "packaging-19.2.tar.gz", hash = "sha256:28b924174df7a2fa32c1953825ff29c61e2f5e082343165438812f00d3a7fc47"}, +] +pathspec = [ + {file = "pathspec-0.6.0.tar.gz", hash = "sha256:e285ccc8b0785beadd4c18e5708b12bb8fcf529a1e61215b3feff1d1e559ea5c"}, +] +pluggy = [ + {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"}, + {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"}, +] +py = [ + {file = "py-1.8.0-py2.py3-none-any.whl", hash = "sha256:64f65755aee5b381cea27766a3a147c3f15b9b6b9ac88676de66ba2ae36793fa"}, + {file = "py-1.8.0.tar.gz", hash = "sha256:dc639b046a6e2cff5bbe40194ad65936d6ba360b52b3c3fe1d08a82dd50b5e53"}, +] +pycodestyle = [ + {file = "pycodestyle-2.5.0-py2.py3-none-any.whl", hash = "sha256:95a2219d12372f05704562a14ec30bc76b05a5b297b21a5dfe3f6fac3491ae56"}, + {file = "pycodestyle-2.5.0.tar.gz", hash = "sha256:e40a936c9a450ad81df37f549d676d127b1b66000a6c500caa2b085bc0ca976c"}, +] +pyflakes = [ + {file = "pyflakes-2.1.1-py2.py3-none-any.whl", hash = "sha256:17dbeb2e3f4d772725c777fabc446d5634d1038f234e77343108ce445ea69ce0"}, + {file = "pyflakes-2.1.1.tar.gz", hash = "sha256:d976835886f8c5b31d47970ed689944a0262b5f3afa00a5a7b4dc81e5449f8a2"}, +] +pykube-ng = [ + {file = "pykube-ng-19.12.0.tar.gz", hash = "sha256:58a457a72c12fc22fa4c69018450818df7ec3498b67dfe4636cb3b59bcfbc08c"}, + {file = "pykube_ng-19.12.0-py2.py3-none-any.whl", hash = "sha256:260c09afff66e97f036a5fa16942271931e8f5f9e7c996f677aebb84b5cec853"}, +] +pyparsing = [ + {file = "pyparsing-2.4.5-py2.py3-none-any.whl", hash = "sha256:20f995ecd72f2a1f4bf6b072b63b22e2eb457836601e76d6e5dfcd75436acc1f"}, + {file = "pyparsing-2.4.5.tar.gz", hash = "sha256:4ca62001be367f01bd3e92ecbb79070272a9d4964dce6a48a82ff0b8bc7e683a"}, +] +pytest = [ + {file = "pytest-5.3.2-py3-none-any.whl", hash = "sha256:e41d489ff43948babd0fad7ad5e49b8735d5d55e26628a58673c39ff61d95de4"}, + {file = "pytest-5.3.2.tar.gz", hash = "sha256:6b571215b5a790f9b41f19f3531c53a45cf6bb8ef2988bc1ff9afb38270b25fa"}, +] +pytest-cov = [ + {file = "pytest-cov-2.8.1.tar.gz", hash = "sha256:cc6742d8bac45070217169f5f72ceee1e0e55b0221f54bcf24845972d3a47f2b"}, + {file = "pytest_cov-2.8.1-py2.py3-none-any.whl", hash = "sha256:cdbdef4f870408ebdbfeb44e63e07eb18bb4619fae852f6e760645fa36172626"}, +] +pyyaml = [ + {file = "PyYAML-5.2-cp27-cp27m-win32.whl", hash = "sha256:35ace9b4147848cafac3db142795ee42deebe9d0dad885ce643928e88daebdcc"}, + {file = "PyYAML-5.2-cp27-cp27m-win_amd64.whl", hash = "sha256:ebc4ed52dcc93eeebeae5cf5deb2ae4347b3a81c3fa12b0b8c976544829396a4"}, + {file = "PyYAML-5.2-cp35-cp35m-win32.whl", hash = "sha256:38a4f0d114101c58c0f3a88aeaa44d63efd588845c5a2df5290b73db8f246d15"}, + {file = "PyYAML-5.2-cp35-cp35m-win_amd64.whl", hash = "sha256:483eb6a33b671408c8529106df3707270bfacb2447bf8ad856a4b4f57f6e3075"}, + {file = "PyYAML-5.2-cp36-cp36m-win32.whl", hash = "sha256:7f38e35c00e160db592091751d385cd7b3046d6d51f578b29943225178257b31"}, + {file = "PyYAML-5.2-cp36-cp36m-win_amd64.whl", hash = "sha256:0e7f69397d53155e55d10ff68fdfb2cf630a35e6daf65cf0bdeaf04f127c09dc"}, + {file = "PyYAML-5.2-cp37-cp37m-win32.whl", hash = "sha256:e4c015484ff0ff197564917b4b4246ca03f411b9bd7f16e02a2f586eb48b6d04"}, + {file = "PyYAML-5.2-cp37-cp37m-win_amd64.whl", hash = "sha256:4b6be5edb9f6bb73680f5bf4ee08ff25416d1400fbd4535fe0069b2994da07cd"}, + {file = "PyYAML-5.2-cp38-cp38-win32.whl", hash = "sha256:8100c896ecb361794d8bfdb9c11fce618c7cf83d624d73d5ab38aef3bc82d43f"}, + {file = "PyYAML-5.2-cp38-cp38-win_amd64.whl", hash = "sha256:2e9f0b7c5914367b0916c3c104a024bb68f269a486b9d04a2e8ac6f6597b7803"}, + {file = "PyYAML-5.2.tar.gz", hash = "sha256:c0ee8eca2c582d29c3c2ec6e2c4f703d1b7f1fb10bc72317355a746057e7346c"}, +] +regex = [ + {file = "regex-2019.12.20-cp27-cp27m-win32.whl", hash = "sha256:7bbbdbada3078dc360d4692a9b28479f569db7fc7f304b668787afc9feb38ec8"}, + {file = "regex-2019.12.20-cp27-cp27m-win_amd64.whl", hash = "sha256:a83049eb717ae828ced9cf607845929efcb086a001fc8af93ff15c50012a5716"}, + {file = "regex-2019.12.20-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:27d1bd20d334f50b7ef078eba0f0756a640fd25f5f1708d3b5bed18a5d6bced9"}, + {file = "regex-2019.12.20-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:1768cf42a78a11dae63152685e7a1d90af7a8d71d2d4f6d2387edea53a9e0588"}, + {file = "regex-2019.12.20-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:4850c78b53acf664a6578bba0e9ebeaf2807bb476c14ec7e0f936f2015133cae"}, + {file = "regex-2019.12.20-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:78b3712ec529b2a71731fbb10b907b54d9c53a17ca589b42a578bc1e9a2c82ea"}, + {file = "regex-2019.12.20-cp36-cp36m-win32.whl", hash = "sha256:8d9ef7f6c403e35e73b7fc3cde9f6decdc43b1cb2ff8d058c53b9084bfcb553e"}, + {file = "regex-2019.12.20-cp36-cp36m-win_amd64.whl", hash = "sha256:faad39fdbe2c2ccda9846cd21581063086330efafa47d87afea4073a08128656"}, + {file = "regex-2019.12.20-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:adc35d38952e688535980ae2109cad3a109520033642e759f987cf47fe278aa1"}, + {file = "regex-2019.12.20-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:ef0b828a7e22e58e06a1cceddba7b4665c6af8afeb22a0d8083001330572c147"}, + {file = "regex-2019.12.20-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:0e6cf1e747f383f52a0964452658c04300a9a01e8a89c55ea22813931b580aa8"}, + {file = "regex-2019.12.20-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:032fdcc03406e1a6485ec09b826eac78732943840c4b29e503b789716f051d8d"}, + {file = "regex-2019.12.20-cp37-cp37m-win32.whl", hash = "sha256:77ae8d926f38700432807ba293d768ba9e7652df0cbe76df2843b12f80f68885"}, + {file = "regex-2019.12.20-cp37-cp37m-win_amd64.whl", hash = "sha256:c29a77ad4463f71a506515d9ec3a899ed026b4b015bf43245c919ff36275444b"}, + {file = "regex-2019.12.20-cp38-cp38-manylinux1_i686.whl", hash = "sha256:57eacd38a5ec40ed7b19a968a9d01c0d977bda55664210be713e750dd7b33540"}, + {file = "regex-2019.12.20-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:724eb24b92fc5fdc1501a1b4df44a68b9c1dda171c8ef8736799e903fb100f63"}, + {file = "regex-2019.12.20-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:d508875793efdf6bab3d47850df8f40d4040ae9928d9d80864c1768d6aeaf8e3"}, + {file = "regex-2019.12.20-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:cfd31b3300fefa5eecb2fe596c6dee1b91b3a05ece9d5cfd2631afebf6c6fadd"}, + {file = "regex-2019.12.20-cp38-cp38-win32.whl", hash = "sha256:29b20f66f2e044aafba86ecf10a84e611b4667643c42baa004247f5dfef4f90b"}, + {file = "regex-2019.12.20-cp38-cp38-win_amd64.whl", hash = "sha256:d3ee0b035816e0520fac928de31b6572106f0d75597f6fa3206969a02baba06f"}, + {file = "regex-2019.12.20.tar.gz", hash = "sha256:106e25a841921d8259dcef2a42786caae35bc750fb996f830065b3dfaa67b77e"}, +] +requests = [ + {file = "requests-2.22.0-py2.py3-none-any.whl", hash = "sha256:9cf5292fcd0f598c671cfc1e0d7d1a7f13bb8085e9a590f48c010551dc6c4b31"}, + {file = "requests-2.22.0.tar.gz", hash = "sha256:11e007a8a2aa0323f5a921e9e6a2d7e4e67d9877e85773fba9ba6419025cbeb4"}, +] +six = [ + {file = "six-1.13.0-py2.py3-none-any.whl", hash = "sha256:1f1b7d42e254082a9db6279deae68afb421ceba6158efa6131de7b3003ee93fd"}, + {file = "six-1.13.0.tar.gz", hash = "sha256:30f610279e8b2578cab6db20741130331735c781b56053c59c4076da27f06b66"}, +] +toml = [ + {file = "toml-0.10.0-py2.7.egg", hash = "sha256:f1db651f9657708513243e61e6cc67d101a39bad662eaa9b5546f789338e07a3"}, + {file = "toml-0.10.0-py2.py3-none-any.whl", hash = "sha256:235682dd292d5899d361a811df37e04a8828a5b1da3115886b73cf81ebc9100e"}, + {file = "toml-0.10.0.tar.gz", hash = "sha256:229f81c57791a41d65e399fc06bf0848bab550a9dfd5ed66df18ce5f05e73d5c"}, +] +typed-ast = [ + {file = "typed_ast-1.4.0-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:262c247a82d005e43b5b7f69aff746370538e176131c32dda9cb0f324d27141e"}, + {file = "typed_ast-1.4.0-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:71211d26ffd12d63a83e079ff258ac9d56a1376a25bc80b1cdcdf601b855b90b"}, + {file = "typed_ast-1.4.0-cp35-cp35m-win32.whl", hash = "sha256:630968c5cdee51a11c05a30453f8cd65e0cc1d2ad0d9192819df9978984529f4"}, + {file = "typed_ast-1.4.0-cp35-cp35m-win_amd64.whl", hash = "sha256:ffde2fbfad571af120fcbfbbc61c72469e72f550d676c3342492a9dfdefb8f12"}, + {file = "typed_ast-1.4.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:4e0b70c6fc4d010f8107726af5fd37921b666f5b31d9331f0bd24ad9a088e631"}, + {file = "typed_ast-1.4.0-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:bc6c7d3fa1325a0c6613512a093bc2a2a15aeec350451cbdf9e1d4bffe3e3233"}, + {file = "typed_ast-1.4.0-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:cc34a6f5b426748a507dd5d1de4c1978f2eb5626d51326e43280941206c209e1"}, + {file = "typed_ast-1.4.0-cp36-cp36m-win32.whl", hash = "sha256:d896919306dd0aa22d0132f62a1b78d11aaf4c9fc5b3410d3c666b818191630a"}, + {file = "typed_ast-1.4.0-cp36-cp36m-win_amd64.whl", hash = "sha256:354c16e5babd09f5cb0ee000d54cfa38401d8b8891eefa878ac772f827181a3c"}, + {file = "typed_ast-1.4.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:95bd11af7eafc16e829af2d3df510cecfd4387f6453355188342c3e79a2ec87a"}, + {file = "typed_ast-1.4.0-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:18511a0b3e7922276346bcb47e2ef9f38fb90fd31cb9223eed42c85d1312344e"}, + {file = "typed_ast-1.4.0-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:d7c45933b1bdfaf9f36c579671fec15d25b06c8398f113dab64c18ed1adda01d"}, + {file = "typed_ast-1.4.0-cp37-cp37m-win32.whl", hash = "sha256:d755f03c1e4a51e9b24d899561fec4ccaf51f210d52abdf8c07ee2849b212a36"}, + {file = "typed_ast-1.4.0-cp37-cp37m-win_amd64.whl", hash = "sha256:2b907eb046d049bcd9892e3076c7a6456c93a25bebfe554e931620c90e6a25b0"}, + {file = "typed_ast-1.4.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:fdc1c9bbf79510b76408840e009ed65958feba92a88833cdceecff93ae8fff66"}, + {file = "typed_ast-1.4.0-cp38-cp38-manylinux1_i686.whl", hash = "sha256:7954560051331d003b4e2b3eb822d9dd2e376fa4f6d98fee32f452f52dd6ebb2"}, + {file = "typed_ast-1.4.0-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:48e5b1e71f25cfdef98b013263a88d7145879fbb2d5185f2a0c79fa7ebbeae47"}, + {file = "typed_ast-1.4.0-cp38-cp38-win32.whl", hash = "sha256:1170afa46a3799e18b4c977777ce137bb53c7485379d9706af8a59f2ea1aa161"}, + {file = "typed_ast-1.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:838997f4310012cf2e1ad3803bce2f3402e9ffb71ded61b5ee22617b3a7f6b6e"}, + {file = "typed_ast-1.4.0.tar.gz", hash = "sha256:66480f95b8167c9c5c5c87f32cf437d585937970f3fc24386f313a4c97b44e34"}, +] +typing-extensions = [ + {file = "typing_extensions-3.7.4.1-py2-none-any.whl", hash = "sha256:910f4656f54de5993ad9304959ce9bb903f90aadc7c67a0bef07e678014e892d"}, + {file = "typing_extensions-3.7.4.1-py3-none-any.whl", hash = "sha256:cf8b63fedea4d89bab840ecbb93e75578af28f76f66c35889bd7065f5af88575"}, + {file = "typing_extensions-3.7.4.1.tar.gz", hash = "sha256:091ecc894d5e908ac75209f10d5b4f118fbdb2eb1ede6a63544054bb1edb41f2"}, +] +urllib3 = [ + {file = "urllib3-1.22-py2.py3-none-any.whl", hash = "sha256:06330f386d6e4b195fbfc736b297f58c5a892e4440e54d294d7004e3a9bbea1b"}, + {file = "urllib3-1.22.tar.gz", hash = "sha256:cc44da8e1145637334317feebd728bd869a35285b93cbb4cca2577da7e62db4f"}, +] +wcwidth = [ + {file = "wcwidth-0.1.7-py2.py3-none-any.whl", hash = "sha256:f4ebe71925af7b40a864553f761ed559b43544f8f71746c2d756c7fe788ade7c"}, + {file = "wcwidth-0.1.7.tar.gz", hash = "sha256:3df37372226d6e63e1b1e1eda15c594bca98a22d33a23832a90998faa96bc65e"}, +] +zipp = [ + {file = "zipp-0.6.0-py2.py3-none-any.whl", hash = "sha256:f06903e9f1f43b12d371004b4ac7b06ab39a44adc747266928ae6debfa7b3335"}, + {file = "zipp-0.6.0.tar.gz", hash = "sha256:3718b1cbcd963c7d4c5511a8240812904164b7f381b647143a89d3b98f9bcd8e"}, +] diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..c1d10a8 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,20 @@ +[tool] +[tool.poetry] +name = "kube-janitor" +version = "2019.2.1dev0" +description = "Clean up (delete) Kubernetes resources after a configured TTL (time to live)" +authors = ["Henning Jacobs "] + +[tool.poetry.dependencies] +python = ">=3.7" +jmespath = "*" +pykube-ng = "*" + +[tool.poetry.dev-dependencies] +coverage = "*" +coveralls = "*" +flake8 = "*" +pytest = "*" +pytest-cov = "*" +black = "^19.10b0" +mypy = "^0.761" diff --git a/tests/test_clean_up.py b/tests/test_clean_up.py index 26f0c23..4dae7b7 100644 --- a/tests/test_clean_up.py +++ b/tests/test_clean_up.py @@ -5,119 +5,183 @@ from pykube.objects import NamespacedAPIObject from pykube import Namespace -from kube_janitor.janitor import matches_resource_filter, handle_resource_on_ttl, handle_resource_on_expiry, clean_up, delete +from kube_janitor.janitor import ( + matches_resource_filter, + handle_resource_on_ttl, + handle_resource_on_expiry, + clean_up, + delete, +) from kube_janitor.rules import Rule -ALL = frozenset(['all']) +ALL = frozenset(["all"]) def test_matches_resource_filter(): - foo_ns = Namespace(None, {'metadata': {'name': 'foo'}}) + foo_ns = Namespace(None, {"metadata": {"name": "foo"}}) assert not matches_resource_filter(foo_ns, [], [], [], []) assert not matches_resource_filter(foo_ns, ALL, [], [], []) assert matches_resource_filter(foo_ns, ALL, [], ALL, []) - assert not matches_resource_filter(foo_ns, ALL, [], ALL, ['foo']) - assert not matches_resource_filter(foo_ns, ALL, ['namespaces'], ALL, []) - assert matches_resource_filter(foo_ns, ALL, ['deployments'], ALL, ['kube-system']) + assert not matches_resource_filter(foo_ns, ALL, [], ALL, ["foo"]) + assert not matches_resource_filter(foo_ns, ALL, ["namespaces"], ALL, []) + assert matches_resource_filter(foo_ns, ALL, ["deployments"], ALL, ["kube-system"]) def test_delete_namespace(caplog): caplog.set_level(logging.INFO) mock_api = MagicMock() - foo_ns = Namespace(mock_api, {'metadata': {'name': 'foo'}}) + foo_ns = Namespace(mock_api, {"metadata": {"name": "foo"}}) delete(foo_ns, dry_run=False) - assert 'Deleting Namespace foo..' in caplog.messages + assert "Deleting Namespace foo.." in caplog.messages mock_api.delete.assert_called_once() def test_handle_resource_no_ttl(): - resource = Namespace(None, {'metadata': {'name': 'foo'}}) + resource = Namespace(None, {"metadata": {"name": "foo"}}) counter = handle_resource_on_ttl(resource, [], None, dry_run=True) - assert counter == {'resources-processed': 1} + assert counter == {"resources-processed": 1} def test_handle_resource_no_expiry(): - resource = Namespace(None, {'metadata': {'name': 'foo'}}) + resource = Namespace(None, {"metadata": {"name": "foo"}}) counter = handle_resource_on_expiry(resource, [], None, dry_run=True) assert counter == {} def test_handle_resource_ttl_annotation(): # TTL is far in the future - resource = Namespace(None, {'metadata': {'name': 'foo', 'annotations': {'janitor/ttl': '999w'}, 'creationTimestamp': '2019-01-17T20:59:12Z'}}) + resource = Namespace( + None, + { + "metadata": { + "name": "foo", + "annotations": {"janitor/ttl": "999w"}, + "creationTimestamp": "2019-01-17T20:59:12Z", + } + }, + ) counter = handle_resource_on_ttl(resource, [], None, dry_run=True) - assert counter == {'resources-processed': 1, 'namespaces-with-ttl': 1} + assert counter == {"resources-processed": 1, "namespaces-with-ttl": 1} def test_handle_resource_expiry_annotation(): # TTL is far in the future - resource = Namespace(None, {'metadata': { - 'name': 'foo', - 'annotations': {'janitor/expires': '2050-09-26T01:51:42Z'}}}) + resource = Namespace( + None, + { + "metadata": { + "name": "foo", + "annotations": {"janitor/expires": "2050-09-26T01:51:42Z"}, + } + }, + ) counter = handle_resource_on_expiry(resource, [], None, dry_run=True) - assert counter == {'namespaces-with-expiry': 1} + assert counter == {"namespaces-with-expiry": 1} def test_handle_resource_ttl_expired(): - resource = Namespace(None, {'metadata': {'name': 'foo', 'annotations': {'janitor/ttl': '1s'}, 'creationTimestamp': '2019-01-17T20:59:12Z'}}) + resource = Namespace( + None, + { + "metadata": { + "name": "foo", + "annotations": {"janitor/ttl": "1s"}, + "creationTimestamp": "2019-01-17T20:59:12Z", + } + }, + ) counter = handle_resource_on_ttl(resource, [], None, dry_run=True) - assert counter == {'resources-processed': 1, 'namespaces-with-ttl': 1, 'namespaces-deleted': 1} + assert counter == { + "resources-processed": 1, + "namespaces-with-ttl": 1, + "namespaces-deleted": 1, + } def test_handle_resource_expiry_expired(): - resource = Namespace(None, {'metadata': { - 'name': 'foo', - 'annotations': {'janitor/expires': '2001-09-26T01:51:42Z'}}}) + resource = Namespace( + None, + { + "metadata": { + "name": "foo", + "annotations": {"janitor/expires": "2001-09-26T01:51:42Z"}, + } + }, + ) counter = handle_resource_on_expiry(resource, [], None, dry_run=True) - assert counter == {'namespaces-with-expiry': 1, 'namespaces-deleted': 1} + assert counter == {"namespaces-with-expiry": 1, "namespaces-deleted": 1} def test_clean_up_default(): - api_mock = MagicMock(spec=NamespacedAPIObject, name='APIMock') + api_mock = MagicMock(spec=NamespacedAPIObject, name="APIMock") def get(**kwargs): - if kwargs.get('url') == 'namespaces': + if kwargs.get("url") == "namespaces": # kube-system is skipped - data = {'items': [{'metadata': {'name': 'default'}}, {'metadata': {'name': 'kube-system'}}]} - elif kwargs['version'] == 'v1': - data = {'resources': []} - elif kwargs['version'] == '/apis': - data = {'groups': []} + data = { + "items": [ + {"metadata": {"name": "default"}}, + {"metadata": {"name": "kube-system"}}, + ] + } + elif kwargs["version"] == "v1": + data = {"resources": []} + elif kwargs["version"] == "/apis": + data = {"groups": []} else: data = {} response = MagicMock() response.json.return_value = data return response + api_mock.get = get - counter = clean_up(api_mock, ALL, [], ALL, ['kube-system'], [], None, dry_run=False) + counter = clean_up(api_mock, ALL, [], ALL, ["kube-system"], [], None, dry_run=False) - assert counter['resources-processed'] == 1 + assert counter["resources-processed"] == 1 def test_ignore_nonlistable_api_group(): - api_mock = MagicMock(spec=NamespacedAPIObject, name='APIMock') + api_mock = MagicMock(spec=NamespacedAPIObject, name="APIMock") def get(**kwargs): - if kwargs.get('url') == 'namespaces': - data = {'items': [{'metadata': {'name': 'ns-1'}}]} - elif kwargs.get('url') == 'customfoos': - data = {'items': [{'metadata': { - 'name': 'foo-1', - 'namespace': 'ns-1', - 'creationTimestamp': '2019-01-17T15:14:38Z', - # invalid TTL (no unit suffix) - 'annotations': {'janitor/ttl': '123'}}}]} - elif kwargs['version'] == 'v1': - data = {'resources': []} - elif kwargs['version'] == 'srcco.de/v1': - data = {'resources': [{'kind': 'CustomFoo', 'name': 'customfoos', 'namespaced': True, 'verbs': ['delete']}]} - elif kwargs['version'] == 'kaput.srcco.de/v1': - raise Exception('Catch me if you can!') - elif kwargs['version'] == '/apis': - data = {'groups': [ - {'preferredVersion': {'groupVersion': 'kaput.srcco.de/v1'}}, - {'preferredVersion': {'groupVersion': 'srcco.de/v1'}}, - ]} + if kwargs.get("url") == "namespaces": + data = {"items": [{"metadata": {"name": "ns-1"}}]} + elif kwargs.get("url") == "customfoos": + data = { + "items": [ + { + "metadata": { + "name": "foo-1", + "namespace": "ns-1", + "creationTimestamp": "2019-01-17T15:14:38Z", + # invalid TTL (no unit suffix) + "annotations": {"janitor/ttl": "123"}, + } + } + ] + } + elif kwargs["version"] == "v1": + data = {"resources": []} + elif kwargs["version"] == "srcco.de/v1": + data = { + "resources": [ + { + "kind": "CustomFoo", + "name": "customfoos", + "namespaced": True, + "verbs": ["delete"], + } + ] + } + elif kwargs["version"] == "kaput.srcco.de/v1": + raise Exception("Catch me if you can!") + elif kwargs["version"] == "/apis": + data = { + "groups": [ + {"preferredVersion": {"groupVersion": "kaput.srcco.de/v1"}}, + {"preferredVersion": {"groupVersion": "srcco.de/v1"}}, + ] + } else: data = {} response = MagicMock() @@ -126,31 +190,47 @@ def get(**kwargs): api_mock.get = get counter = clean_up(api_mock, ALL, [], ALL, [], [], None, dry_run=False) - assert counter['resources-processed'] == 2 - assert counter['customfoos-with-ttl'] == 0 - assert counter['customfoos-deleted'] == 0 + assert counter["resources-processed"] == 2 + assert counter["customfoos-with-ttl"] == 0 + assert counter["customfoos-deleted"] == 0 assert not api_mock.delete.called def test_ignore_invalid_ttl(): - api_mock = MagicMock(spec=NamespacedAPIObject, name='APIMock') + api_mock = MagicMock(spec=NamespacedAPIObject, name="APIMock") def get(**kwargs): - if kwargs.get('url') == 'namespaces': - data = {'items': [{'metadata': {'name': 'ns-1'}}]} - elif kwargs.get('url') == 'customfoos': - data = {'items': [{'metadata': { - 'name': 'foo-1', - 'namespace': 'ns-1', - 'creationTimestamp': '2019-01-17T15:14:38Z', - # invalid TTL (no unit suffix) - 'annotations': {'janitor/ttl': '123'}}}]} - elif kwargs['version'] == 'v1': - data = {'resources': []} - elif kwargs['version'] == 'srcco.de/v1': - data = {'resources': [{'kind': 'CustomFoo', 'name': 'customfoos', 'namespaced': True, 'verbs': ['delete']}]} - elif kwargs['version'] == '/apis': - data = {'groups': [{'preferredVersion': {'groupVersion': 'srcco.de/v1'}}]} + if kwargs.get("url") == "namespaces": + data = {"items": [{"metadata": {"name": "ns-1"}}]} + elif kwargs.get("url") == "customfoos": + data = { + "items": [ + { + "metadata": { + "name": "foo-1", + "namespace": "ns-1", + "creationTimestamp": "2019-01-17T15:14:38Z", + # invalid TTL (no unit suffix) + "annotations": {"janitor/ttl": "123"}, + } + } + ] + } + elif kwargs["version"] == "v1": + data = {"resources": []} + elif kwargs["version"] == "srcco.de/v1": + data = { + "resources": [ + { + "kind": "CustomFoo", + "name": "customfoos", + "namespaced": True, + "verbs": ["delete"], + } + ] + } + elif kwargs["version"] == "/apis": + data = {"groups": [{"preferredVersion": {"groupVersion": "srcco.de/v1"}}]} else: data = {} response = MagicMock() @@ -159,30 +239,46 @@ def get(**kwargs): api_mock.get = get counter = clean_up(api_mock, ALL, [], ALL, [], [], None, dry_run=False) - assert counter['resources-processed'] == 2 - assert counter['customfoos-with-ttl'] == 0 - assert counter['customfoos-deleted'] == 0 + assert counter["resources-processed"] == 2 + assert counter["customfoos-with-ttl"] == 0 + assert counter["customfoos-deleted"] == 0 assert not api_mock.delete.called def test_ignore_invalid_expiry(): - api_mock = MagicMock(spec=NamespacedAPIObject, name='APIMock') + api_mock = MagicMock(spec=NamespacedAPIObject, name="APIMock") def get(**kwargs): - if kwargs.get('url') == 'namespaces': - data = {'items': [{'metadata': {'name': 'ns-1'}}]} - elif kwargs.get('url') == 'customfoos': - data = {'items': [{'metadata': { - 'name': 'foo-1', - 'namespace': 'ns-1', - # invalid expiry - 'annotations': {'janitor/expires': '123'}}}]} - elif kwargs['version'] == 'v1': - data = {'resources': []} - elif kwargs['version'] == 'srcco.de/v1': - data = {'resources': [{'kind': 'CustomFoo', 'name': 'customfoos', 'namespaced': True, 'verbs': ['delete']}]} - elif kwargs['version'] == '/apis': - data = {'groups': [{'preferredVersion': {'groupVersion': 'srcco.de/v1'}}]} + if kwargs.get("url") == "namespaces": + data = {"items": [{"metadata": {"name": "ns-1"}}]} + elif kwargs.get("url") == "customfoos": + data = { + "items": [ + { + "metadata": { + "name": "foo-1", + "namespace": "ns-1", + # invalid expiry + "annotations": {"janitor/expires": "123"}, + } + } + ] + } + elif kwargs["version"] == "v1": + data = {"resources": []} + elif kwargs["version"] == "srcco.de/v1": + data = { + "resources": [ + { + "kind": "CustomFoo", + "name": "customfoos", + "namespaced": True, + "verbs": ["delete"], + } + ] + } + elif kwargs["version"] == "/apis": + data = {"groups": [{"preferredVersion": {"groupVersion": "srcco.de/v1"}}]} else: data = {} response = MagicMock() @@ -191,30 +287,46 @@ def get(**kwargs): api_mock.get = get counter = clean_up(api_mock, ALL, [], ALL, [], [], None, dry_run=False) - assert counter['resources-processed'] == 2 - assert counter['customfoos-with-expiry'] == 0 - assert counter['customfoos-deleted'] == 0 + assert counter["resources-processed"] == 2 + assert counter["customfoos-with-expiry"] == 0 + assert counter["customfoos-deleted"] == 0 assert not api_mock.delete.called def test_clean_up_custom_resource_on_ttl(): - api_mock = MagicMock(name='APIMock') + api_mock = MagicMock(name="APIMock") def get(**kwargs): - if kwargs.get('url') == 'namespaces': - data = {'items': [{'metadata': {'name': 'ns-1'}}]} - elif kwargs.get('url') == 'customfoos': - data = {'items': [{'metadata': { - 'name': 'foo-1', - 'namespace': 'ns-1', - 'creationTimestamp': '2019-01-17T15:14:38Z', - 'annotations': {'janitor/ttl': '10m'}}}]} - elif kwargs['version'] == 'v1': - data = {'resources': []} - elif kwargs['version'] == 'srcco.de/v1': - data = {'resources': [{'kind': 'CustomFoo', 'name': 'customfoos', 'namespaced': True, 'verbs': ['delete']}]} - elif kwargs['version'] == '/apis': - data = {'groups': [{'preferredVersion': {'groupVersion': 'srcco.de/v1'}}]} + if kwargs.get("url") == "namespaces": + data = {"items": [{"metadata": {"name": "ns-1"}}]} + elif kwargs.get("url") == "customfoos": + data = { + "items": [ + { + "metadata": { + "name": "foo-1", + "namespace": "ns-1", + "creationTimestamp": "2019-01-17T15:14:38Z", + "annotations": {"janitor/ttl": "10m"}, + } + } + ] + } + elif kwargs["version"] == "v1": + data = {"resources": []} + elif kwargs["version"] == "srcco.de/v1": + data = { + "resources": [ + { + "kind": "CustomFoo", + "name": "customfoos", + "namespaced": True, + "verbs": ["delete"], + } + ] + } + elif kwargs["version"] == "/apis": + data = {"groups": [{"preferredVersion": {"groupVersion": "srcco.de/v1"}}]} else: data = {} response = MagicMock() @@ -225,40 +337,68 @@ def get(**kwargs): counter = clean_up(api_mock, ALL, [], ALL, [], [], None, dry_run=False) # namespace ns-1 and object foo-1 - assert counter['resources-processed'] == 2 - assert counter['customfoos-with-ttl'] == 1 - assert counter['customfoos-deleted'] == 1 + assert counter["resources-processed"] == 2 + assert counter["customfoos-with-ttl"] == 1 + assert counter["customfoos-deleted"] == 1 api_mock.post.assert_called_once() _, kwargs = api_mock.post.call_args - assert kwargs['url'] == 'events' - data = json.loads(kwargs['data']) - assert data['reason'] == 'TimeToLiveExpired' - assert 'annotation janitor/ttl is set' in data['message'] - involvedObject = {'kind': 'CustomFoo', 'name': 'foo-1', 'namespace': 'ns-1', 'apiVersion': 'srcco.de/v1', 'resourceVersion': None, 'uid': None} - assert data['involvedObject'] == involvedObject + assert kwargs["url"] == "events" + data = json.loads(kwargs["data"]) + assert data["reason"] == "TimeToLiveExpired" + assert "annotation janitor/ttl is set" in data["message"] + involvedObject = { + "kind": "CustomFoo", + "name": "foo-1", + "namespace": "ns-1", + "apiVersion": "srcco.de/v1", + "resourceVersion": None, + "uid": None, + } + assert data["involvedObject"] == involvedObject # verify that the delete call happened - api_mock.delete.assert_called_once_with(data='{"propagationPolicy": "Foreground"}', namespace='ns-1', url='customfoos/foo-1', version='srcco.de/v1') + api_mock.delete.assert_called_once_with( + data='{"propagationPolicy": "Foreground"}', + namespace="ns-1", + url="customfoos/foo-1", + version="srcco.de/v1", + ) def test_clean_up_custom_resource_on_expiry(): - api_mock = MagicMock(name='APIMock') + api_mock = MagicMock(name="APIMock") def get(**kwargs): - if kwargs.get('url') == 'namespaces': - data = {'items': [{'metadata': {'name': 'ns-1'}}]} - elif kwargs.get('url') == 'customfoos': - data = {'items': [{'metadata': { - 'name': 'foo-1', - 'namespace': 'ns-1', - 'annotations': {'janitor/expires': '2001-01-17T15:14:38Z'}}}]} - elif kwargs['version'] == 'v1': - data = {'resources': []} - elif kwargs['version'] == 'srcco.de/v1': - data = {'resources': [{'kind': 'CustomFoo', 'name': 'customfoos', 'namespaced': True, 'verbs': ['delete']}]} - elif kwargs['version'] == '/apis': - data = {'groups': [{'preferredVersion': {'groupVersion': 'srcco.de/v1'}}]} + if kwargs.get("url") == "namespaces": + data = {"items": [{"metadata": {"name": "ns-1"}}]} + elif kwargs.get("url") == "customfoos": + data = { + "items": [ + { + "metadata": { + "name": "foo-1", + "namespace": "ns-1", + "annotations": {"janitor/expires": "2001-01-17T15:14:38Z"}, + } + } + ] + } + elif kwargs["version"] == "v1": + data = {"resources": []} + elif kwargs["version"] == "srcco.de/v1": + data = { + "resources": [ + { + "kind": "CustomFoo", + "name": "customfoos", + "namespaced": True, + "verbs": ["delete"], + } + ] + } + elif kwargs["version"] == "/apis": + data = {"groups": [{"preferredVersion": {"groupVersion": "srcco.de/v1"}}]} else: data = {} response = MagicMock() @@ -269,43 +409,77 @@ def get(**kwargs): counter = clean_up(api_mock, ALL, [], ALL, [], [], None, dry_run=False) # namespace ns-1 and object foo-1 - assert counter['resources-processed'] == 2 - assert counter['customfoos-with-expiry'] == 1 - assert counter['customfoos-deleted'] == 1 + assert counter["resources-processed"] == 2 + assert counter["customfoos-with-expiry"] == 1 + assert counter["customfoos-deleted"] == 1 api_mock.post.assert_called_once() _, kwargs = api_mock.post.call_args - assert kwargs['url'] == 'events' - data = json.loads(kwargs['data']) - assert data['reason'] == 'ExpiryTimeReached' - assert 'annotation janitor/expires is set' in data['message'] - involvedObject = {'kind': 'CustomFoo', 'name': 'foo-1', 'namespace': 'ns-1', 'apiVersion': 'srcco.de/v1', 'resourceVersion': None, 'uid': None} - assert data['involvedObject'] == involvedObject + assert kwargs["url"] == "events" + data = json.loads(kwargs["data"]) + assert data["reason"] == "ExpiryTimeReached" + assert "annotation janitor/expires is set" in data["message"] + involvedObject = { + "kind": "CustomFoo", + "name": "foo-1", + "namespace": "ns-1", + "apiVersion": "srcco.de/v1", + "resourceVersion": None, + "uid": None, + } + assert data["involvedObject"] == involvedObject # verify that the delete call happened - api_mock.delete.assert_called_once_with(data='{"propagationPolicy": "Foreground"}', namespace='ns-1', url='customfoos/foo-1', version='srcco.de/v1') + api_mock.delete.assert_called_once_with( + data='{"propagationPolicy": "Foreground"}', + namespace="ns-1", + url="customfoos/foo-1", + version="srcco.de/v1", + ) def test_clean_up_by_rule(): - api_mock = MagicMock(name='APIMock') + api_mock = MagicMock(name="APIMock") - rule = Rule.from_entry({'id': 'r1', 'resources': ['customfoos'], 'jmespath': "metadata.namespace == 'ns-1'", 'ttl': '10m'}) + rule = Rule.from_entry( + { + "id": "r1", + "resources": ["customfoos"], + "jmespath": "metadata.namespace == 'ns-1'", + "ttl": "10m", + } + ) def get(**kwargs): - if kwargs.get('url') == 'namespaces': - data = {'items': [{'metadata': {'name': 'ns-1'}}]} - elif kwargs.get('url') == 'customfoos': - data = {'items': [{'metadata': { - 'name': 'foo-1', - 'namespace': 'ns-1', - 'creationTimestamp': '2019-01-17T15:14:38Z', - }}]} - elif kwargs['version'] == 'v1': - data = {'resources': []} - elif kwargs['version'] == 'srcco.de/v1': - data = {'resources': [{'kind': 'CustomFoo', 'name': 'customfoos', 'namespaced': True, 'verbs': ['delete']}]} - elif kwargs['version'] == '/apis': - data = {'groups': [{'preferredVersion': {'groupVersion': 'srcco.de/v1'}}]} + if kwargs.get("url") == "namespaces": + data = {"items": [{"metadata": {"name": "ns-1"}}]} + elif kwargs.get("url") == "customfoos": + data = { + "items": [ + { + "metadata": { + "name": "foo-1", + "namespace": "ns-1", + "creationTimestamp": "2019-01-17T15:14:38Z", + } + } + ] + } + elif kwargs["version"] == "v1": + data = {"resources": []} + elif kwargs["version"] == "srcco.de/v1": + data = { + "resources": [ + { + "kind": "CustomFoo", + "name": "customfoos", + "namespaced": True, + "verbs": ["delete"], + } + ] + } + elif kwargs["version"] == "/apis": + data = {"groups": [{"preferredVersion": {"groupVersion": "srcco.de/v1"}}]} else: data = {} response = MagicMock() @@ -316,19 +490,31 @@ def get(**kwargs): counter = clean_up(api_mock, ALL, [], ALL, [], [rule], None, dry_run=False) # namespace ns-1 and object foo-1 - assert counter['resources-processed'] == 2 - assert counter['rule-r1-matches'] == 1 - assert counter['customfoos-with-ttl'] == 1 - assert counter['customfoos-deleted'] == 1 + assert counter["resources-processed"] == 2 + assert counter["rule-r1-matches"] == 1 + assert counter["customfoos-with-ttl"] == 1 + assert counter["customfoos-deleted"] == 1 api_mock.post.assert_called_once() _, kwargs = api_mock.post.call_args - assert kwargs['url'] == 'events' - data = json.loads(kwargs['data']) - assert data['reason'] == 'TimeToLiveExpired' - assert 'rule r1 matches' in data['message'] - involvedObject = {'kind': 'CustomFoo', 'name': 'foo-1', 'namespace': 'ns-1', 'apiVersion': 'srcco.de/v1', 'resourceVersion': None, 'uid': None} - assert data['involvedObject'] == involvedObject + assert kwargs["url"] == "events" + data = json.loads(kwargs["data"]) + assert data["reason"] == "TimeToLiveExpired" + assert "rule r1 matches" in data["message"] + involvedObject = { + "kind": "CustomFoo", + "name": "foo-1", + "namespace": "ns-1", + "apiVersion": "srcco.de/v1", + "resourceVersion": None, + "uid": None, + } + assert data["involvedObject"] == involvedObject # verify that the delete call happened - api_mock.delete.assert_called_once_with(data='{"propagationPolicy": "Foreground"}', namespace='ns-1', url='customfoos/foo-1', version='srcco.de/v1') + api_mock.delete.assert_called_once_with( + data='{"propagationPolicy": "Foreground"}', + namespace="ns-1", + url="customfoos/foo-1", + version="srcco.de/v1", + ) diff --git a/tests/test_cmd.py b/tests/test_cmd.py index 808e3a0..45d0174 100644 --- a/tests/test_cmd.py +++ b/tests/test_cmd.py @@ -3,4 +3,4 @@ def test_parse_args(): parser = get_parser() - parser.parse_args(['--dry-run', '--rules-file=/config/rules.yaml']) + parser.parse_args(["--dry-run", "--rules-file=/config/rules.yaml"]) diff --git a/tests/test_delete_notification.py b/tests/test_delete_notification.py index 45c0e26..cf217bc 100644 --- a/tests/test_delete_notification.py +++ b/tests/test_delete_notification.py @@ -1,144 +1,292 @@ import datetime import unittest -from kube_janitor.janitor import (add_notification_flag, - get_delete_notification_time, get_ttl_expiry_time, - handle_resource_on_expiry, - handle_resource_on_ttl, was_notified) +from kube_janitor.janitor import ( + add_notification_flag, + get_delete_notification_time, + get_ttl_expiry_time, + handle_resource_on_expiry, + handle_resource_on_ttl, + was_notified, +) from pykube import Namespace from pykube.utils import obj_merge class MockedNamespace(Namespace): - def update(self): - print('Mocked update') + print("Mocked update") self.obj = obj_merge(self.obj, self._original_obj) class TestDeleteNotification(unittest.TestCase): - def setUp(self): - self.mocked_resource = MockedNamespace(None, {'metadata': {'annotations': {}, 'name': 'mocked-namespace', 'creationTimestamp': '2019-03-11T11:10:09Z'}}) + self.mocked_resource = MockedNamespace( + None, + { + "metadata": { + "annotations": {}, + "name": "mocked-namespace", + "creationTimestamp": "2019-03-11T11:10:09Z", + } + }, + ) def test_get_ttl_expiry_time(self): ttl_seconds = 300 # 5 minutes - expected_expire = datetime.datetime.strptime('2019-03-11T11:15:09Z', '%Y-%m-%dT%H:%M:%SZ') + expected_expire = datetime.datetime.strptime( + "2019-03-11T11:15:09Z", "%Y-%m-%dT%H:%M:%SZ" + ) expire = get_ttl_expiry_time(self.mocked_resource, ttl_seconds) self.assertEqual(expire, expected_expire) def test_add_notification_flag(self): add_notification_flag(self.mocked_resource, dry_run=False) - self.assertEqual(self.mocked_resource.obj.get('metadata').get('annotations').get('janitor/notified'), 'yes') + self.assertEqual( + self.mocked_resource.obj.get("metadata") + .get("annotations") + .get("janitor/notified"), + "yes", + ) def test_add_notification_flag_dry_run(self): add_notification_flag(self.mocked_resource, dry_run=True) - self.assertIsNone(self.mocked_resource.obj.get('metadata').get('annotations').get('janitor/notified', None)) + self.assertIsNone( + self.mocked_resource.obj.get("metadata") + .get("annotations") + .get("janitor/notified", None) + ) def test_was_notified(self): add_notification_flag(self.mocked_resource, dry_run=False) self.assertTrue(was_notified(self.mocked_resource)) def test_get_delete_notification(self): - expire = datetime.datetime.strptime('2019-03-11T11:15:09Z', '%Y-%m-%dT%H:%M:%SZ') - expected_notification_datetime = datetime.datetime.strptime('2019-03-11T11:10:09Z', '%Y-%m-%dT%H:%M:%SZ') + expire = datetime.datetime.strptime( + "2019-03-11T11:15:09Z", "%Y-%m-%dT%H:%M:%SZ" + ) + expected_notification_datetime = datetime.datetime.strptime( + "2019-03-11T11:10:09Z", "%Y-%m-%dT%H:%M:%SZ" + ) delete_notification = 300 # 5 minutes - self.assertEqual(get_delete_notification_time(expire, delete_notification), expected_notification_datetime) + self.assertEqual( + get_delete_notification_time(expire, delete_notification), + expected_notification_datetime, + ) - @unittest.mock.patch('kube_janitor.janitor.utcnow', return_value=datetime.datetime.strptime('2019-03-11T11:10:09Z', '%Y-%m-%dT%H:%M:%SZ')) - @unittest.mock.patch('kube_janitor.janitor.add_notification_flag') - def test_handle_resource_ttl_annotation_with_notification_not_triggered(self, mocked_add_notification_flag, mocked_utcnow): + @unittest.mock.patch( + "kube_janitor.janitor.utcnow", + return_value=datetime.datetime.strptime( + "2019-03-11T11:10:09Z", "%Y-%m-%dT%H:%M:%SZ" + ), + ) + @unittest.mock.patch("kube_janitor.janitor.add_notification_flag") + def test_handle_resource_ttl_annotation_with_notification_not_triggered( + self, mocked_add_notification_flag, mocked_utcnow + ): # Resource was created: 2019-03-11T11:05:00Z # ttl is 10 minutes, so it will expire: 2019-03-11T11:15:00Z # Current datetime is: 2019-03-11T11:10:09Z # Notification is 3 minutes: 180s. Has to notify after: 2019-03-11T11:12:00Z - resource = Namespace(None, {'metadata': {'name': 'foo', 'annotations': {'janitor/ttl': '10m'}, 'creationTimestamp': '2019-03-11T11:05:00Z'}}) + resource = Namespace( + None, + { + "metadata": { + "name": "foo", + "annotations": {"janitor/ttl": "10m"}, + "creationTimestamp": "2019-03-11T11:05:00Z", + } + }, + ) delete_notification = 180 handle_resource_on_ttl(resource, [], delete_notification, dry_run=True) mocked_add_notification_flag.assert_not_called() - @unittest.mock.patch('kube_janitor.janitor.utcnow', return_value=datetime.datetime.strptime('2019-03-11T11:13:09Z', '%Y-%m-%dT%H:%M:%SZ')) - @unittest.mock.patch('kube_janitor.janitor.add_notification_flag') - def test_handle_resource_ttl_annotation_with_notification_triggered(self, mocked_add_notification_flag, mocked_utcnow): + @unittest.mock.patch( + "kube_janitor.janitor.utcnow", + return_value=datetime.datetime.strptime( + "2019-03-11T11:13:09Z", "%Y-%m-%dT%H:%M:%SZ" + ), + ) + @unittest.mock.patch("kube_janitor.janitor.add_notification_flag") + def test_handle_resource_ttl_annotation_with_notification_triggered( + self, mocked_add_notification_flag, mocked_utcnow + ): # Resource was created: 2019-03-11T11:05:00Z # ttl is 10 minutes, so it will expire: 2019-03-11T11:15:00Z # Current datetime is: 2019-03-11T11:13:09Z # Notification is 3 minutes: 180s. Has to notify after: 2019-03-11T11:12:00Z - resource = Namespace(None, {'metadata': {'name': 'foo', 'annotations': {'janitor/ttl': '10m'}, 'creationTimestamp': '2019-03-11T11:05:00Z'}}) + resource = Namespace( + None, + { + "metadata": { + "name": "foo", + "annotations": {"janitor/ttl": "10m"}, + "creationTimestamp": "2019-03-11T11:05:00Z", + } + }, + ) delete_notification = 180 handle_resource_on_ttl(resource, [], delete_notification, dry_run=True) mocked_add_notification_flag.assert_called() - @unittest.mock.patch('kube_janitor.janitor.utcnow', return_value=datetime.datetime.strptime('2019-03-11T11:13:09Z', '%Y-%m-%dT%H:%M:%SZ')) - @unittest.mock.patch('kube_janitor.janitor.add_notification_flag') - def test_handle_resource_ttl_annotation_with_forever_value_not_triggered(self, mocked_add_notification_flag, mocked_utcnow): + @unittest.mock.patch( + "kube_janitor.janitor.utcnow", + return_value=datetime.datetime.strptime( + "2019-03-11T11:13:09Z", "%Y-%m-%dT%H:%M:%SZ" + ), + ) + @unittest.mock.patch("kube_janitor.janitor.add_notification_flag") + def test_handle_resource_ttl_annotation_with_forever_value_not_triggered( + self, mocked_add_notification_flag, mocked_utcnow + ): # Resource was created: 2019-03-11T11:05:00Z # ttl is `forever`, so it will not expire # Current datetime is: 2019-03-11T11:13:09Z # Notification is 3 minutes: 180s. Has to notify after: 2019-03-11T11:12:00Z - resource = Namespace(None, {'metadata': {'name': 'foo', 'annotations': {'janitor/ttl': 'forever'}, 'creationTimestamp': '2019-03-11T11:05:00Z'}}) + resource = Namespace( + None, + { + "metadata": { + "name": "foo", + "annotations": {"janitor/ttl": "forever"}, + "creationTimestamp": "2019-03-11T11:05:00Z", + } + }, + ) delete_notification = 180 - counter = handle_resource_on_ttl(resource, [], delete_notification, dry_run=True) - self.assertEqual(1, counter['resources-processed']) + counter = handle_resource_on_ttl( + resource, [], delete_notification, dry_run=True + ) + self.assertEqual(1, counter["resources-processed"]) self.assertEqual(1, len(counter)) mocked_add_notification_flag.assert_not_called() - @unittest.mock.patch('kube_janitor.janitor.utcnow', return_value=datetime.datetime.strptime('2019-03-11T11:13:09Z', '%Y-%m-%dT%H:%M:%SZ')) - @unittest.mock.patch('kube_janitor.janitor.create_event') - def test_handle_resource_ttl_annotation_notification_event(self, mocked_create_event, mocked_utcnow): + @unittest.mock.patch( + "kube_janitor.janitor.utcnow", + return_value=datetime.datetime.strptime( + "2019-03-11T11:13:09Z", "%Y-%m-%dT%H:%M:%SZ" + ), + ) + @unittest.mock.patch("kube_janitor.janitor.create_event") + def test_handle_resource_ttl_annotation_notification_event( + self, mocked_create_event, mocked_utcnow + ): # Resource was created: 2019-03-11T11:05:00Z # ttl is 10 minutes, so it will expire: 2019-03-11T11:15:00Z # Current datetime is: 2019-03-11T11:13:09Z # Notification is 3 minutes: 180s. Has to notify after: 2019-03-11T11:12:00Z - resource = MockedNamespace(None, {'metadata': {'name': 'foo', 'annotations': {'janitor/ttl': '10m'}, 'creationTimestamp': '2019-03-11T11:05:00Z'}}) + resource = MockedNamespace( + None, + { + "metadata": { + "name": "foo", + "annotations": {"janitor/ttl": "10m"}, + "creationTimestamp": "2019-03-11T11:05:00Z", + } + }, + ) delete_notification = 180 handle_resource_on_ttl(resource, [], delete_notification, dry_run=True) - expire = datetime.datetime.strptime('2019-03-11T11:15:00Z', '%Y-%m-%dT%H:%M:%SZ') - formatted_expire_datetime = expire.strftime('%Y-%m-%dT%H:%M:%SZ') - reason = 'annotation janitor/ttl is set' - message = f'{resource.kind} {resource.name} will be deleted at {formatted_expire_datetime} ({reason})' - mocked_create_event.assert_called_with(resource, message, 'DeleteNotification', dry_run=True) - - @unittest.mock.patch('kube_janitor.janitor.utcnow', return_value=datetime.datetime.strptime('2019-03-11T11:10:09Z', '%Y-%m-%dT%H:%M:%SZ')) - @unittest.mock.patch('kube_janitor.janitor.add_notification_flag') - def test_handle_resource_expiry_annotation_with_notification_not_triggered(self, mocked_add_notification_flag, mocked_utcnow): + expire = datetime.datetime.strptime( + "2019-03-11T11:15:00Z", "%Y-%m-%dT%H:%M:%SZ" + ) + formatted_expire_datetime = expire.strftime("%Y-%m-%dT%H:%M:%SZ") + reason = "annotation janitor/ttl is set" + message = f"{resource.kind} {resource.name} will be deleted at {formatted_expire_datetime} ({reason})" + mocked_create_event.assert_called_with( + resource, message, "DeleteNotification", dry_run=True + ) + + @unittest.mock.patch( + "kube_janitor.janitor.utcnow", + return_value=datetime.datetime.strptime( + "2019-03-11T11:10:09Z", "%Y-%m-%dT%H:%M:%SZ" + ), + ) + @unittest.mock.patch("kube_janitor.janitor.add_notification_flag") + def test_handle_resource_expiry_annotation_with_notification_not_triggered( + self, mocked_add_notification_flag, mocked_utcnow + ): # Resource was created: 2019-03-11T11:05:00Z # Expire is set to: 2019-03-11T11:15:00Z # Current datetime is: 2019-03-11T11:10:09Z # Notification is 3 minutes: 180s. Has to notify after: 2019-03-11T11:12:00Z - resource = Namespace(None, {'metadata': {'name': 'foo', 'annotations': { - 'janitor/expires': '2019-03-11T11:15:00Z'}, 'creationTimestamp': '2019-03-11T11:05:00Z'}}) + resource = Namespace( + None, + { + "metadata": { + "name": "foo", + "annotations": {"janitor/expires": "2019-03-11T11:15:00Z"}, + "creationTimestamp": "2019-03-11T11:05:00Z", + } + }, + ) delete_notification = 180 handle_resource_on_expiry(resource, [], delete_notification, dry_run=True) mocked_add_notification_flag.assert_not_called() - @unittest.mock.patch('kube_janitor.janitor.utcnow', return_value=datetime.datetime.strptime('2019-03-11T11:12:09Z', '%Y-%m-%dT%H:%M:%SZ')) - @unittest.mock.patch('kube_janitor.janitor.add_notification_flag') - def test_handle_resource_expiry_annotation_with_notification_triggered(self, mocked_add_notification_flag, mocked_utcnow): + @unittest.mock.patch( + "kube_janitor.janitor.utcnow", + return_value=datetime.datetime.strptime( + "2019-03-11T11:12:09Z", "%Y-%m-%dT%H:%M:%SZ" + ), + ) + @unittest.mock.patch("kube_janitor.janitor.add_notification_flag") + def test_handle_resource_expiry_annotation_with_notification_triggered( + self, mocked_add_notification_flag, mocked_utcnow + ): # Resource was created: 2019-03-11T11:05:00Z # Expire is set to: 2019-03-11T11:15:00Z # Current datetime is: 2019-03-11T11:10:09Z # Notification is 3 minutes: 180s. Has to notify after: 2019-03-11T11:12:00Z - resource = Namespace(None, {'metadata': {'name': 'foo', 'annotations': { - 'janitor/expires': '2019-03-11T11:15:00Z'}, 'creationTimestamp': '2019-03-11T11:05:00Z'}}) + resource = Namespace( + None, + { + "metadata": { + "name": "foo", + "annotations": {"janitor/expires": "2019-03-11T11:15:00Z"}, + "creationTimestamp": "2019-03-11T11:05:00Z", + } + }, + ) delete_notification = 180 handle_resource_on_expiry(resource, [], delete_notification, dry_run=True) mocked_add_notification_flag.assert_called() - @unittest.mock.patch('kube_janitor.janitor.utcnow', return_value=datetime.datetime.strptime('2019-03-11T11:12:09Z', '%Y-%m-%dT%H:%M:%SZ')) - @unittest.mock.patch('kube_janitor.janitor.create_event') - def test_handle_resource_expiry_annotation_notification_event(self, mocked_create_event, mocked_utcnow): + @unittest.mock.patch( + "kube_janitor.janitor.utcnow", + return_value=datetime.datetime.strptime( + "2019-03-11T11:12:09Z", "%Y-%m-%dT%H:%M:%SZ" + ), + ) + @unittest.mock.patch("kube_janitor.janitor.create_event") + def test_handle_resource_expiry_annotation_notification_event( + self, mocked_create_event, mocked_utcnow + ): # Resource was created: 2019-03-11T11:05:00Z # Expire is set to: 2019-03-11T11:15:00Z # Current datetime is: 2019-03-11T11:10:09Z # Notification is 3 minutes: 180s. Has to notify after: 2019-03-11T11:12:00Z - resource = MockedNamespace(None, {'metadata': {'name': 'foo', 'annotations': { - 'janitor/expires': '2019-03-11T11:15:00Z'}, 'creationTimestamp': '2019-03-11T11:05:00Z'}}) + resource = MockedNamespace( + None, + { + "metadata": { + "name": "foo", + "annotations": {"janitor/expires": "2019-03-11T11:15:00Z"}, + "creationTimestamp": "2019-03-11T11:05:00Z", + } + }, + ) delete_notification = 180 handle_resource_on_expiry(resource, [], delete_notification, dry_run=True) - expire = datetime.datetime.strptime('2019-03-11T11:15:00Z', '%Y-%m-%dT%H:%M:%SZ') - formatted_expire_datetime = expire.strftime('%Y-%m-%dT%H:%M:%SZ') - reason = 'annotation janitor/expires is set' - message = f'{resource.kind} {resource.name} will be deleted at {formatted_expire_datetime} ({reason})' - mocked_create_event.assert_called_with(resource, message, 'DeleteNotification', dry_run=True) + expire = datetime.datetime.strptime( + "2019-03-11T11:15:00Z", "%Y-%m-%dT%H:%M:%SZ" + ) + formatted_expire_datetime = expire.strftime("%Y-%m-%dT%H:%M:%SZ") + reason = "annotation janitor/expires is set" + message = f"{resource.kind} {resource.name} will be deleted at {formatted_expire_datetime} ({reason})" + mocked_create_event.assert_called_with( + resource, message, "DeleteNotification", dry_run=True + ) diff --git a/tests/test_format_duration.py b/tests/test_format_duration.py index 76f432e..12229a3 100644 --- a/tests/test_format_duration.py +++ b/tests/test_format_duration.py @@ -2,11 +2,11 @@ def test_format_duration(): - assert format_duration(-1) == '-1s' - assert format_duration(0) == '0s' - assert format_duration(1) == '1s' - assert format_duration(61) == '1m1s' - assert format_duration(3600) == '1h' - assert format_duration(3900) == '1h5m' - assert format_duration(3600*25) == '1d1h' - assert format_duration(3600*24*14) == '2w' + assert format_duration(-1) == "-1s" + assert format_duration(0) == "0s" + assert format_duration(1) == "1s" + assert format_duration(61) == "1m1s" + assert format_duration(3600) == "1h" + assert format_duration(3900) == "1h5m" + assert format_duration(3600 * 25) == "1d1h" + assert format_duration(3600 * 24 * 14) == "2w" diff --git a/tests/test_main.py b/tests/test_main.py index 5787342..4995607 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -9,8 +9,9 @@ @pytest.fixture def kubeconfig(tmpdir): - kubeconfig = tmpdir.join('kubeconfig') - kubeconfig.write(''' + kubeconfig = tmpdir.join("kubeconfig") + kubeconfig.write( + """ apiVersion: v1 clusters: - cluster: {server: 'https://localhost:9443'} @@ -24,17 +25,18 @@ def kubeconfig(tmpdir): users: - name: test user: {token: test} - ''') + """ + ) return kubeconfig def test_main_no_rules(kubeconfig, monkeypatch): - monkeypatch.setattr(os.path, 'expanduser', lambda x: str(kubeconfig)) + monkeypatch.setattr(os.path, "expanduser", lambda x: str(kubeconfig)) mock_clean_up = MagicMock() - monkeypatch.setattr('kube_janitor.main.clean_up', mock_clean_up) + monkeypatch.setattr("kube_janitor.main.clean_up", mock_clean_up) - main(['--dry-run', '--once']) + main(["--dry-run", "--once"]) mock_clean_up.assert_called_once() @@ -43,18 +45,18 @@ def test_main_with_rules(tmpdir, kubeconfig, monkeypatch): p = tmpdir.join("rules.yaml") p.write("rules: []") - monkeypatch.setattr(os.path, 'expanduser', lambda x: str(kubeconfig)) + monkeypatch.setattr(os.path, "expanduser", lambda x: str(kubeconfig)) mock_clean_up = MagicMock() - monkeypatch.setattr('kube_janitor.main.clean_up', mock_clean_up) + monkeypatch.setattr("kube_janitor.main.clean_up", mock_clean_up) - main(['--dry-run', '--once', f'--rules-file={p}']) + main(["--dry-run", "--once", f"--rules-file={p}"]) mock_clean_up.assert_called_once() def test_main_continue_on_failure(kubeconfig, monkeypatch): - monkeypatch.setattr(os.path, 'expanduser', lambda x: str(kubeconfig)) + monkeypatch.setattr(os.path, "expanduser", lambda x: str(kubeconfig)) mock_shutdown = MagicMock() mock_handler = MagicMock() @@ -66,13 +68,13 @@ def test_main_continue_on_failure(kubeconfig, monkeypatch): def mock_clean_up(*args, **kwargs): calls.append(args) if len(calls) == 1: - raise Exception('clean up fails on first run') + raise Exception("clean up fails on first run") elif len(calls) == 2: mock_handler.shutdown_now = True - monkeypatch.setattr('kube_janitor.main.clean_up', mock_clean_up) - monkeypatch.setattr('kube_janitor.main.shutdown', mock_shutdown) + monkeypatch.setattr("kube_janitor.main.clean_up", mock_clean_up) + monkeypatch.setattr("kube_janitor.main.shutdown", mock_shutdown) - main(['--dry-run', '--interval=0']) + main(["--dry-run", "--interval=0"]) assert len(calls) == 2 diff --git a/tests/test_parse_expiry.py b/tests/test_parse_expiry.py index 23fc254..d57b0d0 100644 --- a/tests/test_parse_expiry.py +++ b/tests/test_parse_expiry.py @@ -11,10 +11,12 @@ def test_parse_expiry_validate_input_string(): def test_parse_expiry_output_type(): - assert type(parse_expiry('2019-02-25T09:26:14Z')).__name__ == 'datetime' + assert type(parse_expiry("2019-02-25T09:26:14Z")).__name__ == "datetime" def test_parse_expiry_output_value_is_correct(): - assert parse_expiry('2008-09-26T01:51:42Z') == datetime.datetime(2008, 9, 26, 1, 51, 42) - assert parse_expiry('2008-09-26T01:51') == datetime.datetime(2008, 9, 26, 1, 51, 0) - assert parse_expiry('2008-09-26') == datetime.datetime(2008, 9, 26, 0, 0, 0) + assert parse_expiry("2008-09-26T01:51:42Z") == datetime.datetime( + 2008, 9, 26, 1, 51, 42 + ) + assert parse_expiry("2008-09-26T01:51") == datetime.datetime(2008, 9, 26, 1, 51, 0) + assert parse_expiry("2008-09-26") == datetime.datetime(2008, 9, 26, 0, 0, 0) diff --git a/tests/test_parse_ttl.py b/tests/test_parse_ttl.py index 1a286d4..804f293 100644 --- a/tests/test_parse_ttl.py +++ b/tests/test_parse_ttl.py @@ -5,16 +5,16 @@ def test_parse_ttl(): with pytest.raises(ValueError): - parse_ttl('foo') + parse_ttl("foo") with pytest.raises(ValueError): - parse_ttl('1y') + parse_ttl("1y") - assert parse_ttl('1s') == 1 - assert parse_ttl('08s') == 8 - assert parse_ttl('5m') == 300 - assert parse_ttl('2h') == 3600*2 - assert parse_ttl('7d') == 3600*24*7 - assert parse_ttl('1w') == 3600*24*7 + assert parse_ttl("1s") == 1 + assert parse_ttl("08s") == 8 + assert parse_ttl("5m") == 300 + assert parse_ttl("2h") == 3600 * 2 + assert parse_ttl("7d") == 3600 * 24 * 7 + assert parse_ttl("1w") == 3600 * 24 * 7 - assert parse_ttl('forever') < 0 + assert parse_ttl("forever") < 0 diff --git a/tests/test_rules.py b/tests/test_rules.py index eade7aa..24d3a75 100644 --- a/tests/test_rules.py +++ b/tests/test_rules.py @@ -20,52 +20,68 @@ def test_load_rules_from_empty_file(tmpdir): def test_load_rules_from_file_no_mapping(tmpdir): p = tmpdir.join("missing-keys.yaml") - p.write('''rules: + p.write( + """rules: - foo - bar - ''') + """ + ) with pytest.raises(TypeError): load_rules_from_file(str(p)) def test_load_rules_from_file_missing_keys(tmpdir): p = tmpdir.join("missing-keys.yaml") - p.write('''rules: + p.write( + """rules: - resources: [foos, bars] jmespath: a.b.c ttl: 5m - ''') + """ + ) with pytest.raises(TypeError): load_rules_from_file(str(p)) def test_load_rules_from_file(tmpdir): p = tmpdir.join("rules.yaml") - p.write('''rules: + p.write( + """rules: - id: rule-1 resources: [foos, bars] jmespath: a.b.c ttl: 5m - ''') + """ + ) load_rules_from_file(str(p)) def test_rule_invalid_id(): with pytest.raises(ValueError): - Rule.from_entry({'id': 'X', 'resources': [], 'jmespath': 'a.b', 'ttl': '1s'}) + Rule.from_entry({"id": "X", "resources": [], "jmespath": "a.b", "ttl": "1s"}) def test_rule_matches(): - rule = Rule.from_entry({'id': 'test', 'resources': ['deployments'], 'jmespath': 'metadata.labels.app', 'ttl': '30m'}) - resource = Deployment(None, {'metadata': {'namespace': 'ns-1', 'name': 'deploy-1'}}) + rule = Rule.from_entry( + { + "id": "test", + "resources": ["deployments"], + "jmespath": "metadata.labels.app", + "ttl": "30m", + } + ) + resource = Deployment(None, {"metadata": {"namespace": "ns-1", "name": "deploy-1"}}) assert not rule.matches(resource) - resource.obj['metadata']['labels'] = {'app': ''} + resource.obj["metadata"]["labels"] = {"app": ""} assert not rule.matches(resource) - resource.obj['metadata']['labels']['app'] = 'foobar' + resource.obj["metadata"]["labels"]["app"] = "foobar" assert rule.matches(resource) - resource = StatefulSet(None, {'metadata': {'namespace': 'ns-1', 'name': 'ss-1'}}) + resource = StatefulSet(None, {"metadata": {"namespace": "ns-1", "name": "ss-1"}}) assert not rule.matches(resource) - resource = StatefulSet(None, {'metadata': {'namespace': 'ns-1', 'name': 'ss-1', 'labels': {'app': 'x'}}}) + resource = StatefulSet( + None, + {"metadata": {"namespace": "ns-1", "name": "ss-1", "labels": {"app": "x"}}}, + ) assert not rule.matches(resource) diff --git a/tests/test_shutdown.py b/tests/test_shutdown.py index d991896..f4dfcb8 100644 --- a/tests/test_shutdown.py +++ b/tests/test_shutdown.py @@ -15,7 +15,7 @@ def test_graceful_shutdown(monkeypatch): assert handler.shutdown_now mock_exit = MagicMock() - monkeypatch.setattr('sys.exit', mock_exit) + monkeypatch.setattr("sys.exit", mock_exit) with handler.safe_exit(): handler.exit_gracefully(None, None) diff --git a/tox.ini b/tox.ini deleted file mode 100644 index 4a1a18d..0000000 --- a/tox.ini +++ /dev/null @@ -1,14 +0,0 @@ -[tox] -envlist=py37,flake8 -skip_missing_interpreters=true - -[testenv] -deps=pytest -commands= python setup.py test - -[testenv:flake8] -deps=flake8 -commands=python setup.py flake8 - -[flake8] -max-line-length=160