├── kcleaner.rb ├── _config.yml ├── .DS_Store ├── requirements.txt ├── render1557878856917.gif ├── codecov.yml ├── dockerfile ├── tests ├── kcleaner_main_test.py ├── kcleaner_cli_test.py ├── kcleaner_ask_yn_test.py ├── kcleaner_file_exists_test.py ├── kcleaner_check_and_cleanup_backups_test.py ├── kcleaner_test.py ├── sampleConfig ├── kcleaner_update_file_test.py └── kcleaner_remove_resource_test.py ├── .gitignore ├── setup.py ├── .github ├── dependabot.yml └── workflows │ └── pythonapp.yml ├── LICENSE ├── README.md └── kcleaner.py /kcleaner.rb: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-tactile -------------------------------------------------------------------------------- /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gcarrarom/kubeconfig-cleaner-cli/HEAD/.DS_Store -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | click 2 | mock 3 | pathlib 4 | iterfzf 5 | datetime 6 | PyYAML 7 | testfixtures -------------------------------------------------------------------------------- /render1557878856917.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gcarrarom/kubeconfig-cleaner-cli/HEAD/render1557878856917.gif -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | ignore: 2 | - "setup.py" # ignore folders and all its contents 3 | - "tests/*" # wildcards accepted 4 | -------------------------------------------------------------------------------- /dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3 2 | 3 | WORKDIR /usr/src/app 4 | 5 | COPY requirements.txt ./ 6 | RUN pip install --no-cache-dir -r requirements.txt 7 | 8 | COPY . . -------------------------------------------------------------------------------- /tests/kcleaner_main_test.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function 2 | import click 3 | from click.testing import CliRunner 4 | from kcleaner import cli 5 | 6 | runner = CliRunner() 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | config 2 | .vscode/** 3 | kubeconfig_cleaner.egg-info/** 4 | venv/** 5 | __pycache__/** 6 | .pytest_cache/** 7 | test 8 | .coverage 9 | pytestdebug.log 10 | tests/__pycache__ 11 | coverage.xml 12 | htmlcov 13 | demo.yml 14 | tests/*.bak 15 | -------------------------------------------------------------------------------- /tests/kcleaner_cli_test.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function 2 | from kcleaner import cli 3 | from testfixtures import log_capture 4 | import click 5 | from click.testing import CliRunner 6 | import pytest 7 | import yaml 8 | 9 | #cli(resource, name, kubeconfig, undo, debug) 10 | 11 | runner = CliRunner() 12 | sample_yaml = """ 13 | something: 14 | alist: 15 | - item1: test 16 | something: test 17 | - item2: test 18 | something: test 19 | """ 20 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | setup( 4 | name='kcleaner', 5 | version='0.3.3', 6 | author='Gui Martins', 7 | url='https://fancywhale.ca/', 8 | author_email='gui.martins.94@outlook.com', 9 | packages=find_packages(), 10 | include_package_data=True, 11 | py_modules=['kcleaner'], 12 | install_requires=[ 13 | 'Click', 14 | 'iterfzf', 15 | 'pyyaml' 16 | ], 17 | entry_points=''' 18 | [console_scripts] 19 | kcleaner=kcleaner:cli 20 | ''', 21 | ) 22 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "pip" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "daily" 12 | -------------------------------------------------------------------------------- /tests/kcleaner_ask_yn_test.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function 2 | from kcleaner import ask_yn 3 | import kcleaner 4 | from testfixtures import log_capture 5 | import click 6 | from click.testing import CliRunner 7 | import pytest 8 | import yaml 9 | from io import StringIO 10 | import json 11 | 12 | runner = CliRunner() 13 | 14 | @log_capture() 15 | def test_ask_yn_more_than_2_wrong_answers(capture, monkeypatch): 16 | with runner.isolated_filesystem(): 17 | number_inputs = StringIO('a\na\na\na\n') 18 | monkeypatch.setattr('sys.stdin', number_inputs) 19 | 20 | response = ask_yn("some question") 21 | 22 | assert response == 'n' 23 | 24 | 25 | @log_capture() 26 | def test_ask_yn_1_wrong_and_1_right_answer(capture, monkeypatch): 27 | with runner.isolated_filesystem(): 28 | number_inputs = StringIO('a\ny\n') 29 | monkeypatch.setattr('sys.stdin', number_inputs) 30 | 31 | response = ask_yn("some question") 32 | 33 | assert response == "y" 34 | 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 gcarrarom 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /tests/kcleaner_file_exists_test.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function 2 | from kcleaner import file_exists 3 | from testfixtures import log_capture 4 | import click 5 | from click.testing import CliRunner 6 | import pytest 7 | 8 | runner = CliRunner() 9 | 10 | @log_capture() 11 | def test_no_parameters(capture): 12 | 13 | with pytest.raises(SystemExit) as pytest_wrapped_e: 14 | file_exists(None) 15 | 16 | assert pytest_wrapped_e.value.code == 20 17 | capture.check_present( 18 | ('root', 'ERROR', "Filename cannot be 'None'") 19 | ) 20 | 21 | @log_capture() 22 | def test_empty_string(capture): 23 | 24 | with pytest.raises(SystemExit) as pytest_wrapped_e: 25 | file_exists("") 26 | 27 | assert pytest_wrapped_e.value.code == 21 28 | capture.check_present( 29 | ('root', 'ERROR', "Filename cannot be empty!") 30 | ) 31 | 32 | @log_capture() 33 | def test_existing_file(capture): 34 | with runner.isolated_filesystem(): 35 | with open('./config', 'w') as f: 36 | f.write('lololol') 37 | 38 | exists = file_exists("./config") 39 | 40 | assert exists == True 41 | capture.check_present( 42 | ('root', 'DEBUG', 'File exists!') 43 | ) 44 | 45 | @log_capture() 46 | def test_non_existing_file(capture): 47 | with runner.isolated_filesystem(): 48 | 49 | exists = file_exists("./config") 50 | 51 | assert exists == False 52 | capture.check_present( 53 | ('root', 'INFO', 'Config File Not found!') 54 | ) 55 | 56 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![GitHub release](https://img.shields.io/github/release/gcarrarom/kubeconfig-cleaner-cli.svg) 2 | [![codecov](https://codecov.io/gh/gcarrarom/kubeconfig-cleaner-cli/branch/master/graph/badge.svg)](https://codecov.io/gh/gcarrarom/kubeconfig-cleaner-cli) 3 | ![PyPI - Downloads](https://img.shields.io/pypi/dm/kcleaner) 4 | # Table of contents 5 | - [Table of contents](#table-of-contents) 6 | - [Demo](#demo) 7 | - [Usage](#usage) 8 | - [Installation](#installation) 9 | 10 | 11 | # Demo 12 | I want to clean my Kube config file without having to open my config file ever again :) 13 | 14 |

15 | 16 |

17 | 18 | 19 | # Usage 20 | 21 | To use this CLI simply type: 22 | `kcleaner` 23 | This will prompt you to remove the context by using Fuzzy Search. 24 | If you want to clean another Kube config file, you should use the option `-k` or `--kube-config` with the path for your config file. 25 | If you want to remove clusters, you can too! Just call `kcleaner clusters` and voilá! 26 | What about users? Sure can! `kcleaner users` is here to help! 27 | To select more than one entry, just press tab. All the selected entries will be removed! 28 | 29 | If you know the name of the config entry you're going to remove, you can always use the `-n` or `--name` option to remove it. 30 | 31 | Here's the output of the help command `kcleaner --help`: 32 | ``` 33 | Usage: kcleaner.py [OPTIONS] [[users|clusters|contexts|token]] 34 | 35 | A little CLI tool to help keeping Config Files clean :) 36 | 37 | Options: 38 | -k, --kubeconfig TEXT path to the config file to clean 39 | -n, --name TEXT Name of the entry to remove 40 | -u, --undo Use this to roll back latest changes 41 | -d, --debug Use this to see debug level messages 42 | --help Show this message and exit. 43 | ``` 44 | 45 | # Installation 46 | 47 | To install using pip, simply run this command: 48 | 49 | `pip install kcleaner` 50 | 51 | ## Requirements 52 | Python 3.x 53 | -------------------------------------------------------------------------------- /tests/kcleaner_check_and_cleanup_backups_test.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function 2 | from kcleaner import check_and_cleanup_backups, backup_limit 3 | from testfixtures import log_capture 4 | import click 5 | from click.testing import CliRunner 6 | import pytest 7 | import yaml 8 | 9 | runner = CliRunner() 10 | sample_yaml = """ 11 | something: 12 | alist: 13 | - item1: test 14 | something: test 15 | - item2: test 16 | something: test 17 | """ 18 | 19 | @log_capture() 20 | def test_none_parameters(capture): 21 | 22 | with pytest.raises(SystemExit) as pytest_wrapped_e: 23 | check_and_cleanup_backups(None) 24 | 25 | assert pytest_wrapped_e.value.code == 40 26 | capture.check_present( 27 | ('root', 'ERROR', "Filename cannot be 'None'!") 28 | ) 29 | 30 | @log_capture() 31 | def test_bellow_backup_limit(capture): 32 | with runner.isolated_filesystem(): 33 | 34 | for i in range(backup_limit-1): 35 | with open(f'./something_{i}_kcleaner.bak', 'w') as f: 36 | f.write('lololol') 37 | 38 | check_and_cleanup_backups("./something") 39 | 40 | capture.check_present( 41 | ('root', 'DEBUG', 'We are bellow the backup limit, nothing to do here.') 42 | ) 43 | 44 | @log_capture() 45 | def test_1_over_backup_limit(capture): 46 | with runner.isolated_filesystem(): 47 | 48 | for i in range(backup_limit+1): 49 | with open(f'./something_{i}_kcleaner.bak', 'w') as f: 50 | f.write('lololol') 51 | 52 | check_and_cleanup_backups("./something") 53 | 54 | capture.check_present( 55 | ('root', 'DEBUG', 'Removing File something_0_kcleaner.bak') 56 | ) 57 | 58 | @log_capture() 59 | def test_2_over_backup_limit(capture): 60 | with runner.isolated_filesystem(): 61 | 62 | for i in range(backup_limit+2): 63 | with open(f'./something_{i:03}_kcleaner.bak', 'w') as f: 64 | f.write('lololol') 65 | 66 | check_and_cleanup_backups("./something") 67 | 68 | capture.check_present( 69 | ('root', 'DEBUG', 'Removing File something_000_kcleaner.bak'), 70 | ('root', 'DEBUG', 'Removing File something_001_kcleaner.bak') 71 | ) -------------------------------------------------------------------------------- /tests/kcleaner_test.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function 2 | import click 3 | from click.testing import CliRunner 4 | from kcleaner import cli 5 | from testfixtures import log_capture 6 | 7 | 8 | runner = CliRunner() 9 | 10 | @log_capture() 11 | def test_clean_non_existant_file(capture): 12 | 13 | results = runner.invoke(cli, ['-k', './non_existent_file']) 14 | assert results.exit_code == 10 15 | capture.check_present( 16 | ('root', 'DEBUG', 'Trying to retrieve contents of file ./non_existent_file'), 17 | ('root', 'DEBUG', 'checking if file ./non_existent_file exists...'), 18 | ('root', 'INFO', 'Config File Not found!'), 19 | ('root', 'ERROR', 'Cannot work with an empty file!, please check the path of your config file.'), 20 | ) 21 | 22 | @log_capture() 23 | def test_clean_empty_file(capture): 24 | with runner.isolated_filesystem(): 25 | with open('./config', 'w') as f: 26 | f.write('') 27 | 28 | result = runner.invoke(cli, ['-k', './config']) 29 | assert result.exit_code == 11 30 | capture.check_present( 31 | ('root', 'DEBUG', 'Trying to retrieve contents of file ./config'), 32 | ('root', 'DEBUG', 'checking if file ./config exists...'), 33 | ('root', 'DEBUG', 'File exists!'), 34 | ('root', 'DEBUG', "Type of the file contents: "), 35 | ('root', 'ERROR', "Config File is empty! Can't use it.") 36 | ) 37 | 38 | @log_capture() 39 | def test_clean_empty_file_debug(capture): 40 | with runner.isolated_filesystem(): 41 | with open('./config', 'w') as f: 42 | f.write('') 43 | 44 | result = runner.invoke(cli, ['-k', './config', '-d']) 45 | assert result.exit_code == 11 46 | capture.check_present( 47 | ('root', 'DEBUG', 'Running with Debug flag'), 48 | ('root', 'DEBUG', 'Trying to retrieve contents of file ./config'), 49 | ('root', 'DEBUG', 'checking if file ./config exists...'), 50 | ('root', 'DEBUG', 'File exists!'), 51 | ('root', 'DEBUG', "Type of the file contents: "), 52 | ('root', 'ERROR', "Config File is empty! Can't use it.") 53 | ) 54 | 55 | @log_capture() 56 | def test_clean_empty_file_undo(capture): 57 | with runner.isolated_filesystem(): 58 | with open('./config', 'w') as f: 59 | f.write('') 60 | 61 | result = runner.invoke(cli, ['-k', './config', '-u']) 62 | #assert result.exit_code == 11 63 | capture.check_present( 64 | ('root', 'INFO', 'Undo flag was set! checking for the backup file...') 65 | ) 66 | 67 | @log_capture() 68 | def test_non_valid_yaml(capture): 69 | with runner.isolated_filesystem(): 70 | with open('./config', 'w') as f: 71 | f.write('lololol') 72 | 73 | result = runner.invoke(cli, ['-k', './config']) 74 | assert result.exit_code == 12 75 | capture.check_present( 76 | ('root', 'DEBUG', 'checking if file ./config exists...'), 77 | ('root', 'DEBUG', 'File exists!'), 78 | ('root', 'ERROR', 'Config File is not a valid yaml file!'), 79 | ) -------------------------------------------------------------------------------- /.github/workflows/pythonapp.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a single version of Python 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: Python application 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | pull_request: 10 | branches: [ master ] 11 | 12 | jobs: 13 | build: 14 | if: "!contains(github.event.head_commit.message, 'skip ci')" 15 | strategy: 16 | matrix: 17 | python_version: [3.7,3.8] 18 | 19 | fail-fast: true 20 | runs-on: ubuntu-latest 21 | 22 | steps: 23 | - uses: actions/checkout@v2 24 | - name: Set up Python ${{ matrix.python_version }} 25 | uses: actions/setup-python@v1 26 | with: 27 | python-version: ${{ matrix.python_version }} 28 | - name: Install dependencies 29 | run: | 30 | python -m pip install --upgrade pip 31 | pip install -r requirements.txt 32 | - name: Lint with flake8 33 | run: | 34 | pip install flake8 35 | # stop the build if there are Python syntax errors or undefined names 36 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 37 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 38 | flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 39 | - name: Test with pytest 40 | run: | 41 | pip install pytest pytest-cov 42 | python -m pytest --cov=./ 43 | - name: Upload Code coverage 44 | run: | 45 | pip install codecov 46 | codecov --token=$CODECOV_TOKEN 47 | env: 48 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 49 | 50 | release: 51 | needs: [build] 52 | if: "!contains(github.event.head_commit.message, 'skip ci') && github.event_name == 'push' && github.ref == 'refs/heads/master' && !contains(github.event.head_commit.message, 'skip cd')" 53 | runs-on: ubuntu-latest 54 | steps: 55 | - uses: actions/checkout@v2 56 | - name: Retrieving the Version 57 | run: | 58 | echo "Getting version from the setup.py file..." 59 | ApplicationVersion=$(cat setup.py | grep version | cut -d '=' -f 2 | cut -d "'" -f 2) 60 | echo "setup.py file version: $ApplicationVersion" 61 | echo "Testing if this version already exists in GitHub..." 62 | echo "Get the tags first..." 63 | tags=$(curl https://api.github.com/repos/gcarrarom/kubeconfig-cleaner-cli/tags) 64 | echo "check if there's a match on the version.." 65 | Match=$(echo $tags | jq -r ".[] | select(.name == \"v$ApplicationVersion\")") 66 | 67 | if [[ -z "$Match" ]]; then 68 | echo "All good, this doesn't match any old versions" 69 | else 70 | echo "Nope, we have this already... try choosing another one ;)" 71 | exit 100 72 | fi 73 | echo "Version to be used: $ApplicationVersion" 74 | echo "::set-env name=RELEASE_VERSION::$ApplicationVersion" 75 | - name: Create Release 76 | id: create_release 77 | uses: actions/create-release@latest 78 | env: 79 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 80 | with: 81 | tag_name: v${{ env.RELEASE_VERSION }} 82 | release_name: Release v${{ env.RELEASE_VERSION }} 83 | draft: false 84 | prerelease: false 85 | - name: Pip Upload 86 | uses: onichandame/pip-upload-action@0.0.1 87 | with: 88 | username: gui.martins 89 | password: ${{secrets.PYPI_PASS}} 90 | -------------------------------------------------------------------------------- /tests/sampleConfig: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | clusters: 3 | - cluster: 4 | server: https://super.coolcluster.fancywhale.ca 5 | name: SuperCoolCluster 6 | - cluster: 7 | server: https://super.coolcluster.fancywhale.ca 8 | name: SuperCoolCluster2 9 | - cluster: 10 | server: https://super.coolcluster.fancywhale.ca 11 | name: SuperCoolCluster3 12 | - cluster: 13 | server: https://super.coolcluster.fancywhale.ca 14 | name: SuperCoolCluster4 15 | - cluster: 16 | server: https://super.coolcluster.fancywhale.ca 17 | name: SuperCoolCluster5 18 | - cluster: 19 | server: https://super.coolcluster.fancywhale.ca 20 | name: SuperCoolCluster6 21 | - cluster: 22 | server: https://super.coolcluster.fancywhale.ca 23 | name: SuperCoolCluster7 24 | - cluster: 25 | server: https://super.coolcluster.fancywhale.ca 26 | name: SuperCoolCluster8 27 | contexts: 28 | - context: 29 | cluster: SuperCoolCluster 30 | user: SuperCoolUserName 31 | name: SuperCoolContext1 32 | - context: 33 | cluster: SuperCoolCluster 34 | user: SuperCoolUserName 35 | name: SuperCoolContext2 36 | - context: 37 | cluster: SuperCoolCluster 38 | user: SuperCoolUserName 39 | name: SuperCoolContext3 40 | - context: 41 | cluster: SuperCoolCluster 42 | user: SuperCoolUserName 43 | name: SuperCoolContext4 44 | - context: 45 | cluster: SuperCoolCluster 46 | user: SuperCoolUserName 47 | name: SuperCoolContext5 48 | - context: 49 | cluster: SuperCoolCluster 50 | user: SuperCoolUserName 51 | name: SuperCoolContext6 52 | - context: 53 | cluster: SuperCoolCluster 54 | user: SuperCoolUserName 55 | name: SuperCoolContext7 56 | current-context: SuperCoolContext 57 | kind: Config 58 | preferences: {} 59 | users: 60 | - name: SuperCoolUserName1 61 | user: 62 | auth-provider: 63 | config: 64 | apiserver-id: some-id-that-makes-sense 65 | client-id: some-id-that-makes-sense 66 | tenant-id: some-id-that-makes-sense 67 | name: some-auth-provider 68 | - name: SuperCoolUserName2 69 | user: 70 | auth-provider: 71 | config: 72 | apiserver-id: some-id-that-makes-sense 73 | client-id: some-id-that-makes-sense 74 | tenant-id: some-id-that-makes-sense 75 | name: some-auth-provider 76 | - name: SuperCoolUserName3 77 | user: 78 | auth-provider: 79 | config: 80 | apiserver-id: some-id-that-makes-sense 81 | client-id: some-id-that-makes-sense 82 | tenant-id: some-id-that-makes-sense 83 | name: some-auth-provider 84 | - name: SuperCoolUserName4 85 | user: 86 | auth-provider: 87 | config: 88 | apiserver-id: some-id-that-makes-sense 89 | client-id: some-id-that-makes-sense 90 | tenant-id: some-id-that-makes-sense 91 | name: some-auth-provider 92 | - name: SuperCoolUserName5 93 | user: 94 | auth-provider: 95 | config: 96 | apiserver-id: some-id-that-makes-sense 97 | client-id: some-id-that-makes-sense 98 | tenant-id: some-id-that-makes-sense 99 | name: some-auth-provider 100 | - name: SuperCoolUserName6 101 | user: 102 | auth-provider: 103 | config: 104 | apiserver-id: some-id-that-makes-sense 105 | client-id: some-id-that-makes-sense 106 | tenant-id: some-id-that-makes-sense 107 | name: some-auth-provider 108 | - name: SuperCoolUserName7 109 | user: 110 | auth-provider: 111 | config: 112 | access-token: SomeRandomToken 113 | apiserver-id: some-id-that-makes-sense 114 | client-id: some-id-that-makes-sense 115 | expires-in: '3600' 116 | expires-on: '1559593884' 117 | refresh-token: SomeRandomRefreshToken 118 | tenant-id: some-id-that-makes-sense 119 | name: some-auth-provider 120 | - name: SuperCoolUserName8 121 | user: 122 | auth-provider: 123 | config: 124 | apiserver-id: some-id-that-makes-sense 125 | client-id: some-id-that-makes-sense 126 | tenant-id: some-id-that-makes-sense 127 | name: some-auth-provider 128 | -------------------------------------------------------------------------------- /tests/kcleaner_update_file_test.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function 2 | from kcleaner import update_file 3 | from testfixtures import log_capture 4 | import click 5 | from click.testing import CliRunner 6 | import pytest 7 | import yaml 8 | 9 | runner = CliRunner() 10 | sample_yaml = """ 11 | something: 12 | alist: 13 | - item1: test 14 | something: test 15 | - item2: test 16 | something: test 17 | """ 18 | sample_broken_yaml = """ 19 | text foobar 20 | number: 2 21 | """ 22 | 23 | @log_capture() 24 | def test_none_parameters(capture): 25 | 26 | with pytest.raises(SystemExit) as pytest_wrapped_e: 27 | update_file(None, None) 28 | 29 | assert pytest_wrapped_e.value.code == 20 30 | capture.check_present( 31 | ('root', 'ERROR', "Filename cannot be 'None'") 32 | ) 33 | 34 | @log_capture() 35 | def test_none_file_empty_content(capture): 36 | 37 | with pytest.raises(SystemExit) as pytest_wrapped_e: 38 | update_file(None, "") 39 | 40 | assert pytest_wrapped_e.value.code == 20 41 | capture.check_present( 42 | ('root', 'ERROR', "Filename cannot be 'None'") 43 | ) 44 | 45 | @log_capture() 46 | def test_empty_file_empty_content(capture): 47 | 48 | with pytest.raises(SystemExit) as pytest_wrapped_e: 49 | update_file("", "") 50 | 51 | assert pytest_wrapped_e.value.code == 21 52 | capture.check_present( 53 | ('root', 'ERROR', 'Filename cannot be empty!') 54 | ) 55 | 56 | @log_capture() 57 | def test_non_existant_file_none_content(capture): 58 | with runner.isolated_filesystem(): 59 | with pytest.raises(SystemExit) as pytest_wrapped_e: 60 | update_file("./config", None) 61 | 62 | assert pytest_wrapped_e.value.code == 30 63 | capture.check_present( 64 | ('root', 'ERROR', "Yaml Value cannot be 'None'!") 65 | ) 66 | 67 | @log_capture() 68 | def test_non_existant_file_empty_content(capture): 69 | with runner.isolated_filesystem(): 70 | with pytest.raises(SystemExit) as pytest_wrapped_e: 71 | update_file("./config", "") 72 | 73 | assert pytest_wrapped_e.value.code == 31 74 | capture.check_present( 75 | ('root', 'ERROR', 'Yaml Value cannot be empty!') 76 | ) 77 | 78 | @log_capture() 79 | def test_non_existant_file_not_yaml_content(capture): 80 | with runner.isolated_filesystem(): 81 | with pytest.raises(SystemExit) as pytest_wrapped_e: 82 | update_file("./config", "{{123lololol123}}") 83 | 84 | assert pytest_wrapped_e.value.code == 32 85 | capture.check_present( 86 | ('root', 'ERROR', 'Yaml value is not valid!') 87 | ) 88 | 89 | @log_capture() 90 | def test_existant_file_yaml_content(capture): 91 | with runner.isolated_filesystem(): 92 | with open('./config', 'w') as f: 93 | f.write('lololol') 94 | update_file("./config", sample_yaml) 95 | 96 | with open('./config', 'r') as f: 97 | result = yaml.safe_load(f) 98 | 99 | assert sample_yaml in result 100 | capture.check_present( 101 | ('root', 'DEBUG', 'Writing new yaml doc into the config file') 102 | ) 103 | 104 | @log_capture() 105 | def test_existant_file_dict_content(capture): 106 | with runner.isolated_filesystem(): 107 | with open('./config', 'w') as f: 108 | f.write('lololol') 109 | dictyaml = yaml.safe_load(sample_yaml) 110 | update_file("./config", dictyaml) 111 | 112 | with open('./config', 'r') as f: 113 | result = yaml.safe_load(f) 114 | 115 | capture.check_present( 116 | ('root', 'DEBUG', f'This is a dict yaml doc object. Should be fine to convert as yaml. Content:\n{dictyaml}'), 117 | ('root', 'DEBUG', 'Writing new yaml doc into the config file') 118 | ) 119 | 120 | @log_capture() 121 | def test_backup_file_yaml_content(capture): 122 | with runner.isolated_filesystem(): 123 | with open('./something_kcleaner.bak', 'w') as f: 124 | f.write('lololol') 125 | update_file("./something_kcleaner.bak", sample_yaml) 126 | 127 | with open('./something_kcleaner.bak', 'r') as f: 128 | result = yaml.safe_load(f) 129 | 130 | assert sample_yaml in result 131 | capture.check_present( 132 | ('root', 'DEBUG', 'Checking for all kcleaner backup files') 133 | ) -------------------------------------------------------------------------------- /tests/kcleaner_remove_resource_test.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function 2 | from kcleaner import remove_resource, get_file 3 | import kcleaner 4 | from testfixtures import log_capture 5 | import click 6 | from click.testing import CliRunner 7 | import pytest 8 | import yaml 9 | 10 | runner = CliRunner() 11 | sample_yaml_no_token = """ 12 | apiVersion: v1 13 | clusters: 14 | - cluster: 15 | server: https://super.coolcluster.fancywhale.ca 16 | name: SuperCoolCluster 17 | contexts: 18 | - context: 19 | cluster: SuperCoolCluster 20 | user: SuperCoolUserName 21 | name: SuperCoolContext1 22 | current-context: SuperCoolContext 23 | kind: Config 24 | preferences: {} 25 | users: 26 | - name: SuperCoolUserName1 27 | user: 28 | auth-provider: 29 | config: 30 | apiserver-id: some-id-that-makes-sense 31 | client-id: some-id-that-makes-sense 32 | tenant-id: some-id-that-makes-sense 33 | name: some-auth-provider 34 | """ 35 | sample_yaml_token = """ 36 | apiVersion: v1 37 | clusters: 38 | - cluster: 39 | server: https://super.coolcluster.fancywhale.ca 40 | name: SuperCoolCluster 41 | contexts: 42 | - context: 43 | cluster: SuperCoolCluster 44 | user: SuperCoolUserName 45 | name: SuperCoolContext1 46 | current-context: SuperCoolContext 47 | kind: Config 48 | preferences: {} 49 | users: 50 | - name: SuperCoolUserName1 51 | user: 52 | auth-provider: 53 | config: 54 | access-token: SomeRandomToken 55 | apiserver-id: some-id-that-makes-sense 56 | client-id: some-id-that-makes-sense 57 | expires-in: '3600' 58 | expires-on: '1559593884' 59 | refresh-token: SomeRandomRefreshToken 60 | tenant-id: some-id-that-makes-sense 61 | name: some-auth-provider 62 | """ 63 | 64 | @log_capture() 65 | def test_none_parameters(capture): 66 | 67 | with pytest.raises(SystemExit) as pytest_wrapped_e: 68 | remove_resource(None, None) 69 | 70 | assert pytest_wrapped_e.value.code == 50 71 | capture.check_present( 72 | ('root', 'ERROR', 'Config File cannot be "None"!'), 73 | ) 74 | 75 | @log_capture() 76 | def test_removing_type_none(capture): 77 | 78 | with pytest.raises(SystemExit) as pytest_wrapped_e: 79 | remove_resource("", None) 80 | 81 | assert pytest_wrapped_e.value.code == 50 82 | capture.check_present( 83 | ('root', 'ERROR', 'Removing type cannot be "None"!'), 84 | ) 85 | 86 | @log_capture() 87 | def test_config_file_empty(capture): 88 | 89 | with pytest.raises(SystemExit) as pytest_wrapped_e: 90 | remove_resource("", "") 91 | 92 | assert pytest_wrapped_e.value.code == 51 93 | capture.check_present( 94 | ('root', 'ERROR', 'Parameters cannot be empty!'), 95 | ) 96 | 97 | @log_capture() 98 | def test_remove_token_not_available(capture, monkeypatch): 99 | with runner.isolated_filesystem(): 100 | 101 | with open(f'./SampleConfigFile', 'w') as f: 102 | f.write(sample_yaml_no_token) 103 | 104 | config_file = get_file("./SampleConfigFile") 105 | 106 | monkeypatch.setattr("kcleaner.iterfzf", lambda listOfResources,multi: "") 107 | 108 | with pytest.raises(SystemExit) as pytest_wrapped_e: 109 | new_config = remove_resource(config_file, "token") 110 | 111 | assert pytest_wrapped_e.value.code == 52 112 | capture.check_present( 113 | ('root', 'ERROR', 'No resources to remove selected!'), 114 | ) 115 | 116 | 117 | @log_capture() 118 | def test_remove_token(capture, monkeypatch): 119 | name_to_remove = "SuperCoolUserName1" 120 | with runner.isolated_filesystem(): 121 | 122 | with open(f'./SampleConfigFile', 'w') as f: 123 | f.write(sample_yaml_token) 124 | with open(f'./SampleConfigFileToTest', 'w') as f: 125 | f.write(sample_yaml_no_token) 126 | 127 | config_file = get_file("./SampleConfigFile") 128 | config_to_test = get_file("./SampleConfigFileToTest") 129 | 130 | monkeypatch.setattr("kcleaner.iterfzf", lambda listOfResources,multi: f"{name_to_remove}") 131 | 132 | new_config = remove_resource(config_file, "token") 133 | 134 | assert new_config == config_to_test 135 | capture.check_present( 136 | ('root', 'DEBUG', f'Removing token information from the user(s) {name_to_remove}'), 137 | ('root', 'DEBUG', f'removing tokens from user {name_to_remove}'), 138 | ('root', 'DEBUG', 'Token Removed successfully!'), 139 | ) -------------------------------------------------------------------------------- /kcleaner.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from __future__ import print_function 3 | import os 4 | import logging 5 | import click 6 | import yaml 7 | from pathlib import Path 8 | from iterfzf import iterfzf 9 | import datetime 10 | 11 | backup_limit = 10 12 | backup_date_format = '%Y-%m-%d_%H-%M-%S' 13 | 14 | def ask_yn(yn_question, default='n'): 15 | tries = 0 16 | while True: 17 | response = input("%s (y/n)" % (yn_question)) 18 | tries = tries + 1 19 | if response in ['y', 'n']: 20 | break 21 | elif tries > 2: 22 | response = default 23 | break 24 | return response 25 | 26 | def check_and_cleanup_backups(filename): 27 | if filename == None: 28 | logging.error("Filename cannot be 'None'!") 29 | exit(40) 30 | logging.debug(f"Checking if there's not more than {backup_limit} backup files in this directory") 31 | dirpath = os.path.dirname(os.path.abspath(filename)) 32 | logging.debug(f"Getting all the files in {dirpath}") 33 | files = os.listdir(dirpath) 34 | logging.debug(f"These are all the files in the directory:\n{files}") 35 | logging.debug(f"Checking for all kcleaner backup files") 36 | files = [item for item in files if "kcleaner.bak" in item] 37 | logging.debug(f"These are the backup files in this folder:\n{files}") 38 | if len(files) > backup_limit: 39 | logging.info(f"Cleaning up excess of backup files - we have {len(files)} already... - Removing the {len(files) - backup_limit} oldest files") 40 | files.sort() 41 | for file in files[0:(len(files)-backup_limit)]: 42 | logging.debug(f"Removing File {file}") 43 | os.remove(f"{dirpath}/{file}") 44 | else: 45 | logging.debug(f'We are bellow the backup limit, nothing to do here.') 46 | 47 | def update_file(filename, yamldoc): 48 | if not file_exists(filename) and not "bak" in filename: 49 | logging.error("Cannot work with an empty file!, please check the path of your config file.") 50 | if "bak" in filename: 51 | check_and_cleanup_backups(filename) 52 | if yamldoc == None: 53 | logging.error("Yaml Value cannot be 'None'!") 54 | exit(30) 55 | elif yamldoc == "": 56 | logging.error("Yaml Value cannot be empty!") 57 | exit(31) 58 | logging.debug(f'Checking the type of the yamldoc: {type(yamldoc)}') 59 | if type(yamldoc) is not dict: 60 | try: 61 | yaml.safe_load(yamldoc) 62 | except: 63 | logging.exception("Yaml value is not valid!") 64 | exit(32) 65 | else: 66 | logging.debug(f"This is a dict yaml doc object. Should be fine to convert as yaml. Content:\n{yamldoc}") 67 | logging.debug(f"Opening write stream for file {filename}") 68 | with open(filename, 'w') as stream: 69 | try: 70 | logging.debug("Writing new yaml doc into the config file") 71 | yaml.dump(yamldoc, stream) 72 | except yaml.YAMLError as exc: 73 | logging.exception(f"Exception occured while trying to write Yaml file: {exc}") 74 | 75 | def get_file(filename): 76 | logging.debug(f'Trying to retrieve contents of file {filename}') 77 | if not file_exists(filename): 78 | logging.error("Cannot work with an empty file!, please check the path of your config file.") 79 | exit(10) 80 | with open(filename, 'r') as stream: 81 | try: 82 | config_file = yaml.safe_load(stream) 83 | except yaml.YAMLError as exc: 84 | logging.exception(f"Exception occured while trying to load Yaml file: {exc}") 85 | exit(13) 86 | logging.debug(f'File Contents\n{config_file}') 87 | logging.debug(f'Type of the file contents: {type(config_file)}') 88 | if config_file == None: 89 | logging.error("Config File is empty! Can't use it.") 90 | exit(11) 91 | elif type(config_file) == str: 92 | logging.error("Config File is not a valid yaml file!") 93 | exit(12) 94 | return config_file 95 | 96 | def file_exists(filename): 97 | logging.debug(f"checking if file {filename} exists...") 98 | if filename == None: 99 | logging.error("Filename cannot be 'None'") 100 | exit(20) 101 | elif filename == "": 102 | logging.error("Filename cannot be empty!") 103 | exit(21) 104 | exists = os.path.isfile(filename) 105 | if exists: 106 | logging.debug("File exists!") 107 | else: 108 | logging.info('Config File Not found!') 109 | return exists 110 | 111 | def get_backup(backup_path): 112 | logging.debug(f"Checking all backups available in the directory {backup_path}") 113 | files = os.listdir(backup_path) 114 | logging.debug(f"These are all the files in the directory:\n{files}") 115 | logging.debug(f"Checking for all kcleaner backup files") 116 | files = [item for item in files if "kcleaner.bak" in item] 117 | logging.debug(f"These are the backup files in this folder:\n{files}") 118 | files.sort(reverse=True) 119 | dates = [] 120 | for file in files: 121 | dates.append((datetime.datetime.strptime("_".join(file.split('_')[0:2]), backup_date_format).strftime("%c"))) 122 | logging.debug(dates) 123 | backup_to_use = iterfzf(dates) 124 | logging.debug(f'Backup chosen: {backup_to_use}') 125 | backup_file_to_use = f"{datetime.datetime.strptime(backup_to_use, '%c').strftime(backup_date_format)}_kcleaner.bak" 126 | logging.debug(f'Backup file: {backup_file_to_use}') 127 | return get_file(f"{backup_path}/{backup_file_to_use}") 128 | 129 | 130 | def remove_resource(config_file, removing_type): 131 | if config_file == None: 132 | logging.error(f'Config File cannot be "None"!') 133 | exit(50) 134 | if removing_type == None: 135 | logging.error(f'Removing type cannot be "None"!') 136 | exit(50) 137 | 138 | if removing_type == "" or config_file == "": 139 | logging.error(f'Parameters cannot be empty!') 140 | exit(51) 141 | 142 | if removing_type == 'token': 143 | removing_type = 'users' 144 | removing_token = True 145 | else: 146 | removing_token = False 147 | logging.debug(f"Started removal of {removing_type}") 148 | resources_name_list = [] 149 | logging.debug('gathering list of objects for the this resource type') 150 | if removing_token: 151 | for resource in config_file[removing_type]: 152 | try: 153 | logging.debug(f"{resource['user']['auth-provider']['config']['access-token']}") 154 | resources_name_list.append(resource['name']) 155 | except: 156 | continue 157 | else: 158 | for resource in config_file[removing_type]: 159 | resources_name_list.append(resource['name']) 160 | 161 | resources_to_remove = [] 162 | logging.debug('Prompting for selection') 163 | resources_to_remove = (iterfzf(resources_name_list, multi=True)) 164 | logging.debug('List of resources selected: {resources_to_remove}') 165 | if resources_to_remove == None or resources_to_remove == "": 166 | logging.error("No resources to remove selected!") 167 | exit(52) 168 | 169 | logging.debug(f"{len(config_file[removing_type])} {removing_type} before the removal") 170 | 171 | #TODO: Implement cross resource finding 172 | #response = ask_yn("Remove Related Resources?") 173 | #print(f"Your response = {response}") 174 | 175 | if removing_token: 176 | logging.debug(f"Removing token information from the user(s) {resources_to_remove}") 177 | for item in config_file[removing_type]: 178 | if item['name'] in resources_to_remove: 179 | logging.debug(f"removing tokens from user {item['name']}") 180 | item['user']['auth-provider']['config'].pop('access-token', None) 181 | item['user']['auth-provider']['config'].pop('expires-in', None) 182 | item['user']['auth-provider']['config'].pop('expires-on', None) 183 | item['user']['auth-provider']['config'].pop('refresh-token', None) 184 | logging.debug(f'Token Removed successfully!') 185 | 186 | else: 187 | try: 188 | logging.debug('Removing resources...') 189 | config_file[removing_type] = [item for item in config_file[removing_type] if item['name'] not in resources_to_remove] 190 | except KeyError: 191 | logging.exception(f"Something went wrong!!") 192 | 193 | logging.debug(f"{len(config_file[removing_type])} {removing_type} in the end") 194 | 195 | return config_file 196 | 197 | @click.command() 198 | @click.argument( 199 | "resource", 200 | type=click.Choice( 201 | [ 202 | 'users', 203 | 'clusters', 204 | 'contexts', 205 | 'token' 206 | ] 207 | ), 208 | default='contexts' 209 | ) 210 | @click.option( 211 | '--kubeconfig', '-k', default=f'{Path.home()}/.kube/config', 212 | help="path to the config file to clean" 213 | ) 214 | @click.option( 215 | '--name', '-n', 216 | help='Name of the entry to remove', 217 | ) 218 | @click.option( 219 | '--undo', '-u', 220 | help='Use this to roll back latest changes', 221 | is_flag=True 222 | ) 223 | @click.option( 224 | '--debug', '-d', 225 | help='Use this to see debug level messages', 226 | is_flag=True 227 | ) 228 | def cli(resource, name, kubeconfig, undo, debug): 229 | """ 230 | A little CLI tool to help keeping Config Files clean :) 231 | """ 232 | if debug: 233 | logging.basicConfig(level=logging.DEBUG, format='%(asctime)s - %(levelname)s - %(message)s') 234 | logging.debug('Running with Debug flag') 235 | else: 236 | logging.basicConfig(format='%(asctime)s - %(levelname)s - %(message)s') 237 | 238 | kubeconfig_dir = os.path.dirname(os.path.abspath(kubeconfig)) 239 | kubeconfig_backup = f"{kubeconfig_dir}/{datetime.datetime.now().strftime(backup_date_format)}_kcleaner.bak" 240 | 241 | if undo: 242 | logging.info(f"Undo flag was set! checking for the backup file...") 243 | logging.debug(f'Searching for backup config file {kubeconfig_backup}') 244 | config_file_after = get_backup(kubeconfig_dir) 245 | else: 246 | config_file_before = get_file(kubeconfig) 247 | logging.debug(f'Backing up config file at {kubeconfig_backup} before doing anything') 248 | update_file(kubeconfig_backup, config_file_before) 249 | logging.info(f'Using resource {resource}') 250 | logging.debug(f'Config file to use: {kubeconfig}') 251 | if name == None: 252 | logging.debug(f'Name is empty, using fzf to search for the resource to remove') 253 | else: 254 | logging.debug(f'Name of the resource requested to remove: {name}') 255 | config_file_after = remove_resource(config_file_before, resource) 256 | 257 | logging.debug(f"New Config file content: \n{config_file_after}") 258 | update_file(kubeconfig, config_file_after) 259 | 260 | if __name__ == '__main__': 261 | cli(obj={}) 262 | --------------------------------------------------------------------------------