├── .github ├── .kodiak.toml └── workflows │ ├── release.yaml │ ├── tag.yml │ └── test.yaml ├── .gitignore ├── .goreleaser.yml ├── LICENSE ├── Readme.md ├── cmd ├── cleanup.go ├── cleanup_test.go ├── completion.go ├── completion_test.go ├── delete.go ├── delete_test.go ├── import.go ├── import_test.go ├── namespace.go ├── namespace_test.go ├── root.go ├── set.go ├── set_test.go ├── shellwrapper.go ├── shellwrapper_test.go ├── version.go └── version_test.go ├── config └── config.go ├── demo.cast ├── doc ├── demo.gif └── demo_source.cast ├── go.mod ├── go.sum ├── konf ├── id.go ├── id_test.go ├── konfig.go ├── split.go └── split_test.go ├── log └── log.go ├── main.go ├── prompt ├── prompt.go └── prompt_test.go ├── store ├── error.go ├── store.go └── store_test.go ├── testhelper ├── shellwrapper.sh └── unit.go └── utils ├── dir.go └── dir_test.go /.github/.kodiak.toml: -------------------------------------------------------------------------------- 1 | version = 1 2 | 3 | [merge] 4 | automerge_label = ["merge-breaking", "merge-feature", "merge-fix", "merge-none"] 5 | blocking_labels = ["wip", "hold"] 6 | delete_branch_on_merge = true 7 | method = "rebase_fast_forward" 8 | 9 | [approve] 10 | # list of benevolant dictators ;) 11 | auto_approve_usernames = ["SimonTheLeg"] 12 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: release 2 | on: 3 | push: 4 | tags: 5 | - "v*.*.*" 6 | 7 | jobs: 8 | tag: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: checkout 12 | uses: actions/checkout@v3 13 | with: 14 | fetch-depth: 0 # we need this, so GoReleaser has access to the whole history for generating changelog 15 | - name: Setup go 16 | uses: actions/setup-go@v2 17 | with: 18 | go-version: 1.24.0 19 | - name: GoReleaser 20 | uses: goreleaser/goreleaser-action@v6 21 | with: 22 | version: 'v2.0.1' 23 | args: release --clean 24 | env: 25 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 26 | -------------------------------------------------------------------------------- /.github/workflows/tag.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - main 5 | 6 | jobs: 7 | new_semver_tag: 8 | permissions: 9 | contents: write 10 | pull-requests: read 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v3 14 | with: 15 | fetch-depth: "0" 16 | ssh-key: ${{ secrets.SEMVER_TAG_SSH_KEY }} 17 | - name: tag 18 | uses: simontheleg/semver-tag-from-pr-action@v1.4.0 19 | with: 20 | # due to https://github.community/t/github-actions-workflow-not-triggering-with-tag-push/17053/8 21 | # we have to actually use a deploy-key here, so that the release flows push.tags section is working 22 | repo_token: ${{ secrets.GITHUB_TOKEN }} 23 | label_major: merge-breaking 24 | label_minor: merge-feature 25 | label_patch: merge-fix 26 | label_none: merge-none 27 | repo_ssh_key: ${{ secrets.SEMVER_TAG_SSH_KEY}} 28 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: test 2 | on: push 3 | jobs: 4 | unit-test: 5 | strategy: 6 | matrix: 7 | go: [1.24.0] 8 | runs-on: ubuntu-latest 9 | name: unit -> go ${{ matrix.go }} 10 | steps: 11 | - uses: actions/checkout@v2 12 | - name: Setup go 13 | uses: actions/setup-go@v2 14 | with: 15 | go-version: ${{ matrix.go }} 16 | - name: Run test 17 | run: go test -short -cover -coverprofile=coverage.txt -covermode=atomic ./... 18 | integration-test: 19 | strategy: 20 | fail-fast: false 21 | matrix: 22 | os: [ubuntu-latest, macos-latest, windows-latest] 23 | go: [1.24.0] 24 | runs-on: ${{ matrix.os }} 25 | name: integration -> ${{ matrix.os }} / go ${{ matrix.go }} 26 | steps: 27 | - uses: actions/checkout@v2 28 | - name: Setup go 29 | uses: actions/setup-go@v2 30 | with: 31 | go-version: ${{ matrix.go }} 32 | # for now we only run Integration tests in utils. 33 | # Unfortunately we have to go this way, because if you use './...' it will try to build 34 | # the unittests for windows in ./cmd (even if there is no integration test there) and fail 35 | # in the build phase already with 'unknown field 'Setpgid' in struct literal of type syscall.SysProcAttr' 36 | - run: go test -run Integration ./utils 37 | shellwrapper-test: 38 | runs-on: ubuntu-latest 39 | name: shellwrapper 40 | steps: 41 | - uses: actions/checkout@v2 42 | - name: Setup go 43 | uses: actions/setup-go@v2 44 | with: 45 | go-version: 1.24.0 # for now we can keep this as a fixed version 46 | - name: Install zsh # until https://github.com/actions/virtual-environments/issues/4849 is resolved 47 | run: sudo apt-get update; sudo apt-get install zsh 48 | - name: Install konf-go 49 | run: go install . 50 | - name: zsh test 51 | run: zsh testhelper/shellwrapper.sh 52 | - name: bash test 53 | run: bash testhelper/shellwrapper.sh 54 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # File created using '.gitignore Generator' for Visual Studio Code: https://bit.ly/vscode-gig 2 | 3 | # Created by https://www.toptal.com/developers/gitignore/api/visualstudiocode,macos,go 4 | # Edit at https://www.toptal.com/developers/gitignore?templates=visualstudiocode,macos,go 5 | 6 | ### Go ### 7 | # Binaries for programs and plugins 8 | *.exe 9 | *.exe~ 10 | *.dll 11 | *.so 12 | *.dylib 13 | 14 | # Test binary, built with `go test -c` 15 | *.test 16 | 17 | # Output of the go coverage tool, specifically when used with LiteIDE 18 | *.out 19 | 20 | # Dependency directories (remove the comment below to include it) 21 | # vendor/ 22 | 23 | ### Go Patch ### 24 | /vendor/ 25 | /Godeps/ 26 | 27 | ### macOS ### 28 | # General 29 | .DS_Store 30 | .AppleDouble 31 | .LSOverride 32 | 33 | # Icon must end with two \r 34 | Icon 35 | 36 | 37 | # Thumbnails 38 | ._* 39 | 40 | # Files that might appear in the root of a volume 41 | .DocumentRevisions-V100 42 | .fseventsd 43 | .Spotlight-V100 44 | .TemporaryItems 45 | .Trashes 46 | .VolumeIcon.icns 47 | .com.apple.timemachine.donotpresent 48 | 49 | # Directories potentially created on remote AFP share 50 | .AppleDB 51 | .AppleDesktop 52 | Network Trash Folder 53 | Temporary Items 54 | .apdisk 55 | 56 | ### VisualStudioCode ### 57 | .vscode/* 58 | !.vscode/settings.json 59 | !.vscode/tasks.json 60 | !.vscode/launch.json 61 | !.vscode/extensions.json 62 | *.code-workspace 63 | 64 | # Local History for Visual Studio Code 65 | .history/ 66 | 67 | ### VisualStudioCode Patch ### 68 | # Ignore all local history of files 69 | .history 70 | .ionide 71 | 72 | # Support for Project snippet scope 73 | !.vscode/*.code-snippets 74 | 75 | # End of https://www.toptal.com/developers/gitignore/api/visualstudiocode,macos,go 76 | 77 | # Custom rules (everything added below won't be overriden by 'Generate .gitignore File' if you use 'Update' option) 78 | # ignore linux binary 79 | konf-go 80 | retro.md 81 | # for now there is not really anything interesting in the launch.json, so we can ignore it 82 | .vscode/launch.json 83 | 84 | # ignore builds from goreleaser 85 | dist -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | builds: 2 | - id: konf-go 3 | goos: 4 | - linux 5 | - darwin 6 | - windows 7 | goarch: 8 | - amd64 9 | - arm 10 | - arm64 11 | - ppc64le 12 | - s390x 13 | goarm: [6, 7] 14 | ldflags: 15 | - -s -w -X github.com/simontheleg/konf-go/cmd.gitversion={{.Version}} -X github.com/simontheleg/konf-go/cmd.gitcommit={{.Commit}} -X github.com/simontheleg/konf-go/cmd.builddate={{.Date}} 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | # Konf - Lightweight kubeconfig Manager 2 | 3 | [![Go Report Card](https://goreportcard.com/badge/github.com/simontheleg/konf-go)](https://goreportcard.com/report/github.com/simontheleg/konf-go) 4 | ![test](https://github.com/simontheleg/konf-go/actions/workflows/test.yaml/badge.svg) 5 | 6 | - [Konf - Lightweight kubeconfig Manager](#konf---lightweight-kubeconfig-manager) 7 | - [Why konf?](#why-konf) 8 | - [Installation](#installation) 9 | - [1. Install the konf-go binary](#1-install-the-konf-go-binary) 10 | - [2. Install the konf shellwrapper](#2-install-the-konf-shellwrapper) 11 | - [Customizations to Have a Good Time](#customizations-to-have-a-good-time) 12 | - [Usage](#usage) 13 | - [How does it work?](#how-does-it-work) 14 | - [kubeconfig management across shells](#kubeconfig-management-across-shells) 15 | - [zsh/bash-func-magic](#zshbash-func-magic) 16 | - [Upgrading spf13/cobra](#upgrading-spf13cobra) 17 | - [Contributing](#contributing) 18 | - [Usage of stdout and stderr](#usage-of-stdout-and-stderr) 19 | - [Tests](#tests) 20 | - [Ideas for Future Improvements](#ideas-for-future-improvements) 21 | 22 | ## Why konf? 23 | 24 | - konf allows you to quickly switch between different kubeconfig files 25 | - konf allows you to simultaneously use different kubeconfigs in different shells 26 | - konf executes directly in your current shell and does not start any subshell (unlike kubie). As a result it works extremely fast 27 | 28 | ![demo.gif](doc/demo.gif) 29 | 30 | ## Installation 31 | 32 | ### 1. Install the konf-go binary 33 | 34 | #### 1.1 Pre-compiled binary 35 | 36 | The [GH Releases](https://github.com/SimonTheLeg/konf-go/releases) provide pre-compiled binaries for common platforms. 37 | 38 | #### 1.2 Nix Package 39 | 40 | The package can be installed in the local nix-profile. 41 | 42 | ```shell 43 | nix-env -iA nixpkgs.konf 44 | ``` 45 | 46 | For adhoc or testing purposes a shell with the package can be spawned. 47 | 48 | ```shell 49 | nix-shell -p konf 50 | ``` 51 | 52 | For NixOS users it is highly recommended to install the package by adding it to the list of `systemPackages`. 53 | 54 | ```nix 55 | { # ... 56 | 57 | environment.systemPackages = with pkgs; [ 58 | konf 59 | # ... 60 | ]; 61 | } 62 | ``` 63 | 64 | #### 1.3 Building from source 65 | 66 | ```shell 67 | go install github.com/simontheleg/konf-go@latest 68 | ``` 69 | 70 | Please do not rename or alias this binary, it is not be called by the user directly. Instead alias the konf shellwrapper described in the next step! 71 | 72 | ### 2. Install the konf shellwrapper 73 | 74 | Depending on whether you are using zsh/bash or fish, please use the following: 75 | 76 | #### A) zsh/bash 77 | 78 | Add the following to your `.zshrc` / `.bashrc` and restart your shell or re-source this file: 79 | 80 | ```sh 81 | # Currently supported shells: zsh, bash 82 | source <(konf-go shellwrapper zsh) 83 | ``` 84 | 85 | #### B) fish 86 | 87 | Add the following to your `config.fish` and restart your shell or re-source this file: 88 | 89 | ```sh 90 | konf-go shellwrapper fish | source 91 | ``` 92 | 93 | This will install a shellwrapper called `konf`, which you can use like any command. The wrapper can also be aliased if need be. 94 | 95 | ### Customizations to Have a Good Time 96 | 97 | A collection of optional settings to improve quality of life with konf. 98 | 99 | #### A) zsh/bash 100 | 101 | These can be added to your `.zshrc` / `.bashrc`: 102 | 103 | ```sh 104 | # Autocompletion. Currently supported shells: zsh, bash 105 | source <(konf completion zsh) 106 | 107 | # Open last konf on new shell session 108 | konf --silent set - 109 | 110 | # Alias 111 | alias kctx="konf set" 112 | alias kns="konf ns" 113 | ``` 114 | 115 | #### B) fish 116 | 117 | These can be added to your `config.fish`: 118 | 119 | ```sh 120 | # Autocompletion 121 | konf completion fish | source 122 | 123 | # Open last konf on new shell session 124 | set -x KUBECONFIG (konf --silent set -) 125 | 126 | # Alias 127 | abbr --add --global -- kctx 'konf set' 128 | abbr --add --global -- kns 'konf ns' 129 | ``` 130 | 131 | ## Usage 132 | 133 | Before any kubeconfig can be used with konf you have to import it: 134 | 135 | ```sh 136 | konf import 137 | ``` 138 | 139 | This is required, because konf maintains its own store of kubeconfigs to be able to work its "no-additional-shell-required"-magic. 140 | 141 | Afterwards you can quickly switch between konfs using either: 142 | 143 | ```sh 144 | konf set # will open a picker dialogue 145 | konf set - # will open the last konf 146 | konf set # will set a specific konf. is usually _ 147 | ``` 148 | 149 | Additional commands and flags can be seen by calling `konf --help` 150 | 151 | ## How does it work? 152 | 153 | ### kubeconfig management across shells 154 | 155 | Essentially konf maintains its state via two directories: 156 | 157 | - `/store` -> contains all of your imported kubeconfigs, where each context is split into its own file 158 | - `/active` -> contains all currently active konfs. The filename refers to the PID of the shell. Konf will automatically clean unused files after you close the session 159 | 160 | We need these two extra directories because: 161 | 162 | - each konf file must only contain one context. This is because konf can only use the `$KUBECONFIG` variable to point to one kubeconfig file. If there are multiple contexts in that file, kubernetes looks for a `current-context` key and sets the config to that, thus introducing some ambiguity. To avoid this, konf import splits all the contexts into separate files 163 | - in order to allow for different shells to have different kubeconfigs we need to maintain a single one per shell. Otherwise when you run modifications like changing the namespace, these would affect all shells, which is not what we want 164 | 165 | ### zsh/bash-func-magic 166 | 167 | One of the largest difficulties in this project lies in the core design of the shell. 168 | Essentially a child process cannot make modifications to its parents. 169 | This includes setting an environment variable, which affects us because we want to set `$KUBECONFIG`. 170 | The way we work around this "limitation" is by using a zsh/bash function that executes our binary and then sets `$KUBECONFIG` to the output of `konf-go`. 171 | With this trick we are able to set `$KUBECONFIG` and can make this project work. Since only the result of stdout will be captured by the zsh/bash-func, we can still communicate normally with the user by using stderr. 172 | 173 | ## Contributing 174 | 175 | ### Usage of stdout and stderr 176 | 177 | When developing for konf, it is important to understand the konf-shellwrapper. It is designed to solve the problem described in the [zsh/bash-func-magic section](###zsh/bash-func-magic). 178 | It works by saving the stdOut of konf-go in a separate variable and then evaluating the result. Should the result contain the keyword `KUBECONFIGCHANGE:`, the wrapper will set `$KUBECONFIG` to the value after the colon. 179 | Otherwise the wrapper ist just going to print the result to stdOut in the terminal. This setup allows for konf-go commands to print to stdOut (which is required for example for zsh/bash completion). Additionally it should be able to handle large stdOut outputs as well, as it only parses the first line of output. 180 | Interactive prompts however (like promptUI) should always print their dialogue to stdErr, as the wrapper has troubles with user input. Nonetheless you can still easily submit the result of the selection to the wrapper later on using the aforementioned keyword. So it should not be a big issue. 181 | 182 | ### Tests 183 | 184 | By default `go test ./...` will run both unit and integration tests. Integration tests are mainly used to check for filename validity and only write in the `/tmp/konf` directory. They are mainly being used by the CI. If you only want to run unit-test, you can do so by using the `-short` flag: 185 | 186 | ```sh 187 | go test -short ./... 188 | ``` 189 | 190 | If you want to only run integration tests, simply run: 191 | 192 | ```sh 193 | go test -run Integration ./... 194 | ``` 195 | 196 | ### Upgrading spf13/cobra 197 | 198 | Special care should be taken before upgrading the cobra package. 199 | This is due to the fact that in `completion.go` we use the standard completion from the library and then apply some string insertions at certain positions. 200 | As a result, before any upgrade of the package, it should be checked whether the GenXYZCompletion funcs from cobra have changed. 201 | Unfortunately I was not able to find a more elegant solution, so for now we just have to be vigilant when upgrading the dependency. 202 | -------------------------------------------------------------------------------- /cmd/cleanup.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "errors" 5 | "io/fs" 6 | "os" 7 | "strconv" 8 | 9 | "github.com/mitchellh/go-ps" 10 | "github.com/simontheleg/konf-go/config" 11 | "github.com/simontheleg/konf-go/konf" 12 | log "github.com/simontheleg/konf-go/log" 13 | "github.com/simontheleg/konf-go/store" 14 | "github.com/spf13/afero" 15 | "github.com/spf13/cobra" 16 | ) 17 | 18 | // cleanupCmd represents the cleanup command 19 | var cleanupCmd = &cobra.Command{ 20 | Use: "cleanup", 21 | Short: "Cleanup inactive kubeconfigs", 22 | Long: `This command cleans up any unused active configs (stored in konfDir/active). 23 | An active config is considered unused when no process points to it anymore`, 24 | RunE: func(cmd *cobra.Command, args []string) error { 25 | 26 | fs := afero.NewOsFs() 27 | sm := &store.Storemanager{Activedir: config.ActiveDir(), Storedir: config.StoreDir(), Fs: fs} 28 | 29 | err := cleanLeftOvers(sm) 30 | if err != nil { 31 | return err 32 | } 33 | 34 | err = selfClean(sm) 35 | if err != nil { 36 | return err 37 | } 38 | 39 | return nil 40 | }, 41 | } 42 | 43 | // selfClean should just find its parent process and delete that file 44 | // it is required as the idempotent clean would delete all files that 45 | // do not belong to any process anymore, but of course the current process 46 | // is still running at this time 47 | func selfClean(sm *store.Storemanager) error { 48 | pid := os.Getppid() 49 | 50 | konfID := konf.IDFromProcessID(pid) 51 | fpath := sm.ActivePathFromID(konfID) 52 | err := sm.Fs.Remove(fpath) 53 | 54 | if errors.Is(err, fs.ErrNotExist) { 55 | log.Info("current konf '%s' was already deleted, nothing to self-cleanup\n", fpath) 56 | return nil 57 | } 58 | 59 | if err != nil { 60 | return err 61 | } 62 | 63 | return nil 64 | } 65 | 66 | // cleanLeftOvers should look through the list of all processes that are available 67 | // and clean up any files that are not in use any more. It's main purpose is to clean-up 68 | // any leftovers that can occur if a previous session was not cleaned up nicely. This is 69 | // necessary as we cannot tell a user that a selfClean has failed if they close the shell 70 | // session before 71 | func cleanLeftOvers(sm *store.Storemanager) error { 72 | konfs, err := afero.ReadDir(sm.Fs, sm.Activedir) 73 | 74 | if err != nil { 75 | return err 76 | } 77 | 78 | for _, k := range konfs { 79 | // We need to trim of the .yaml file extension to get to the PID 80 | konfID := konf.IDFromFileInfo(k) 81 | pid, err := strconv.Atoi(string(konfID)) 82 | if err != nil { 83 | log.Warn("file '%s' could not be converted into an int, and therefore cannot be a valid process id. Skip for cleanup", k.Name()) 84 | continue 85 | } 86 | 87 | p, err := ps.FindProcess(pid) 88 | if err != nil { 89 | return err 90 | } 91 | 92 | if p == nil { 93 | err := sm.Fs.Remove(sm.ActivePathFromID(konfID)) 94 | if err != nil { 95 | return err 96 | } 97 | } 98 | } 99 | 100 | return nil 101 | } 102 | -------------------------------------------------------------------------------- /cmd/cleanup_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io/fs" 7 | "os" 8 | "os/exec" 9 | "syscall" 10 | "testing" 11 | 12 | "github.com/simontheleg/konf-go/konf" 13 | "github.com/simontheleg/konf-go/store" 14 | "github.com/simontheleg/konf-go/testhelper" 15 | "github.com/simontheleg/konf-go/utils" 16 | "github.com/spf13/afero" 17 | ) 18 | 19 | func TestSelfClean(t *testing.T) { 20 | activeDir := "./konf/active" 21 | storeDir := "./konf/store" 22 | 23 | ppid := os.Getppid() 24 | 25 | tt := map[string]struct { 26 | Fs afero.Fs 27 | ExpError error 28 | ExpFiles []string 29 | NotExpFiles []string 30 | }{ 31 | "PID FS": { 32 | ppidFS(activeDir), 33 | nil, 34 | []string{activeDir + "/abc", activeDir + "/1234"}, 35 | []string{activeDir + "/" + fmt.Sprint(ppid) + ".yaml"}, 36 | }, 37 | "PID file deleted by external source": { 38 | ppidFileMissing(activeDir), 39 | nil, 40 | []string{activeDir + "/abc", activeDir + "/1234"}, 41 | []string{}, 42 | }, 43 | // Unfortunately it was not possible with afero memFS to test what happens if 44 | // someone changes the active dir permissions and we cannot delete it anymore. 45 | // Apparently in the memFS afero can just delete these files, regardless of 46 | // permissions :D 47 | } 48 | 49 | for name, tc := range tt { 50 | t.Run(name, func(t *testing.T) { 51 | 52 | sm := &store.Storemanager{Fs: tc.Fs, Activedir: activeDir, Storedir: storeDir} 53 | err := selfClean(sm) 54 | 55 | if !testhelper.EqualError(err, tc.ExpError) { 56 | t.Errorf("Want error '%s', got '%s'", tc.ExpError, err) 57 | } 58 | 59 | for _, s := range tc.ExpFiles { 60 | if _, err := tc.Fs.Stat(s); err != nil { 61 | t.Errorf("Exp file '%s' to exist, but it does not", s) 62 | } 63 | } 64 | 65 | for _, s := range tc.NotExpFiles { 66 | _, err := tc.Fs.Stat(s) 67 | 68 | if err == nil { 69 | t.Errorf("Exp file '%s' to be deleted, but it still exists", s) 70 | } 71 | 72 | if err != nil && !errors.Is(err, fs.ErrNotExist) { 73 | t.Fatalf("An unexpected error has occurred") 74 | } 75 | } 76 | }) 77 | } 78 | } 79 | 80 | func ppidFS(activeDir string) afero.Fs { 81 | ppid := os.Getppid() 82 | fs := ppidFileMissing(activeDir) 83 | sm := testhelper.SampleKonfManager{} 84 | afero.WriteFile(fs, activeDir+"/"+fmt.Sprint(ppid), []byte(sm.SingleClusterSingleContextEU()), utils.KonfPerm) 85 | return fs 86 | } 87 | 88 | func ppidFileMissing(activeDir string) afero.Fs { 89 | fs := afero.NewMemMapFs() 90 | sm := testhelper.SampleKonfManager{} 91 | afero.WriteFile(fs, activeDir+"/abc", []byte("I am not even a kubeconfig, what am I doing here?"), utils.KonfPerm) 92 | afero.WriteFile(fs, activeDir+"/1234", []byte(sm.SingleClusterSingleContextEU()), utils.KonfPerm) 93 | return fs 94 | } 95 | 96 | func TestCleanLeftOvers(t *testing.T) { 97 | 98 | tt := map[string]struct { 99 | Setup func(t *testing.T, sm *store.Storemanager) ([]*exec.Cmd, []*exec.Cmd) 100 | ExpErr error 101 | }{ 102 | "all procs still running": { 103 | mixedFSWithAllProcs, 104 | nil, 105 | }, 106 | "some procs have stopped": { 107 | mixedFSIncompleteProcs, 108 | nil, 109 | }, 110 | "dirty dir": { 111 | mixedFSDirtyDir, 112 | nil, 113 | }, 114 | "dir does not exist": { 115 | emptyFS, 116 | fs.ErrNotExist, 117 | }, 118 | } 119 | 120 | for name, tc := range tt { 121 | t.Run(name, func(t *testing.T) { 122 | sm := &store.Storemanager{Fs: afero.NewMemMapFs(), Activedir: "./konf/active", Storedir: "./konf/store"} 123 | cmdsRunning, cmdsStopped := tc.Setup(t, sm) 124 | 125 | t.Cleanup(func() { 126 | cleanUpRunningCmds(t, cmdsRunning) 127 | }) 128 | 129 | err := cleanLeftOvers(sm) 130 | 131 | if !errors.Is(err, tc.ExpErr) { 132 | t.Errorf("Want error '%s', got '%s'", tc.ExpErr, err) 133 | } 134 | 135 | for _, cmd := range cmdsRunning { 136 | id := konf.IDFromProcessID(cmd.Process.Pid) 137 | fpath := sm.ActivePathFromID(id) 138 | _, err := sm.Fs.Stat(fpath) 139 | 140 | if err != nil { 141 | if errors.Is(err, fs.ErrNotExist) { 142 | t.Errorf("Exp file '%s' to be present, but it is not", fpath) 143 | } else { 144 | t.Fatalf("Unexpected error occurred: '%s'", err) 145 | } 146 | } 147 | } 148 | 149 | for _, cmd := range cmdsStopped { 150 | id := konf.IDFromProcessID(cmd.Process.Pid) 151 | fpath := sm.ActivePathFromID(id) 152 | _, err := sm.Fs.Stat(fpath) 153 | 154 | if !errors.Is(err, fs.ErrNotExist) { 155 | t.Fatalf("Unexpected error occurred: '%s'", err) 156 | } 157 | 158 | if err == nil { 159 | t.Errorf("Exp file '%s' to be deleted, but it is still present", fpath) 160 | } 161 | } 162 | 163 | }) 164 | } 165 | 166 | } 167 | 168 | func mixedFSWithAllProcs(t *testing.T, sm *store.Storemanager) (cmdsRunning []*exec.Cmd, cmdsStopped []*exec.Cmd) { 169 | // we are simulating other instances of konf here 170 | numOfConfs := 3 171 | 172 | skm := testhelper.SampleKonfManager{} 173 | 174 | for i := 1; i <= numOfConfs; i++ { 175 | // set sleep to an extremely high number as the argument "infinity" does not exist in all versions of the util 176 | cmd := exec.Command("sleep", "315360000") // aka 10 years. Should be long enough for the unit test to finish ;) 177 | cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} 178 | err := cmd.Start() 179 | if err != nil { 180 | t.Fatal(err) 181 | } 182 | pid := cmd.Process.Pid 183 | cmdsRunning = append(cmdsRunning, cmd) 184 | afero.WriteFile(sm.Fs, sm.ActivePathFromID(konf.IDFromProcessID(pid)), []byte(skm.SingleClusterSingleContextEU()), utils.KonfPerm) 185 | } 186 | 187 | return cmdsRunning, nil 188 | } 189 | 190 | // returns a state where there are more fs files than cmds 191 | func mixedFSIncompleteProcs(t *testing.T, sm *store.Storemanager) (cmdsRunning []*exec.Cmd, cmdsStopped []*exec.Cmd) { 192 | cmdsRunning, cmdsStopped = mixedFSWithAllProcs(t, sm) 193 | 194 | cmdToKill := cmdsRunning[0] 195 | origPID := cmdToKill.Process.Pid 196 | err := cmdToKill.Process.Kill() 197 | if err != nil { 198 | t.Fatal(err) 199 | } 200 | 201 | // we need to call release here, as otherwise our process will have received the signal, but 202 | // the kernel will still be waiting for the parent to send wait, thus turning our process into a 203 | // zombie. Zombies are unfortunately a problem as they still have a PID and therefore mess with 204 | // funcs like cleanLeftOvers 205 | // err = cmdToKill.Process.Release() 206 | _, err = cmdToKill.Process.Wait() 207 | if err != nil { 208 | t.Fatal(err) 209 | } 210 | 211 | // Release will set the PID to -1. Therefore we need to set the PID back, so 212 | // we can use the original PID in our tests 213 | cmdToKill.Process.Pid = origPID 214 | 215 | cmdsStopped = append(cmdsStopped, cmdsRunning[0]) 216 | cmdsRunning = cmdsRunning[1:] 217 | 218 | return cmdsRunning, cmdsStopped 219 | } 220 | 221 | func mixedFSDirtyDir(t *testing.T, sm *store.Storemanager) (cmdsRunning []*exec.Cmd, cmdsStopped []*exec.Cmd) { 222 | cmdsRunning, cmdsStopped = mixedFSIncompleteProcs(t, sm) 223 | 224 | id := konf.KonfID("/not-a-valid-process-id") 225 | afero.WriteFile(sm.Fs, sm.ActivePathFromID(id), []byte{}, utils.KonfPerm) 226 | 227 | return cmdsRunning, cmdsStopped 228 | 229 | } 230 | 231 | func emptyFS(t *testing.T, sm *store.Storemanager) (cmdsRunning []*exec.Cmd, cmdsStopped []*exec.Cmd) { 232 | // no op 233 | return nil, nil 234 | } 235 | 236 | func cleanUpRunningCmds(t *testing.T, cmds []*exec.Cmd) { 237 | rogueProcesses := []*exec.Cmd{} 238 | for _, cmd := range cmds { 239 | 240 | err := cmd.Process.Kill() 241 | if err != nil { 242 | rogueProcesses = append(rogueProcesses, cmd) 243 | } 244 | 245 | _, err = cmd.Process.Wait() 246 | if err != nil { 247 | rogueProcesses = append(rogueProcesses, cmd) 248 | } 249 | } 250 | if len(rogueProcesses) != 0 { 251 | t.Fatalf("Cleanup went wrong, please manually check the following processes: %v", rogueProcesses) 252 | } 253 | } 254 | -------------------------------------------------------------------------------- /cmd/completion.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "os" 7 | "strings" 8 | 9 | "github.com/spf13/cobra" 10 | ) 11 | 12 | // Main modifications from the auto-generated completion.go by cobra are to accommodate the konf wrapper. 13 | // A detailed explanation can be found on a per shell basis down below 14 | // The preset for this file is taken from https://github.com/spf13/cobra/blob/master/shell_completions.md 15 | 16 | type completionCmd struct { 17 | cmd *cobra.Command 18 | } 19 | 20 | func newCompletionCmd() *completionCmd { 21 | cc := completionCmd{} 22 | 23 | cc.cmd = &cobra.Command{ 24 | Use: "completion [bash|zsh|fish]", 25 | Short: "Generate completion script", 26 | Long: `To load completions: 27 | 28 | Bash: 29 | 30 | # To load completions for each session, add this to your zshrc: 31 | 32 | source <(konf completion bash) 33 | 34 | Zsh: 35 | 36 | # If shell completion is not already enabled in your environment, 37 | # you will need to enable it. Simply add the following to your .zshrc: 38 | 39 | autoload -U compinit && compinit 40 | 41 | # To load completions for each session, add this to your zshrc: 42 | source <(konf completion zsh) 43 | 44 | fish: 45 | 46 | $ konf completion fish | source 47 | 48 | # To load completions for each session, execute once: 49 | $ konf completion fish > ~/.config/fish/completions/konf.fish 50 | 51 | `, 52 | DisableFlagsInUseLine: true, 53 | ValidArgs: []string{"bash", "zsh", "fish"}, 54 | Args: cobra.ExactValidArgs(1), 55 | RunE: cc.completion, 56 | } 57 | 58 | return &cc 59 | } 60 | 61 | func (c *completionCmd) completion(cmd *cobra.Command, args []string) error { 62 | switch args[0] { 63 | 64 | case "zsh": 65 | // This allows for also using 'source <(konf completion zsh)' with zsh, similar to bash. 66 | // Basically it just adds the compdef command so it can be run. Taken from kubectl, who 67 | // do a similar thing 68 | zshHeader := "#compdef _konf konf\ncompdef _konf konf\n" 69 | 70 | // So per default cobra makes use of the words[] array that zsh provides to you in completion funcs. 71 | // Words is an array that contains all words that have been typed by the user before hitting tab 72 | // Now cobra takes words[1] which is equal to the name of the comand and uses this to call completion on it 73 | // However in our case this does not work as words[1] points to 'konf' which is the wrapper and not the binary 74 | // In order to solve this we have to ensure that words[1] equates to konf-go, which is the binary. 75 | // Currently I have found, the fastest way to do this is by inserting a line to overwrite words[1]. This is 76 | // because the words[1] reference is used throughout the script and I would not want to replace all of it 77 | var b bytes.Buffer 78 | err := rootCmd.GenZshCompletion(&b) 79 | if err != nil { 80 | return err 81 | } 82 | anchor := "local -a completions" // this is basically a line early in the original script that we are going to cling onto 83 | genZsh := strings.Replace(b.String(), anchor, anchor+"\n words[1]=\"konf-go\"", 1) 84 | 85 | os.Stdout.WriteString(zshHeader + genZsh) 86 | 87 | case "bash": 88 | var b bytes.Buffer 89 | err := rootCmd.GenBashCompletionV2(&b, true) 90 | if err != nil { 91 | return err 92 | } 93 | anchor := "local requestComp lastParam lastChar args" 94 | genBash := strings.Replace(b.String(), anchor, anchor+"\n words[0]=\"konf-go\"", 1) // basically the same as for zsh, but this words[] is zero-indexed 95 | 96 | os.Stdout.WriteString(genBash) 97 | 98 | case "fish": 99 | const name = "konf-go" 100 | var b bytes.Buffer 101 | rootCmd.Use = name 102 | err := rootCmd.GenFishCompletion(&b, true) 103 | if err != nil { 104 | return err 105 | } 106 | anchor := "complete -c " + name 107 | genFish := strings.Replace(b.String(), anchor, anchor+" -w konf", 2) // this is a bit different as we have to replace two occurrences of the anchor 108 | 109 | os.Stdout.WriteString(genFish) 110 | 111 | default: 112 | return fmt.Errorf("konf currently does not support autocompletions for %s", args[0]) 113 | } 114 | 115 | return nil 116 | } 117 | -------------------------------------------------------------------------------- /cmd/completion_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/simontheleg/konf-go/testhelper" 8 | ) 9 | 10 | func TestCompletionCmd(t *testing.T) { 11 | 12 | tt := map[string]struct { 13 | args []string 14 | ExpErr error 15 | }{ 16 | "zsh arg": { 17 | []string{"zsh"}, 18 | nil, 19 | }, 20 | "bash arg": { 21 | []string{"bash"}, 22 | nil, 23 | }, 24 | "fish": { 25 | []string{"fish"}, 26 | nil, 27 | }, 28 | "invalid arg": { 29 | []string{"invalid"}, 30 | fmt.Errorf("konf currently does not support autocompletions for invalid"), 31 | }, 32 | } 33 | 34 | for name, tc := range tt { 35 | t.Run(name, func(t *testing.T) { 36 | cc := newCompletionCmd() 37 | cmd := cc.cmd 38 | 39 | err := cmd.RunE(cmd, tc.args) 40 | 41 | if !testhelper.EqualError(err, tc.ExpErr) { 42 | t.Errorf("Want error '%s', got '%s'", tc.ExpErr, err) 43 | } 44 | }) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /cmd/delete.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/simontheleg/konf-go/config" 5 | "github.com/simontheleg/konf-go/konf" 6 | "github.com/simontheleg/konf-go/log" 7 | "github.com/simontheleg/konf-go/prompt" 8 | "github.com/simontheleg/konf-go/store" 9 | "github.com/spf13/afero" 10 | "github.com/spf13/cobra" 11 | ) 12 | 13 | type deleteCmd struct { 14 | sm *store.Storemanager 15 | fetchconfs func() ([]*store.Metadata, error) 16 | selectSingleKonf func(*store.Storemanager, prompt.RunFunc) (konf.KonfID, error) 17 | deleteKonfWithID func(*store.Storemanager, konf.KonfID) error 18 | idsForGlobs func(*store.Storemanager, []string) ([]konf.KonfID, error) 19 | prompt prompt.RunFunc 20 | 21 | cmd *cobra.Command 22 | } 23 | 24 | func newDeleteCommand() *deleteCmd { 25 | fs := afero.NewOsFs() 26 | sm := &store.Storemanager{Fs: fs, Activedir: config.ActiveDir(), Storedir: config.StoreDir()} 27 | dc := &deleteCmd{ 28 | sm: sm, 29 | fetchconfs: sm.FetchAllKonfs, 30 | selectSingleKonf: selectSingleKonf, 31 | deleteKonfWithID: deleteKonfWithID, 32 | idsForGlobs: idsForGlobs, 33 | prompt: prompt.Terminal, 34 | } 35 | 36 | dc.cmd = &cobra.Command{ 37 | Use: "delete", 38 | Short: "Delete kubeconfig", 39 | Long: `Delete one or multiple kubeconfigs 40 | 41 | Examples: 42 | -> 'delete' run selection prompt for deletion 43 | -> 'delete []' delete specific konf(s) 44 | -> 'delete "my-konf*"' delete konf matching fileglob 45 | `, 46 | RunE: dc.delete, 47 | ValidArgsFunction: dc.completeDelete, 48 | } 49 | 50 | return dc 51 | } 52 | 53 | func (c *deleteCmd) delete(cmd *cobra.Command, args []string) error { 54 | var ids []konf.KonfID 55 | var err error 56 | 57 | if len(args) == 0 { 58 | var id konf.KonfID 59 | id, err = c.selectSingleKonf(c.sm, c.prompt) 60 | if err != nil { 61 | return err 62 | } 63 | ids = append(ids, id) 64 | } else { 65 | ids, err = c.idsForGlobs(c.sm, args) 66 | if err != nil { 67 | return err 68 | } 69 | } 70 | 71 | for _, id := range ids { 72 | if err := c.deleteKonfWithID(c.sm, id); err != nil { 73 | return err 74 | } 75 | } 76 | 77 | log.Info("Deletion successful. If for security reasons you want to remove any currently active konfs, close the shell sessions they are used in.") 78 | return nil 79 | } 80 | 81 | func deleteKonfWithID(sm *store.Storemanager, id konf.KonfID) error { 82 | path := sm.StorePathFromID(id) 83 | if err := sm.Fs.Remove(path); err != nil { 84 | return err 85 | } 86 | log.Info("Successfully deleted konf %q at %q", id, path) 87 | return nil 88 | } 89 | 90 | // idsForGlobs takes in a slice of patterns and returns corresponding IDs from 91 | // the konfStore 92 | func idsForGlobs(sm *store.Storemanager, patterns []string) ([]konf.KonfID, error) { 93 | var ids []konf.KonfID 94 | for _, pattern := range patterns { 95 | metadata, err := sm.FetchKonfsForGlob(pattern) // resolve any globs among the arguments 96 | if err != nil { 97 | return nil, err 98 | } 99 | for _, f := range metadata { 100 | id := konf.IDFromClusterAndContext(f.Cluster, f.Context) 101 | ids = append(ids, id) 102 | } 103 | } 104 | return ids, nil 105 | } 106 | 107 | func (c *deleteCmd) completeDelete(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { 108 | konfs, err := c.fetchconfs() 109 | if err != nil { 110 | // if the store is just empty, return no suggestions, instead of throwing an error 111 | if _, ok := err.(*store.EmptyStore); ok { 112 | return []string{}, cobra.ShellCompDirectiveNoFileComp 113 | } 114 | 115 | cobra.CompDebugln(err.Error(), true) 116 | return nil, cobra.ShellCompDirectiveError 117 | } 118 | 119 | sug := []string{} 120 | for _, k := range konfs { 121 | // with the current design of 'set', we need to return the ID here in the autocomplete as the first part of the completion 122 | // as it is directly passed to set 123 | sug = append(sug, string(konf.IDFromClusterAndContext(k.Cluster, k.Context))) 124 | } 125 | 126 | return sug, cobra.ShellCompDirectiveNoFileComp 127 | } 128 | -------------------------------------------------------------------------------- /cmd/delete_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "errors" 5 | "io/fs" 6 | "sort" 7 | "testing" 8 | 9 | "github.com/google/go-cmp/cmp" 10 | "github.com/simontheleg/konf-go/konf" 11 | "github.com/simontheleg/konf-go/prompt" 12 | "github.com/simontheleg/konf-go/store" 13 | "github.com/simontheleg/konf-go/testhelper" 14 | "github.com/spf13/afero" 15 | "github.com/spf13/cobra" 16 | "k8s.io/utils/strings/slices" 17 | ) 18 | 19 | func TestDeleteKonfWithID(t *testing.T) { 20 | storeDir := "./konf/store" 21 | activeDir := "./konf/active" 22 | fm := testhelper.FilesystemManager{Storedir: storeDir, Activedir: activeDir} 23 | 24 | tt := map[string]struct { 25 | fsCreator func() afero.Fs 26 | idToDelete konf.KonfID 27 | expError error 28 | expFiles []string 29 | notExpFiles []string 30 | }{ 31 | "file was found": { 32 | fsCreator: testhelper.FSWithFiles(fm.SingleClusterSingleContextEU, fm.SingleClusterSingleContextASIA), 33 | idToDelete: "dev-eu_dev-eu-1", 34 | expError: nil, 35 | expFiles: []string{storeDir + "/dev-asia_dev-asia-1.yaml"}, 36 | notExpFiles: []string{storeDir + "/dev-eu_dev-eu-1.yaml"}, 37 | }, 38 | "file was not found": { 39 | fsCreator: testhelper.FSWithFiles(fm.SingleClusterSingleContextASIA), 40 | idToDelete: "dev-eu_dev-eu-1", 41 | expError: fs.ErrNotExist, 42 | expFiles: []string{storeDir + "/dev-asia_dev-asia-1.yaml"}, 43 | notExpFiles: []string{}, 44 | }, 45 | } 46 | 47 | for name, tc := range tt { 48 | t.Run(name, func(t *testing.T) { 49 | 50 | fsm := tc.fsCreator() 51 | sm := &store.Storemanager{Fs: fsm, Activedir: activeDir, Storedir: storeDir} 52 | 53 | err := deleteKonfWithID(sm, tc.idToDelete) 54 | 55 | if !errors.Is(err, tc.expError) { 56 | t.Errorf("Exp err to be %q, got %q", tc.expError, err) 57 | } 58 | 59 | for _, f := range tc.expFiles { 60 | if _, err := fsm.Stat(f); err != nil { 61 | t.Errorf("Exp file %q to exist, but it does not", f) 62 | } 63 | } 64 | 65 | for _, s := range tc.notExpFiles { 66 | _, err := fsm.Stat(s) 67 | 68 | if err == nil { 69 | t.Errorf("Exp file '%s' to be deleted, but it still exists", s) 70 | } 71 | 72 | if err != nil && !errors.Is(err, fs.ErrNotExist) { 73 | t.Fatalf("An unexpected error has occurred: %q", err) 74 | } 75 | } 76 | 77 | }) 78 | } 79 | } 80 | 81 | func TestIDsForGlobs(t *testing.T) { 82 | storeDir := "./konf/store" 83 | activeDir := "./konf/active" 84 | fm := testhelper.FilesystemManager{Storedir: storeDir, Activedir: activeDir} 85 | 86 | tt := map[string]struct { 87 | fsCreator func() afero.Fs 88 | patterns []string 89 | expIDs []string 90 | expError error 91 | }{ 92 | "single argument no glob": { 93 | fsCreator: testhelper.FSWithFiles(fm.SingleClusterSingleContextEU, fm.SingleClusterSingleContextASIA, fm.SingleClusterSingleContextEU2, fm.SingleClusterSingleContextASIA2), 94 | patterns: []string{"dev-eu_dev-eu-1"}, 95 | expIDs: []string{"dev-eu_dev-eu-1"}, 96 | expError: nil, 97 | }, 98 | "single argument glob": { 99 | fsCreator: testhelper.FSWithFiles(fm.SingleClusterSingleContextEU, fm.SingleClusterSingleContextASIA, fm.SingleClusterSingleContextEU2, fm.SingleClusterSingleContextASIA2), 100 | patterns: []string{"dev-eu_dev-eu*"}, 101 | expIDs: []string{"dev-eu_dev-eu-1", "dev-eu_dev-eu-2"}, 102 | expError: nil, 103 | }, 104 | "two arguments no glob": { 105 | fsCreator: testhelper.FSWithFiles(fm.SingleClusterSingleContextEU, fm.SingleClusterSingleContextASIA, fm.SingleClusterSingleContextEU2, fm.SingleClusterSingleContextASIA2), 106 | patterns: []string{"dev-eu_dev-eu-1", "dev-asia_dev-asia-1"}, 107 | expIDs: []string{"dev-eu_dev-eu-1", "dev-asia_dev-asia-1"}, 108 | expError: nil, 109 | }, 110 | "two arguments one glob": { 111 | fsCreator: testhelper.FSWithFiles(fm.SingleClusterSingleContextEU, fm.SingleClusterSingleContextASIA, fm.SingleClusterSingleContextEU2, fm.SingleClusterSingleContextASIA2), 112 | patterns: []string{"dev-eu_dev-eu*", "dev-asia_dev-asia-1"}, 113 | expIDs: []string{"dev-eu_dev-eu-1", "dev-eu_dev-eu-2", "dev-asia_dev-asia-1"}, 114 | expError: nil, 115 | }, 116 | "two arguments two globs": { 117 | fsCreator: testhelper.FSWithFiles(fm.SingleClusterSingleContextEU, fm.SingleClusterSingleContextASIA, fm.SingleClusterSingleContextEU2, fm.SingleClusterSingleContextASIA2), 118 | patterns: []string{"dev-eu_dev-eu*", "dev-asia_dev-asia*"}, 119 | expIDs: []string{"dev-eu_dev-eu-1", "dev-eu_dev-eu-2", "dev-asia_dev-asia-1", "dev-asia_dev-asia-2"}, 120 | expError: nil, 121 | }, 122 | "no match": { 123 | fsCreator: testhelper.FSWithFiles(fm.SingleClusterSingleContextEU, fm.SingleClusterSingleContextASIA, fm.SingleClusterSingleContextEU2, fm.SingleClusterSingleContextASIA2), 124 | patterns: []string{"no-match"}, 125 | expIDs: []string{}, 126 | expError: &store.NoMatch{Pattern: "no-match"}, 127 | }, 128 | } 129 | 130 | for name, tc := range tt { 131 | t.Run(name, func(t *testing.T) { 132 | 133 | fs := tc.fsCreator() 134 | sm := &store.Storemanager{Activedir: activeDir, Storedir: storeDir, Fs: fs} 135 | 136 | res, err := idsForGlobs(sm, tc.patterns) 137 | 138 | if !testhelper.EqualError(tc.expError, err) { 139 | t.Errorf("Exp error %q, got %q", tc.expError, err) 140 | } 141 | 142 | var ids []string 143 | for _, r := range res { 144 | ids = append(ids, string(r)) 145 | } 146 | 147 | sort.Strings(tc.expIDs) 148 | sort.Strings(ids) 149 | 150 | if !slices.Equal(tc.expIDs, ids) { 151 | t.Errorf("Exp ids to be %v, got %v", tc.expIDs, ids) 152 | } 153 | 154 | }) 155 | } 156 | } 157 | 158 | func TestDelete(t *testing.T) { 159 | 160 | selectSingleKonfCalled := 0 161 | idsForGlobsCalled := 0 162 | deleteKonfWithIDCalled := 0 163 | 164 | var mockSelectSingleKonf = func(*store.Storemanager, prompt.RunFunc) (konf.KonfID, error) { 165 | selectSingleKonfCalled++ 166 | return "id1", nil 167 | } 168 | 169 | var mockIDsForGlobs = func(*store.Storemanager, []string) ([]konf.KonfID, error) { 170 | idsForGlobsCalled++ 171 | return []konf.KonfID{"id1", "id2", "id3"}, nil 172 | } 173 | 174 | var mockDeleteKonfWithID = func(*store.Storemanager, konf.KonfID) error { 175 | deleteKonfWithIDCalled++ 176 | return nil 177 | } 178 | 179 | cmd := &deleteCmd{ 180 | selectSingleKonf: mockSelectSingleKonf, 181 | idsForGlobs: mockIDsForGlobs, 182 | deleteKonfWithID: mockDeleteKonfWithID, 183 | } 184 | 185 | tt := map[string]struct { 186 | args []string 187 | expSelectSingleKonfCalled int 188 | expIdsForGlobsCalled int 189 | expDeleteKonfWithIDCalled int 190 | }{ 191 | "select one": { 192 | args: []string{}, 193 | expSelectSingleKonfCalled: 1, 194 | expIdsForGlobsCalled: 0, 195 | expDeleteKonfWithIDCalled: 1, 196 | }, 197 | "multiple arguments supplied": { 198 | args: []string{"id1", "id2", "id3"}, 199 | expSelectSingleKonfCalled: 0, 200 | expIdsForGlobsCalled: 1, 201 | expDeleteKonfWithIDCalled: 3, 202 | }, 203 | } 204 | 205 | for name, tc := range tt { 206 | t.Run(name, func(t *testing.T) { 207 | 208 | selectSingleKonfCalled = 0 209 | idsForGlobsCalled = 0 210 | deleteKonfWithIDCalled = 0 211 | 212 | err := cmd.delete(cmd.cmd, tc.args) 213 | 214 | if err != nil { 215 | t.Fatalf("An unexpected error occured: %v", err) 216 | } 217 | 218 | if tc.expSelectSingleKonfCalled != selectSingleKonfCalled { 219 | t.Errorf("Exp SelectSingleKonf to be called %d times, was called %d times", tc.expSelectSingleKonfCalled, selectSingleKonfCalled) 220 | } 221 | 222 | if tc.expIdsForGlobsCalled != idsForGlobsCalled { 223 | t.Errorf("Exp IDsForGlobsCalled to be called %d times, was called %d times", tc.expIdsForGlobsCalled, idsForGlobsCalled) 224 | } 225 | 226 | if tc.expDeleteKonfWithIDCalled != deleteKonfWithIDCalled { 227 | t.Errorf("Exp DeleteKonfWithID to be called %d times, was called %d times", tc.expDeleteKonfWithIDCalled, deleteKonfWithIDCalled) 228 | } 229 | 230 | }) 231 | 232 | } 233 | 234 | } 235 | 236 | func TestCompleteDelete(t *testing.T) { 237 | // since cobra takes care of the majority of the complexity (like parsing out results that don't match completion start), 238 | // we only need to test regular cases 239 | storeDir := "./konf/store" 240 | activeDir := "./konf/active" 241 | fm := testhelper.FilesystemManager{Storedir: storeDir, Activedir: activeDir} 242 | 243 | tt := map[string]struct { 244 | fsCreator func() afero.Fs 245 | expComp []string 246 | expCompDirec cobra.ShellCompDirective 247 | }{ 248 | "normal results": { 249 | testhelper.FSWithFiles(fm.StoreDir, fm.SingleClusterSingleContextASIA, fm.SingleClusterSingleContextEU), 250 | []string{"dev-asia_dev-asia-1", "dev-eu_dev-eu-1"}, 251 | cobra.ShellCompDirectiveNoFileComp, 252 | }, 253 | "no results": { 254 | testhelper.FSWithFiles(fm.StoreDir), 255 | []string{}, 256 | cobra.ShellCompDirectiveNoFileComp, 257 | }, 258 | } 259 | 260 | for name, tc := range tt { 261 | t.Run(name, func(t *testing.T) { 262 | fs := tc.fsCreator() 263 | sm := &store.Storemanager{Activedir: activeDir, Storedir: storeDir, Fs: fs} 264 | 265 | dcmd := newDeleteCommand() 266 | dcmd.sm = sm 267 | dcmd.fetchconfs = sm.FetchAllKonfs 268 | 269 | res, compdirec := dcmd.completeDelete(dcmd.cmd, []string{}, "") 270 | 271 | if !cmp.Equal(res, tc.expComp) { 272 | t.Errorf("Exp and given comps differ: \n '%s'", cmp.Diff(tc.expComp, res)) 273 | } 274 | 275 | if compdirec != tc.expCompDirec { 276 | t.Errorf("Exp compdirec %q, got %q", tc.expCompDirec, compdirec) 277 | } 278 | }) 279 | } 280 | } 281 | -------------------------------------------------------------------------------- /cmd/import.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io" 7 | "path/filepath" 8 | "strings" 9 | 10 | "github.com/simontheleg/konf-go/config" 11 | "github.com/simontheleg/konf-go/konf" 12 | log "github.com/simontheleg/konf-go/log" 13 | "github.com/simontheleg/konf-go/store" 14 | "github.com/spf13/afero" 15 | "github.com/spf13/cobra" 16 | ) 17 | 18 | type importCmd struct { 19 | sm *store.Storemanager 20 | 21 | filesForDir func(*store.Storemanager, string) ([]*FileWithPath, error) 22 | determineConfigs func(io.Reader) ([]*konf.Konfig, error) 23 | writeConfig func(*konf.Konfig) (string, error) 24 | deleteOriginalConfig func(*store.Storemanager, string) error 25 | 26 | move bool 27 | 28 | cmd *cobra.Command 29 | } 30 | 31 | func newImportCmd() *importCmd { 32 | fs := afero.NewOsFs() 33 | sm := &store.Storemanager{Activedir: config.ActiveDir(), Storedir: config.StoreDir(), Fs: fs} 34 | 35 | ic := &importCmd{ 36 | sm: sm, 37 | 38 | filesForDir: filesForDir, 39 | determineConfigs: konf.KonfsFromKubeconfig, 40 | writeConfig: sm.WriteKonfToStore, 41 | deleteOriginalConfig: deleteOriginalConfig, 42 | } 43 | 44 | ic.cmd = &cobra.Command{ 45 | Use: "import", 46 | Short: "Import kubeconfigs into konf store", 47 | Long: `Import one or multiple kubeconfigs 48 | 49 | Examples: 50 | -> 'konf import /mydir/myfile.yaml' will import a single kubeconfig 51 | -> 'konf import /mydir' will import all files in that directory 52 | 53 | It is important that you import all configs first, as konf requires each config to only 54 | contain a single context. Import will take care of splitting if necessary.`, 55 | Args: cobra.ExactArgs(1), 56 | RunE: ic.importf, 57 | } 58 | 59 | ic.cmd.Flags().BoolVarP(&ic.move, "move", "m", false, "whether the original kubeconfig should be deleted after successful import (default is false)") 60 | 61 | return ic 62 | } 63 | 64 | // because import is a reserved word, we have to slightly rename this :) 65 | func (c *importCmd) importf(cmd *cobra.Command, args []string) error { 66 | searchpath := args[0] // safe, as we specify cobra.ExactArgs(1) 67 | 68 | files, err := c.filesForDir(c.sm, searchpath) 69 | if err != nil { 70 | return err 71 | } 72 | 73 | // we need to wrap this here, as we require the original importpath 74 | type importKonf struct { 75 | Konf *konf.Konfig 76 | ImportPath string 77 | } 78 | konfs := []*importKonf{} 79 | for _, file := range files { 80 | ks, err := c.determineConfigs(file.File) 81 | if err != nil { 82 | return err 83 | } 84 | for _, k := range ks { 85 | konfs = append(konfs, &importKonf{Konf: k, ImportPath: file.FilePath}) 86 | } 87 | } 88 | 89 | if len(konfs) == 0 { 90 | errMsg := "no contexts found in the following file(s):\n" 91 | for _, file := range files { 92 | errMsg += fmt.Sprintf("\t- %q\n", file.FilePath) 93 | } 94 | return errors.New(errMsg) 95 | } 96 | 97 | for _, k := range konfs { 98 | _, err = c.writeConfig(k.Konf) 99 | if err != nil { 100 | return err 101 | } 102 | storePath := c.sm.StorePathFromID(k.Konf.Id) 103 | log.Info("Imported konf from %q successfully into %q\n", k.ImportPath, storePath) 104 | } 105 | 106 | if c.move { 107 | for _, f := range files { 108 | if err := c.deleteOriginalConfig(c.sm, f.FilePath); err != nil { 109 | return err 110 | } 111 | log.Info("Successfully deleted original kubeconfig file at %q", f.FilePath) 112 | } 113 | } 114 | 115 | return nil 116 | } 117 | 118 | func deleteOriginalConfig(sm *store.Storemanager, path string) error { 119 | // TODO refactor: This action should be provided by a convenience func inside the store package 120 | err := sm.Fs.Remove(path) 121 | if err != nil { 122 | return err 123 | } 124 | return nil 125 | } 126 | 127 | // wrapper struct, so we can return the original path as well 128 | type FileWithPath struct { 129 | FilePath string 130 | File afero.File 131 | } 132 | 133 | // filesForDir extracts all relevant files from a dir. 134 | // 135 | // Relevant is defined as in no subdirectories and no hidden files. If a file 136 | // instead of a dir is supplied, the file will be returned 137 | func filesForDir(sm *store.Storemanager, path string) ([]*FileWithPath, error) { 138 | fileinfo, err := sm.Fs.Stat(path) 139 | if err != nil { 140 | return nil, err 141 | } 142 | 143 | files := []*FileWithPath{} 144 | 145 | if fileinfo.IsDir() { 146 | fileinfos, err := afero.ReadDir(sm.Fs, path) 147 | if err != nil { 148 | return nil, err 149 | } 150 | for _, p := range fileinfos { 151 | if p.IsDir() || strings.HasPrefix(p.Name(), ".") { 152 | continue // skip any directories or hidden files 153 | } 154 | fpath := filepath.Join(path, p.Name()) 155 | file, err := sm.Fs.Open(fpath) 156 | if err != nil { 157 | return nil, err 158 | } 159 | files = append(files, &FileWithPath{FilePath: fpath, File: file}) 160 | } 161 | } else { 162 | file, err := sm.Fs.Open(path) 163 | if err != nil { 164 | return nil, err 165 | } 166 | // by calling file.Name(), we resolve any path ambiguities (e.g. "./dir" and 167 | // "dir") 168 | files = append(files, &FileWithPath{FilePath: file.Name(), File: file}) 169 | } 170 | 171 | return files, nil 172 | } 173 | -------------------------------------------------------------------------------- /cmd/import_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io" 7 | "io/fs" 8 | "sort" 9 | "testing" 10 | 11 | "github.com/google/go-cmp/cmp" 12 | "github.com/simontheleg/konf-go/konf" 13 | "github.com/simontheleg/konf-go/store" 14 | "github.com/simontheleg/konf-go/testhelper" 15 | "github.com/spf13/afero" 16 | ) 17 | 18 | func TestImport(t *testing.T) { 19 | storeDir := "./konf/store" 20 | activeDir := "./konf/active" 21 | fm := testhelper.FilesystemManager{Storedir: storeDir, Activedir: activeDir} 22 | var determineConfigsCalled int 23 | var writeConfigCalledCount int 24 | var deleteOriginalConfigCalled int 25 | var filesForDirCalled int 26 | // using just a wrapper here instead of a full mock, makes testing it slightly easier 27 | var wrapDetermineConfig = func(r io.Reader) ([]*konf.Konfig, error) { 28 | determineConfigsCalled++ 29 | return konf.KonfsFromKubeconfig(r) 30 | } 31 | var wrapFilesForDir = func(sm *store.Storemanager, s string) ([]*FileWithPath, error) { 32 | filesForDirCalled++ 33 | return filesForDir(sm, s) 34 | } 35 | var mockWriteConfig = func(*konf.Konfig) (string, error) { writeConfigCalledCount++; return "", nil } 36 | var mockDeleteOriginalConfig = func(*store.Storemanager, string) error { deleteOriginalConfigCalled++; return nil } 37 | 38 | type expCalls struct { 39 | DetermineConfigs int 40 | WriteConfig int 41 | DeleteOriginalConfig int 42 | FilesForDir int 43 | } 44 | tt := map[string]struct { 45 | args []string 46 | fsCreator func() afero.Fs 47 | expErr error 48 | moveFlag bool 49 | expCalls 50 | }{ 51 | "single file, single context": { 52 | []string{"./konf/store/dev-eu_dev-eu-1.yaml"}, 53 | testhelper.FSWithFiles(fm.StoreDir, fm.SingleClusterSingleContextEU), 54 | nil, 55 | false, 56 | expCalls{DetermineConfigs: 1, WriteConfig: 1, FilesForDir: 1}, 57 | }, 58 | "single file, empty context": { 59 | []string{"./konf/store/no-context.yaml"}, 60 | testhelper.FSWithFiles(fm.StoreDir, fm.KonfWithoutContext), 61 | fmt.Errorf("no contexts found in the following file(s):\n\t- \"konf/store/no-context.yaml\"\n"), 62 | false, 63 | expCalls{DetermineConfigs: 1, WriteConfig: 0, FilesForDir: 1}, 64 | }, 65 | "single file, move flag provided": { 66 | []string{"./konf/store/dev-eu_dev-eu-1.yaml"}, 67 | testhelper.FSWithFiles(fm.StoreDir, fm.SingleClusterSingleContextEU), 68 | nil, 69 | true, 70 | expCalls{DetermineConfigs: 1, WriteConfig: 1, DeleteOriginalConfig: 1, FilesForDir: 1}, 71 | }, 72 | "directory with single file": { 73 | []string{"./konf/store"}, 74 | testhelper.FSWithFiles(fm.StoreDir, fm.SingleClusterSingleContextEU), 75 | nil, 76 | false, 77 | expCalls{DetermineConfigs: 1, WriteConfig: 1, DeleteOriginalConfig: 0, FilesForDir: 1}, 78 | }, 79 | "directory with multiple files": { 80 | []string{"./konf/store"}, 81 | testhelper.FSWithFiles(fm.StoreDir, fm.SingleClusterSingleContextEU, fm.SingleClusterSingleContextASIA), 82 | nil, 83 | false, 84 | expCalls{DetermineConfigs: 2, WriteConfig: 2, DeleteOriginalConfig: 0, FilesForDir: 1}, 85 | }, 86 | "directory with multiple files, move flag provided": { 87 | []string{"./konf/store"}, 88 | testhelper.FSWithFiles(fm.StoreDir, fm.SingleClusterSingleContextEU, fm.SingleClusterSingleContextASIA), 89 | nil, 90 | true, 91 | expCalls{DetermineConfigs: 2, WriteConfig: 2, DeleteOriginalConfig: 2, FilesForDir: 1}, 92 | }, 93 | "directory with multiple files, empty context": { 94 | []string{"./konf/store"}, 95 | testhelper.FSWithFiles(fm.StoreDir, fm.KonfWithoutContext, fm.KonfWithoutContext2), 96 | fmt.Errorf("no contexts found in the following file(s):\n\t- \"konf/store/no-context-2.yaml\"\n\t- \"konf/store/no-context.yaml\"\n"), 97 | false, 98 | expCalls{DetermineConfigs: 2, WriteConfig: 0, FilesForDir: 1}, 99 | }, 100 | } 101 | 102 | for name, tc := range tt { 103 | t.Run(name, func(t *testing.T) { 104 | determineConfigsCalled = 0 105 | writeConfigCalledCount = 0 106 | deleteOriginalConfigCalled = 0 107 | filesForDirCalled = 0 108 | sm := &store.Storemanager{Activedir: activeDir, Storedir: storeDir, Fs: tc.fsCreator()} 109 | 110 | icmd := newImportCmd() 111 | icmd.sm = sm 112 | icmd.determineConfigs = wrapDetermineConfig 113 | icmd.writeConfig = mockWriteConfig 114 | icmd.deleteOriginalConfig = mockDeleteOriginalConfig 115 | icmd.filesForDir = wrapFilesForDir 116 | icmd.move = tc.moveFlag 117 | cmd := icmd.cmd 118 | 119 | // TODO unfortunately I was not able to use ExecuteC here as this would run 120 | // the cobra.OnInitialize, which sets the filesystem to OS. It should be investigated 121 | // if there is another way 122 | err := cmd.RunE(cmd, tc.args) 123 | if !testhelper.EqualError(tc.expErr, err) { 124 | t.Errorf("Exp error %q, got %q", tc.expErr, err) 125 | } 126 | 127 | if tc.expCalls.DetermineConfigs != determineConfigsCalled { 128 | t.Errorf("Exp DetermineConfigsCalled to be %d, but got %d", tc.expCalls.DetermineConfigs, determineConfigsCalled) 129 | } 130 | 131 | if tc.expCalls.WriteConfig != writeConfigCalledCount { 132 | t.Errorf("Exp WriteConfigCalled to be %d, but got %d", tc.expCalls.WriteConfig, writeConfigCalledCount) 133 | } 134 | 135 | if tc.expCalls.DeleteOriginalConfig != deleteOriginalConfigCalled { 136 | t.Errorf("Exp DeleteOriginalConfigCalled to be %d, but got %d", tc.expCalls.DeleteOriginalConfig, deleteOriginalConfigCalled) 137 | } 138 | 139 | if tc.expCalls.FilesForDir != filesForDirCalled { 140 | t.Errorf("Exp FilesForDirCalled to be %d, but got %d", tc.expCalls.FilesForDir, filesForDirCalled) 141 | } 142 | 143 | }) 144 | } 145 | } 146 | 147 | func TestDeleteOriginalConfig(t *testing.T) { 148 | fpath := "/dir/original-file.yaml" 149 | 150 | f := afero.NewMemMapFs() 151 | afero.WriteFile(f, fpath, nil, 0664) 152 | sm := &store.Storemanager{Fs: f} // for this simple case we do not need to set ActiveDir and StoreDir 153 | 154 | if err := deleteOriginalConfig(sm, fpath); err != nil { 155 | t.Fatalf("Could not delete original kubeconfig %q: '%v'", fpath, err) 156 | } 157 | 158 | if _, err := f.Stat(fpath); !errors.Is(err, fs.ErrNotExist) { 159 | t.Fatalf("Expected error to be FileNotFound, but got %v", err) 160 | } 161 | } 162 | 163 | // cases to handle: 164 | // - dir with closing slash 165 | // - dir without closing slash 166 | // - a file 167 | // - dir without any files 168 | func TestFilesForDir(t *testing.T) { 169 | storeDir := "./konf/store" 170 | activeDir := "./konf/active" 171 | fm := testhelper.FilesystemManager{Storedir: storeDir, Activedir: activeDir} 172 | f := testhelper.FSWithFiles(fm.DSStore, fm.MultiClusterMultiContext, fm.SingleClusterSingleContextEU, fm.SingleClusterSingleContextASIA)() 173 | sm := &store.Storemanager{Activedir: activeDir, Storedir: storeDir, Fs: f} 174 | 175 | tt := map[string]struct { 176 | path string 177 | expRes []string 178 | }{ 179 | "dir with hidden files, slash path": { 180 | path: "./konf/store/", 181 | expRes: []string{ 182 | "konf/store/multi_multi_konf.yaml", 183 | "konf/store/dev-eu_dev-eu-1.yaml", 184 | "konf/store/dev-asia_dev-asia-1.yaml", 185 | }, 186 | }, 187 | "dir with hidden files, no slash path": { 188 | path: "./konf/store", 189 | expRes: []string{ 190 | "konf/store/multi_multi_konf.yaml", 191 | "konf/store/dev-eu_dev-eu-1.yaml", 192 | "konf/store/dev-asia_dev-asia-1.yaml", 193 | }, 194 | }, 195 | "single file": { 196 | path: "./konf/store/dev-eu_dev-eu-1.yaml", 197 | expRes: []string{ 198 | "konf/store/dev-eu_dev-eu-1.yaml", 199 | }, 200 | }, 201 | } 202 | 203 | for name, tc := range tt { 204 | t.Run(name, func(t *testing.T) { 205 | files, err := filesForDir(sm, tc.path) 206 | if err != nil { 207 | t.Fatal(err) 208 | } 209 | 210 | res := make([]string, len(files)) 211 | for i, file := range files { 212 | res[i] = file.FilePath 213 | } 214 | 215 | sort.Strings(res) 216 | sort.Strings(tc.expRes) 217 | 218 | if !cmp.Equal(res, tc.expRes) { 219 | t.Errorf("Exp and given filepaths differ:\n '%s'", cmp.Diff(res, tc.expRes)) 220 | } 221 | 222 | }) 223 | } 224 | 225 | } 226 | -------------------------------------------------------------------------------- /cmd/namespace.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | 8 | "github.com/lithammer/fuzzysearch/fuzzy" 9 | "github.com/manifoldco/promptui" 10 | "github.com/simontheleg/konf-go/prompt" 11 | "github.com/simontheleg/konf-go/utils" 12 | "github.com/spf13/afero" 13 | "github.com/spf13/cobra" 14 | v1 "k8s.io/apimachinery/pkg/apis/meta/v1" 15 | "k8s.io/client-go/kubernetes" 16 | "k8s.io/client-go/tools/clientcmd" 17 | k8s "k8s.io/client-go/tools/clientcmd/api/v1" 18 | "sigs.k8s.io/yaml" 19 | ) 20 | 21 | type clientSetCreator = func(afero.Fs) (kubernetes.Interface, error) 22 | 23 | type namespaceCmd struct { 24 | fs afero.Fs 25 | 26 | promptFunc prompt.RunFunc 27 | selectNamespace func(clientSetCreator, prompt.RunFunc, afero.Fs) (string, error) 28 | setNamespace func(afero.Fs, string) error 29 | clientSetCreator clientSetCreator 30 | 31 | cmd *cobra.Command 32 | } 33 | 34 | func newNamespaceCmd() *namespaceCmd { 35 | 36 | fs := afero.NewOsFs() 37 | 38 | cc := &namespaceCmd{ 39 | fs: fs, 40 | promptFunc: prompt.Terminal, 41 | selectNamespace: selectNamespace, 42 | setNamespace: setNamespace, 43 | clientSetCreator: newKubeClientSet, 44 | } 45 | 46 | cc.cmd = &cobra.Command{ 47 | Use: "namespace", 48 | Aliases: []string{"ns"}, 49 | Short: "Change namespace in current context", 50 | Long: `Set the namespace in the current context or start picker dialogue. 51 | Can also be invoked via 'ns' alias 52 | 53 | Examples: 54 | -> 'ns' run namespace selection 55 | -> 'ns = len(nss) { 156 | return "", fmt.Errorf("invalid selection %d", selPos) 157 | } 158 | 159 | return nss[selPos], nil 160 | } 161 | 162 | func searchNamespace(searchTerm, curItem string) bool { 163 | return fuzzy.Match(searchTerm, curItem) 164 | } 165 | 166 | func newKubeClientSet(fs afero.Fs) (kubernetes.Interface, error) { 167 | kPath, err := kubeconfigEnv() 168 | if err != nil { 169 | return nil, err 170 | } 171 | 172 | b, err := afero.ReadFile(fs, kPath) 173 | if err != nil { 174 | return nil, err 175 | } 176 | 177 | conf, err := clientcmd.NewClientConfigFromBytes(b) 178 | if err != nil { 179 | return nil, err 180 | } 181 | 182 | cc, err := conf.ClientConfig() 183 | if err != nil { 184 | return nil, err 185 | } 186 | 187 | cs, err := kubernetes.NewForConfig(cc) 188 | if err != nil { 189 | return nil, err 190 | } 191 | 192 | return cs, nil 193 | } 194 | 195 | func setNamespace(fs afero.Fs, ns string) error { 196 | kPath, err := kubeconfigEnv() 197 | if err != nil { 198 | return err 199 | } 200 | 201 | b, err := afero.ReadFile(fs, kPath) 202 | if err != nil { 203 | return err 204 | } 205 | 206 | var conf k8s.Config 207 | err = yaml.Unmarshal(b, &conf) 208 | if err != nil { 209 | return err 210 | } 211 | 212 | if len(conf.Contexts) == 0 { 213 | return fmt.Errorf("could not set namespace as contexts[] is empty in kubeconfig") 214 | } 215 | 216 | conf.Contexts[0].Context.Namespace = ns // this should be safe as konf import ensures we have only one context 217 | 218 | retconf, err := yaml.Marshal(conf) 219 | if err != nil { 220 | return err 221 | } 222 | 223 | err = afero.WriteFile(fs, kPath, retconf, utils.KonfPerm) 224 | if err != nil { 225 | return err 226 | } 227 | 228 | return nil 229 | } 230 | 231 | func kubeconfigEnv() (string, error) { 232 | kPath := os.Getenv("KUBECONFIG") 233 | if kPath == "" { 234 | // it makes sense to return an error here, as depending funcs do not work without KUBECONFIG being set 235 | return "", fmt.Errorf("KUBECONFIG ist not set in your shell. Have you run konf set?") 236 | } 237 | return kPath, nil 238 | } 239 | -------------------------------------------------------------------------------- /cmd/namespace_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/google/go-cmp/cmp" 8 | "github.com/manifoldco/promptui" 9 | "github.com/simontheleg/konf-go/prompt" 10 | "github.com/simontheleg/konf-go/testhelper" 11 | "github.com/spf13/afero" 12 | "github.com/spf13/cobra" 13 | "k8s.io/apimachinery/pkg/runtime" 14 | "k8s.io/apimachinery/pkg/util/yaml" 15 | "k8s.io/client-go/kubernetes" 16 | "k8s.io/client-go/kubernetes/fake" 17 | k8s "k8s.io/client-go/tools/clientcmd/api/v1" 18 | ) 19 | 20 | func TestNamespace(t *testing.T) { 21 | 22 | selectNamespaceCalled := false 23 | setNamespaceCalled := false 24 | var mockSelectNamespace = func(clientSetCreator, prompt.RunFunc, afero.Fs) (string, error) { 25 | selectNamespaceCalled = true 26 | return "", nil 27 | } 28 | var mockSetNamespace = func(afero.Fs, string) error { setNamespaceCalled = true; return nil } 29 | 30 | nscmd := newNamespaceCmd() 31 | nscmd.selectNamespace = mockSelectNamespace 32 | nscmd.setNamespace = mockSetNamespace 33 | 34 | type ExpCalls struct { 35 | SelectNamespace bool 36 | SetNamespace bool 37 | } 38 | tt := map[string]struct { 39 | Args []string 40 | ExpErr error 41 | ExpCalls 42 | }{ 43 | "1 arg": { 44 | []string{"ns1"}, 45 | nil, 46 | ExpCalls{SelectNamespace: false, SetNamespace: true}, 47 | }, 48 | "0 args": { 49 | []string{}, 50 | nil, 51 | ExpCalls{SelectNamespace: true, SetNamespace: true}, 52 | }, 53 | } 54 | 55 | for name, tc := range tt { 56 | t.Run(name, func(t *testing.T) { 57 | selectNamespaceCalled = false 58 | setNamespaceCalled = false 59 | cmd := nscmd.cmd 60 | 61 | err := cmd.RunE(cmd, tc.Args) 62 | if !testhelper.EqualError(tc.ExpErr, err) { 63 | t.Errorf("Exp error %q, got %q", tc.ExpErr, err) 64 | } 65 | 66 | if tc.ExpCalls.SelectNamespace != selectNamespaceCalled { 67 | t.Errorf("Exp SelectNamespaceCalled to be %t, but got %t", tc.ExpCalls.SelectNamespace, selectNamespaceCalled) 68 | } 69 | 70 | if tc.ExpCalls.SetNamespace != setNamespaceCalled { 71 | t.Errorf("Exp SetNamespaceCalled to be %t, but got %t", tc.ExpCalls.SetNamespace, setNamespaceCalled) 72 | } 73 | 74 | }) 75 | } 76 | } 77 | 78 | func TestCompleteNamespace(t *testing.T) { 79 | // since cobra takes care of the majority of the complexity (like parsing out results that don't match completion start), 80 | // we only need to test regular cases 81 | 82 | tt := map[string]struct { 83 | nss []runtime.Object 84 | expComp []string 85 | expCompDirec cobra.ShellCompDirective 86 | }{ 87 | "normal results": { 88 | []runtime.Object{testhelper.NamespaceFromName("kube-system"), testhelper.NamespaceFromName(("public"))}, 89 | []string{"kube-system", "public"}, 90 | cobra.ShellCompDirectiveNoFileComp, 91 | }, 92 | "no results": { 93 | []runtime.Object{}, 94 | []string{}, 95 | cobra.ShellCompDirectiveNoFileComp, 96 | }, 97 | } 98 | 99 | for name, tc := range tt { 100 | t.Run(name, func(t *testing.T) { 101 | nscmd := newNamespaceCmd() 102 | nscmd.clientSetCreator = func(f afero.Fs) (kubernetes.Interface, error) { return fake.NewSimpleClientset(tc.nss...), nil } 103 | 104 | res, compdirec := nscmd.completeNamespace(nscmd.cmd, []string{}, "") 105 | 106 | if !cmp.Equal(res, tc.expComp) { 107 | t.Errorf("Exp and given comps differ: \n '%s'", cmp.Diff(tc.expComp, res)) 108 | } 109 | 110 | if compdirec != tc.expCompDirec { 111 | t.Errorf("Exp compdirec %q, got %q", tc.expCompDirec, compdirec) 112 | } 113 | 114 | }) 115 | } 116 | 117 | } 118 | 119 | func TestSearchNamespace(t *testing.T) { 120 | tt := map[string]struct { 121 | search string 122 | item string 123 | expRes bool 124 | }{ 125 | "full-match": { 126 | "kube-system", 127 | "kube-system", 128 | true, 129 | }, 130 | "partial-match-front": { 131 | "kube", 132 | "kube-system", 133 | true, 134 | }, 135 | "partial-match-middle": { 136 | "e-sys", 137 | "kube-system", 138 | true, 139 | }, 140 | "partial-match-end": { 141 | "stem", 142 | "kube-system", 143 | true, 144 | }, 145 | "partial-match-fuzzy": { 146 | "esys", 147 | "kube-system", 148 | true, 149 | }, 150 | "no-match": { 151 | "apples and oranges", 152 | "kube-system", 153 | false, 154 | }, 155 | } 156 | 157 | for name, tc := range tt { 158 | t.Run(name, func(t *testing.T) { 159 | res := searchNamespace(tc.search, tc.item) 160 | if res != tc.expRes { 161 | t.Errorf("Exp res to be %t got %t", tc.expRes, res) 162 | } 163 | }) 164 | } 165 | } 166 | 167 | func TestNewKubeClientSet(t *testing.T) { 168 | storeDir := "./konf/store" 169 | activeDir := "./konf/active" 170 | fm := testhelper.FilesystemManager{Storedir: storeDir, Activedir: activeDir} 171 | 172 | tt := map[string]struct { 173 | kubeenv string 174 | FSCreator func() afero.Fs 175 | ExpErr bool 176 | }{ 177 | "no $KUBECONFIG set": { 178 | "", 179 | testhelper.FSWithFiles(), 180 | true, 181 | }, 182 | "valid kubeconfig": { 183 | "./konf/active/dev-eu_dev-eu-1.yaml", 184 | testhelper.FSWithFiles(fm.ActiveDir, fm.SingleClusterSingleContextEU), 185 | false, 186 | }, 187 | "invalid kubeconfig": { 188 | "./konf/active/no-konf.yaml", 189 | testhelper.FSWithFiles(fm.ActiveDir, fm.InvalidYaml), 190 | true, 191 | }, 192 | } 193 | 194 | for name, tc := range tt { 195 | t.Run(name, func(t *testing.T) { 196 | t.Setenv("KUBECONFIG", tc.kubeenv) 197 | 198 | _, err := newKubeClientSet(tc.FSCreator()) 199 | 200 | if err != nil && tc.ExpErr == false { 201 | t.Errorf("Exp no error, but got: %v", err) 202 | } 203 | }) 204 | } 205 | } 206 | 207 | func TestSelectNamespace(t *testing.T) { 208 | 209 | // keep these in alphabetical order for tests to work! 210 | nss := []runtime.Object{ 211 | testhelper.NamespaceFromName("first"), 212 | testhelper.NamespaceFromName("kube-system"), 213 | testhelper.NamespaceFromName("zebra"), 214 | } 215 | 216 | var mockClientSetCreator = func(afero.Fs) (kubernetes.Interface, error) { 217 | return fake.NewSimpleClientset(nss...), nil 218 | } 219 | 220 | var mockSelect = func(sel int) prompt.RunFunc { 221 | return func(*promptui.Select) (int, error) { 222 | return sel, nil 223 | } 224 | } 225 | 226 | tt := map[string]struct { 227 | csc clientSetCreator 228 | sel func(*promptui.Select) (int, error) 229 | expNS string 230 | expErr error 231 | }{ 232 | "valid selection": { 233 | mockClientSetCreator, 234 | mockSelect(1), 235 | "kube-system", 236 | nil, 237 | }, 238 | "invalid selection": { 239 | mockClientSetCreator, 240 | mockSelect(3), 241 | "", 242 | fmt.Errorf("invalid selection 3"), 243 | }, 244 | "error prompt": { 245 | mockClientSetCreator, 246 | func(s *promptui.Select) (int, error) { return 0, fmt.Errorf("big bad error") }, 247 | "", 248 | fmt.Errorf("big bad error"), 249 | }, 250 | } 251 | 252 | for name, tc := range tt { 253 | t.Run(name, func(t *testing.T) { 254 | res, err := selectNamespace(tc.csc, tc.sel, nil) 255 | 256 | if !testhelper.EqualError(err, tc.expErr) { 257 | t.Errorf("Exp err %q, got %q", tc.expErr, err) 258 | } 259 | 260 | if res != tc.expNS { 261 | t.Errorf("Exp namespace to be %q, got %q", tc.expNS, res) 262 | } 263 | }) 264 | } 265 | 266 | } 267 | 268 | func TestSetNamespace(t *testing.T) { 269 | storeDir := "./konf/store" 270 | activeDir := "./konf/active" 271 | fm := testhelper.FilesystemManager{Storedir: storeDir, Activedir: activeDir} 272 | 273 | tt := map[string]struct { 274 | kubeenv string 275 | FSCreator func() afero.Fs 276 | ns string 277 | ExpErr bool 278 | }{ 279 | "no $KUBECONFIG set": { 280 | "", 281 | testhelper.FSWithFiles(), 282 | "", 283 | true, 284 | }, 285 | "valid kubeconfig": { 286 | "./konf/active/dev-eu_dev-eu-1.yaml", 287 | testhelper.FSWithFiles(fm.ActiveDir, fm.SingleClusterSingleContextEU), 288 | "kube-system", 289 | false, 290 | }, 291 | "invalid kubeconfig": { 292 | "./konf/active/no-konf.yaml", 293 | testhelper.FSWithFiles(fm.ActiveDir, fm.InvalidYaml), 294 | "kube-system", 295 | true, 296 | }, 297 | "valid kubeconfig, but missing context[]": { 298 | "./konf/active/no-context.yaml", 299 | testhelper.FSWithFiles(fm.ActiveDir, fm.KonfWithoutContext), 300 | "kube-system", 301 | true, 302 | }, 303 | } 304 | 305 | for name, tc := range tt { 306 | t.Run(name, func(t *testing.T) { 307 | t.Setenv("KUBECONFIG", tc.kubeenv) 308 | 309 | fs := tc.FSCreator() 310 | 311 | err := setNamespace(fs, tc.ns) 312 | 313 | if err != nil && tc.ExpErr == false { 314 | t.Errorf("Exp no error, but got: %v", err) 315 | } 316 | 317 | if tc.ExpErr == false { 318 | b, err := afero.ReadFile(fs, tc.kubeenv) 319 | if err != nil { 320 | t.Errorf("failed to read file %q", err) 321 | } 322 | 323 | var kconf k8s.Config 324 | err = yaml.Unmarshal(b, &kconf) 325 | if err != nil { 326 | t.Errorf("failed to unmarshal %q", err) 327 | } 328 | 329 | resNs := kconf.Contexts[0].Context.Namespace 330 | if resNs != tc.ns { 331 | t.Errorf("exp ns to be %q, but is %q", tc.ns, resNs) 332 | } 333 | } 334 | }) 335 | } 336 | } 337 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "flag" 5 | "io" 6 | "os" 7 | 8 | "github.com/simontheleg/konf-go/config" 9 | "github.com/simontheleg/konf-go/log" 10 | "github.com/simontheleg/konf-go/utils" 11 | "github.com/spf13/afero" 12 | "github.com/spf13/cobra" 13 | ) 14 | 15 | var ( 16 | konfDir string 17 | silent bool 18 | ) 19 | 20 | // rootCmd represents the base command when called without any subcommands 21 | var rootCmd = &cobra.Command{ 22 | Use: "konf", 23 | Short: "Root Command", 24 | Long: ` 25 | konf is a lightweight kubeconfig manager 26 | 27 | Before switchting between kubeconfigs make sure to import them via 'konf import' 28 | Afterwards switch between different kubeconfigs via 'konf set' 29 | `, 30 | } 31 | 32 | // Execute adds all child commands to the root command and sets flags appropriately. 33 | // This is called by main.main(). It only needs to happen once to the rootCmd. 34 | func Execute() error { 35 | if err := initPersistentFlags(); err != nil { 36 | return err 37 | } 38 | 39 | if err := initConfig(); err != nil { 40 | return err 41 | } 42 | 43 | // addCommands needs to be run after the config has been initialized! 44 | initCommands() 45 | 46 | // make sure the default directories exist for the sub-commands 47 | if err := utils.EnsureDir(afero.NewOsFs()); err != nil { 48 | return err 49 | } 50 | 51 | if err := rootCmd.Execute(); err != nil { 52 | return err 53 | } 54 | return nil 55 | } 56 | 57 | // initialize flags that are valid for all commands 58 | func initPersistentFlags() error { 59 | // we need to make a copy of our flagset to parse it immediately. This is because we cannot wait for 60 | // rootCmd.Execute to parse flags naturally, as we need the config already ready during initCommands. 61 | // We cannot use flags.Parse here, because cobra's flagchecker will complain that it cannot find 62 | // flags supplied by the end-user, because it thinks those flags do not exist. 63 | // For now I cannot think of a better way to handle this 64 | f := flag.FlagSet{} 65 | 66 | f.StringVar(&konfDir, "konf-dir", "", "konfs directory for kubeconfigs and tracking active konfs (default is $HOME/.kube/konfs)") 67 | f.BoolVar(&silent, "silent", false, "suppress log output if set to true (default is false)") 68 | if err := f.Parse(os.Args[1:]); err != nil { 69 | return err 70 | } 71 | 72 | // we just want these flags to be visible to the end-user, but they are not really to be used outside of 73 | // config initialization, which is already handled using regular flags above 74 | rootCmd.PersistentFlags().String("konf-dir", "", "konfs directory for kubeconfigs and tracking active konfs (default is $HOME/.kube/konfs)") 75 | rootCmd.PersistentFlags().Bool("silent", false, "suppress log output if set to true (default is false)") 76 | 77 | return nil 78 | } 79 | 80 | func initConfig() error { 81 | conf, err := config.DefaultConfig() 82 | if err != nil { 83 | return err 84 | } 85 | 86 | // apply any overrides 87 | if konfDir != "" { 88 | conf.KonfDir = konfDir 89 | } 90 | if silent { 91 | conf.Silent = silent 92 | log.InitLogger(io.Discard, io.Discard) 93 | } 94 | 95 | config.SetGlobalConfig(conf) 96 | return nil 97 | } 98 | 99 | func initCommands() { 100 | rootCmd.AddCommand(cleanupCmd) 101 | rootCmd.AddCommand(newCompletionCmd().cmd) 102 | rootCmd.AddCommand(newDeleteCommand().cmd) 103 | rootCmd.AddCommand(newImportCmd().cmd) 104 | rootCmd.AddCommand(newNamespaceCmd().cmd) 105 | rootCmd.AddCommand(newSetCommand().cmd) 106 | rootCmd.AddCommand(newShellwrapperCmd().cmd) 107 | rootCmd.AddCommand(newVersionCommand().cmd) 108 | } 109 | -------------------------------------------------------------------------------- /cmd/set.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io/fs" 7 | "os" 8 | 9 | "github.com/manifoldco/promptui" 10 | "github.com/simontheleg/konf-go/config" 11 | "github.com/simontheleg/konf-go/konf" 12 | log "github.com/simontheleg/konf-go/log" 13 | "github.com/simontheleg/konf-go/prompt" 14 | "github.com/simontheleg/konf-go/store" 15 | "github.com/simontheleg/konf-go/utils" 16 | "github.com/spf13/afero" 17 | "github.com/spf13/cobra" 18 | ) 19 | 20 | type setCmd struct { 21 | sm *store.Storemanager 22 | 23 | cmd *cobra.Command 24 | } 25 | 26 | func newSetCommand() *setCmd { 27 | fs := afero.NewOsFs() 28 | sm := &store.Storemanager{Fs: fs, Activedir: config.ActiveDir(), Storedir: config.StoreDir(), LatestKonfPath: config.LatestKonfFilePath()} 29 | sc := &setCmd{ 30 | sm: sm, 31 | } 32 | 33 | sc.cmd = &cobra.Command{ 34 | Use: `set`, 35 | Short: "Set kubeconfig to use in current shell", 36 | Args: cobra.MaximumNArgs(1), 37 | Long: `Sets kubeconfig to use or start picker dialogue. 38 | 39 | Examples: 40 | -> 'set' run konf selection 41 | -> 'set ' set a specific konf 42 | -> 'set -' set to last used konf 43 | `, 44 | RunE: sc.set, 45 | ValidArgsFunction: sc.completeSet, 46 | } 47 | 48 | return sc 49 | } 50 | 51 | func (c *setCmd) set(cmd *cobra.Command, args []string) error { 52 | // TODO if I stay with the mocking approach used in commands like 53 | // namespace. This part should be refactored to allow for mocking 54 | // the downstream funcs in order to test the if-else logic 55 | var id konf.KonfID 56 | var err error 57 | 58 | if len(args) == 0 { 59 | id, err = selectSingleKonf(c.sm, prompt.Terminal) 60 | if err != nil { 61 | return err 62 | } 63 | } else if args[0] == "-" { 64 | id, err = idOfLatestKonf(c.sm) 65 | if err != nil { 66 | return err 67 | } 68 | } else { 69 | id = konf.KonfID(args[0]) 70 | } 71 | 72 | context, err := setContext(id, c.sm) 73 | if err != nil { 74 | return err 75 | } 76 | err = saveLatestKonf(c.sm, id) 77 | if err != nil { 78 | return fmt.Errorf("could not save latest konf. As a result 'konf set -' might not work: %q ", err) 79 | } 80 | 81 | log.Info("Setting context to %q\n", id) 82 | 83 | // By printing out to stdout, we pass the value to our zsh hook, which then sets $KUBECONFIG to it 84 | // Both operate on the convention to use "KUBECONFIGCHANGE:". If you change this part in 85 | // here, do not forget to update shellwraper.go 86 | fmt.Println("KUBECONFIGCHANGE:" + context) 87 | 88 | return nil 89 | } 90 | 91 | func (c *setCmd) completeSet(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { 92 | konfs, err := c.sm.FetchAllKonfs() 93 | if err != nil { 94 | // if the store is just empty, return no suggestions, instead of throwing an error 95 | if _, ok := err.(*store.EmptyStore); ok { 96 | return []string{}, cobra.ShellCompDirectiveNoFileComp 97 | } 98 | 99 | cobra.CompDebugln(err.Error(), true) 100 | return nil, cobra.ShellCompDirectiveError 101 | } 102 | 103 | sug := []string{} 104 | for _, k := range konfs { 105 | // with the current design of 'set', we need to return the ID here in the autocomplete as the first part of the completion 106 | // as it is directly passed to set 107 | sug = append(sug, string(konf.IDFromClusterAndContext(k.Cluster, k.Context))) 108 | } 109 | 110 | return sug, cobra.ShellCompDirectiveNoFileComp 111 | } 112 | 113 | // TODO make a decision where this code should be placed. Currently it does not 114 | // make a lot of sense to bring it into its own package as it is at the nice 115 | // intersection between utilizing two packages to fulfil business logic However 116 | // it is also being used by two commands: "set" and "delete". But because 117 | // they are in the same package, we also cannot easily duplicate the code for 118 | // each 119 | func selectSingleKonf(sm *store.Storemanager, pf prompt.RunFunc) (konf.KonfID, error) { 120 | k, err := sm.FetchAllKonfs() 121 | if err != nil { 122 | return "", err 123 | } 124 | p := createSetPrompt(k) 125 | selPos, err := pf(p) 126 | if err != nil { 127 | return "", err 128 | } 129 | 130 | if selPos >= len(k) { 131 | return "", fmt.Errorf("invalid selection %d", selPos) 132 | } 133 | sel := k[selPos] 134 | 135 | return konf.IDFromClusterAndContext(sel.Cluster, sel.Context), nil 136 | } 137 | 138 | func idOfLatestKonf(sm *store.Storemanager) (konf.KonfID, error) { 139 | b, err := afero.ReadFile(sm.Fs, sm.LatestKonfPath) 140 | if err != nil { 141 | if errors.Is(err, fs.ErrNotExist) { 142 | return "", fmt.Errorf("could not select latest konf, because no konf was yet set") 143 | } 144 | return "", err 145 | } 146 | return konf.KonfID(b), nil 147 | } 148 | 149 | func setContext(id konf.KonfID, sm *store.Storemanager) (string, error) { 150 | k, err := afero.ReadFile(sm.Fs, sm.StorePathFromID(id)) 151 | if err != nil { 152 | return "", err 153 | } 154 | 155 | ppid := os.Getppid() 156 | konfID := konf.IDFromProcessID(ppid) 157 | activeKonf := sm.ActivePathFromID(konfID) 158 | err = afero.WriteFile(sm.Fs, activeKonf, k, utils.KonfPerm) 159 | if err != nil { 160 | return "", err 161 | } 162 | 163 | return activeKonf, nil 164 | 165 | } 166 | 167 | func saveLatestKonf(sm *store.Storemanager, id konf.KonfID) error { 168 | return afero.WriteFile(sm.Fs, sm.LatestKonfPath, []byte(id), utils.KonfPerm) 169 | } 170 | 171 | func createSetPrompt(options []*store.Metadata) *promptui.Select { 172 | // TODO use ssh/terminal to get the terminalsize and set trunc accordingly https://stackoverflow.com/questions/16569433/get-terminal-size-in-go 173 | trunc := 25 174 | promptInactive, promptActive, label, fmap := prompt.NewTableOutputTemplates(trunc) 175 | 176 | // Wrapper is required as we need access to options, but the methodSignature from promptUI 177 | // requires you to only pass an index not the whole func 178 | // This wrapper allows us to unit-test the FuzzyFilterKonf func better 179 | var wrapFuzzyFilterKonf = func(input string, index int) bool { 180 | return prompt.FuzzyFilterKonf(input, options[index]) 181 | } 182 | 183 | prompt := promptui.Select{ 184 | Label: label, 185 | Items: options, 186 | Templates: &promptui.SelectTemplates{ 187 | Active: promptActive, 188 | Inactive: promptInactive, 189 | FuncMap: fmap, 190 | }, 191 | HideSelected: true, 192 | Stdout: os.Stderr, 193 | Searcher: wrapFuzzyFilterKonf, 194 | Size: 15, 195 | } 196 | return &prompt 197 | } 198 | -------------------------------------------------------------------------------- /cmd/set_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io/fs" 7 | "os" 8 | "testing" 9 | 10 | "github.com/google/go-cmp/cmp" 11 | "github.com/manifoldco/promptui" 12 | "github.com/simontheleg/konf-go/konf" 13 | "github.com/simontheleg/konf-go/prompt" 14 | "github.com/simontheleg/konf-go/store" 15 | "github.com/simontheleg/konf-go/testhelper" 16 | "github.com/simontheleg/konf-go/utils" 17 | "github.com/spf13/afero" 18 | "github.com/spf13/cobra" 19 | ) 20 | 21 | func TestSelectLastKonf(t *testing.T) { 22 | storeDir := "./konf/store" 23 | activeDir := "./konf/active" 24 | latestKonfPath := "./konf/latestkonf" // it is fine to use an imaginary file location here 25 | fm := testhelper.FilesystemManager{Storedir: storeDir, Activedir: activeDir, LatestKonfPath: latestKonfPath} 26 | 27 | tt := map[string]struct { 28 | FSCreator func() afero.Fs 29 | ExpID konf.KonfID 30 | ExpError error 31 | }{ 32 | "latestKonf set": { 33 | FSCreator: testhelper.FSWithFiles(fm.LatestKonf), 34 | ExpID: "context_cluster", 35 | ExpError: nil, 36 | }, 37 | "no latestKonf": { 38 | FSCreator: testhelper.FSWithFiles(), 39 | ExpID: "", 40 | ExpError: fmt.Errorf("could not select latest konf, because no konf was yet set"), 41 | }, 42 | } 43 | 44 | for name, tc := range tt { 45 | t.Run(name, func(t *testing.T) { 46 | sm := &store.Storemanager{Fs: tc.FSCreator(), Activedir: activeDir, Storedir: storeDir, LatestKonfPath: latestKonfPath} 47 | id, err := idOfLatestKonf(sm) 48 | 49 | if !testhelper.EqualError(tc.ExpError, err) { 50 | t.Errorf("Want error %q, got %q", tc.ExpError, err) 51 | } 52 | 53 | if tc.ExpID != id { 54 | t.Errorf("Want ID %q, got %q", tc.ExpID, id) 55 | } 56 | }) 57 | } 58 | } 59 | 60 | func TestCompleteSet(t *testing.T) { 61 | // since cobra takes care of the majority of the complexity (like parsing out results that don't match completion start), 62 | // we only need to test regular cases 63 | storeDir := "./konf/store" 64 | activeDir := "./konf/active" 65 | fm := testhelper.FilesystemManager{Storedir: storeDir, Activedir: activeDir} 66 | 67 | tt := map[string]struct { 68 | fsCreator func() afero.Fs 69 | expComp []string 70 | expCompDirec cobra.ShellCompDirective 71 | }{ 72 | "normal results": { 73 | testhelper.FSWithFiles(fm.StoreDir, fm.SingleClusterSingleContextASIA, fm.SingleClusterSingleContextEU), 74 | []string{"dev-asia_dev-asia-1", "dev-eu_dev-eu-1"}, 75 | cobra.ShellCompDirectiveNoFileComp, 76 | }, 77 | "no results": { 78 | testhelper.FSWithFiles(fm.StoreDir), 79 | []string{}, 80 | cobra.ShellCompDirectiveNoFileComp, 81 | }, 82 | } 83 | 84 | for name, tc := range tt { 85 | t.Run(name, func(t *testing.T) { 86 | fs := tc.fsCreator() 87 | sm := &store.Storemanager{Activedir: activeDir, Storedir: storeDir, Fs: fs} 88 | 89 | scmd := newSetCommand() 90 | scmd.sm = sm 91 | 92 | res, compdirec := scmd.completeSet(scmd.cmd, []string{}, "") 93 | 94 | if !cmp.Equal(res, tc.expComp) { 95 | t.Errorf("Exp and given comps differ: \n '%s'", cmp.Diff(tc.expComp, res)) 96 | } 97 | 98 | if compdirec != tc.expCompDirec { 99 | t.Errorf("Exp compdirec %q, got %q", tc.expCompDirec, compdirec) 100 | } 101 | }) 102 | } 103 | } 104 | 105 | func TestSaveLatestKonf(t *testing.T) { 106 | expFile := "./konf/latestkonf" 107 | expID := konf.KonfID("context_cluster") 108 | 109 | f := afero.NewMemMapFs() 110 | sm := &store.Storemanager{Fs: f, LatestKonfPath: expFile} 111 | err := saveLatestKonf(sm, expID) 112 | if err != nil { 113 | t.Errorf("Could not save last konf: %q", err) 114 | } 115 | finf, err := f.Stat(expFile) 116 | if err != nil { 117 | t.Errorf("Could not stat file: %q", err) 118 | } 119 | if finf == nil { 120 | t.Errorf("Exp file %q to be present, but it isnt", expFile) 121 | } 122 | id, _ := afero.ReadFile(f, expFile) 123 | if konf.KonfID(id) != expID { 124 | t.Errorf("Exp id to be %q but is %q", expID, id) 125 | } 126 | } 127 | 128 | func TestSetContext(t *testing.T) { 129 | storeDir := "./konf/store" 130 | activeDir := "./konf/active" 131 | ppid := os.Getppid() 132 | skm := testhelper.SampleKonfManager{} 133 | 134 | tt := map[string]struct { 135 | InID konf.KonfID 136 | StoreExists bool 137 | ExpErr error 138 | ExpKonfPath string 139 | }{ 140 | "normal write": { 141 | "dev-eu_dev-eu", 142 | true, 143 | nil, 144 | activeDir + "/" + string(konf.IDFromProcessID(ppid)) + ".yaml", 145 | }, 146 | "invalid id": { 147 | "i-am-invalid", 148 | false, 149 | fs.ErrNotExist, 150 | "", 151 | }, 152 | } 153 | 154 | for name, tc := range tt { 155 | 156 | t.Run(name, func(t *testing.T) { 157 | f := afero.NewMemMapFs() 158 | sm := &store.Storemanager{Fs: f, Storedir: storeDir, Activedir: activeDir} 159 | 160 | if tc.StoreExists { 161 | afero.WriteFile(f, storeDir+"/"+string(tc.InID)+".yaml", []byte(skm.SingleClusterSingleContextEU()), utils.KonfPerm) 162 | } 163 | 164 | resKonfPath, resError := setContext(tc.InID, sm) 165 | 166 | if !errors.Is(resError, tc.ExpErr) { 167 | t.Errorf("Want error '%s', got '%s'", tc.ExpErr, resError) 168 | } 169 | 170 | if resKonfPath != tc.ExpKonfPath { 171 | t.Errorf("Want konfPath '%s', got '%s'", tc.ExpKonfPath, resKonfPath) 172 | } 173 | 174 | if tc.ExpKonfPath != "" { 175 | _, err := f.Stat(tc.ExpKonfPath) 176 | if err != nil { 177 | if errors.Is(err, fs.ErrNotExist) { 178 | t.Errorf("Exp file %q to be present, but it is not", tc.ExpKonfPath) 179 | } else { 180 | t.Fatalf("Unexpected error occurred: '%s'", err) 181 | } 182 | } 183 | res, err := afero.ReadFile(f, tc.ExpKonfPath) 184 | if err != nil { 185 | t.Errorf("Wanted to read file %q, but failed: %q", tc.ExpKonfPath, err) 186 | } 187 | if string(res) != skm.SingleClusterSingleContextEU() { 188 | t.Errorf("Exp content %q, got %q", res, skm.SingleClusterSingleContextEU()) 189 | } 190 | } 191 | }) 192 | 193 | } 194 | } 195 | 196 | func TestSelectContext(t *testing.T) { 197 | storeDir := "./konf/store" 198 | activeDir := "./konf/active" 199 | fm := testhelper.FilesystemManager{Storedir: storeDir, Activedir: activeDir} 200 | f := testhelper.FSWithFiles(fm.StoreDir, fm.SingleClusterSingleContextEU, fm.SingleClusterSingleContextASIA)() 201 | sm := &store.Storemanager{Fs: f, Activedir: activeDir, Storedir: storeDir} 202 | 203 | // cases 204 | // - invalid selection 205 | // - prompt failure 206 | tt := map[string]struct { 207 | pf prompt.RunFunc 208 | expID konf.KonfID 209 | expErr error 210 | }{ 211 | "select asia": { 212 | func(s *promptui.Select) (int, error) { return 0, nil }, 213 | "dev-asia_dev-asia-1", 214 | nil, 215 | }, 216 | "select eu": { 217 | func(s *promptui.Select) (int, error) { return 1, nil }, 218 | "dev-eu_dev-eu-1", 219 | nil, 220 | }, 221 | "prompt failure": { 222 | func(s *promptui.Select) (int, error) { return 1, fmt.Errorf("err") }, 223 | "", 224 | fmt.Errorf("err"), 225 | }, 226 | "invalid selection": { 227 | func(s *promptui.Select) (int, error) { return 2, nil }, 228 | "", 229 | fmt.Errorf("invalid selection 2"), 230 | }, 231 | } 232 | 233 | for name, tc := range tt { 234 | t.Run(name, func(t *testing.T) { 235 | res, err := selectSingleKonf(sm, tc.pf) 236 | 237 | if !testhelper.EqualError(err, tc.expErr) { 238 | t.Errorf("Exp err %q, got %q", tc.expErr, err) 239 | } 240 | 241 | if res != tc.expID { 242 | t.Errorf("Exp id %q, got %q", tc.expID, res) 243 | } 244 | }) 245 | } 246 | } 247 | -------------------------------------------------------------------------------- /cmd/shellwrapper.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/spf13/cobra" 7 | ) 8 | 9 | type shellwrapperCmd struct { 10 | cmd *cobra.Command 11 | } 12 | 13 | func newShellwrapperCmd() *shellwrapperCmd { 14 | sc := shellwrapperCmd{} 15 | 16 | sc.cmd = &cobra.Command{ 17 | Use: "shellwrapper", 18 | Short: "Shell wrapper and hooks for konf command", 19 | Long: `Shell wrapper and hooks for konf command 20 | 21 | The output of this command should be sourced in your .rc file. 22 | 23 | See https://github.com/SimonTheLeg/konf-go#installation on how to do so 24 | `, 25 | RunE: sc.shellwrapper, 26 | Args: cobra.ExactArgs(1), 27 | } 28 | 29 | return &sc 30 | } 31 | 32 | func (c *shellwrapperCmd) shellwrapper(cmd *cobra.Command, args []string) error { 33 | var wrapper string 34 | var zsh = ` 35 | konf() { 36 | res=$(konf-go $@) 37 | # only change $KUBECONFIG if instructed by konf-go 38 | if [[ $res == "KUBECONFIGCHANGE:"* ]] 39 | then 40 | # this basically takes the line and cuts out the KUBECONFIGCHANGE Part 41 | export KUBECONFIG="${res#*KUBECONFIGCHANGE:}" 42 | else 43 | # this makes --help work 44 | echo "${res}" 45 | fi 46 | } 47 | konf_cleanup() { 48 | konf-go cleanup 49 | } 50 | add-zsh-hook zshexit konf_cleanup 51 | ` 52 | 53 | var bash = ` 54 | konf() { 55 | res=$(konf-go $@) 56 | # only change $KUBECONFIG if instructed by konf-go 57 | if [[ $res == "KUBECONFIGCHANGE:"* ]] 58 | then 59 | # this basically takes the line and cuts out the KUBECONFIGCHANGE Part 60 | export KUBECONFIG="${res#*KUBECONFIGCHANGE:}" 61 | else 62 | # this makes --help work 63 | echo "${res}" 64 | fi 65 | } 66 | konf_cleanup() { 67 | konf-go cleanup 68 | } 69 | 70 | trap konf_cleanup EXIT 71 | ` 72 | 73 | var fish = ` 74 | function konf -w konf-go 75 | set -f res (konf-go $argv) 76 | # only change $KUBECONFIG if instructed by konf-go 77 | if string match -q 'KUBECONFIGCHANGE:*' $res 78 | # this basically takes the line and cuts out the KUBECONFIGCHANGE Part 79 | set -gx KUBECONFIG (string replace -r '^KUBECONFIGCHANGE:' '' $res) 80 | else 81 | # this makes --help work 82 | # because fish does not support bracketed vars, we use printf instead 83 | printf "%s\n" $res 84 | end 85 | end 86 | 87 | function konf_cleanup 88 | konf-go cleanup 89 | end 90 | 91 | trap konf_cleanup EXIT 92 | ` 93 | 94 | switch args[0] { 95 | case "zsh": 96 | wrapper = zsh 97 | case "bash": 98 | wrapper = bash 99 | case "fish": 100 | wrapper = fish 101 | default: 102 | return fmt.Errorf("konf currently does not support %s", args[0]) 103 | } 104 | 105 | fmt.Println(wrapper) 106 | 107 | return nil 108 | } 109 | -------------------------------------------------------------------------------- /cmd/shellwrapper_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/simontheleg/konf-go/testhelper" 8 | ) 9 | 10 | func TestShellWrapperCmd(t *testing.T) { 11 | 12 | tt := map[string]struct { 13 | args []string 14 | ExpErr error 15 | }{ 16 | "zsh arg": { 17 | []string{"zsh"}, 18 | nil, 19 | }, 20 | "bash arg": { 21 | []string{"bash"}, 22 | nil, 23 | }, 24 | "fish arg": { 25 | []string{"fish"}, 26 | nil, 27 | }, 28 | "invalid arg": { 29 | []string{"invalid"}, 30 | fmt.Errorf("konf currently does not support invalid"), 31 | }, 32 | } 33 | 34 | for name, tc := range tt { 35 | t.Run(name, func(t *testing.T) { 36 | cs := newShellwrapperCmd() 37 | cmd := cs.cmd 38 | 39 | err := cmd.RunE(cmd, tc.args) 40 | 41 | if !testhelper.EqualError(err, tc.ExpErr) { 42 | t.Errorf("Want error '%s', got '%s'", tc.ExpErr, err) 43 | } 44 | }) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /cmd/version.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "runtime" 6 | 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | type versionInfo struct { 11 | GitVersion string 12 | GitCommit string 13 | BuildDate string 14 | GoVersion string 15 | Platform string 16 | Compiler string 17 | } 18 | 19 | // defaultVersion will be returned if no ldflags were provided e.g. when running 20 | // go build 21 | var defaultVersionInfo versionInfo = versionInfo{ 22 | GitVersion: "dev", 23 | GitCommit: "dev", 24 | BuildDate: "1970-01-01T00:00:00Z", 25 | GoVersion: runtime.Version(), 26 | Platform: fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH), 27 | Compiler: runtime.Compiler, 28 | } 29 | 30 | type versionCmd struct { 31 | cmd *cobra.Command 32 | } 33 | 34 | func newVersionCommand() *versionCmd { 35 | vc := &versionCmd{} 36 | vc.cmd = &cobra.Command{ 37 | Use: "version", 38 | Short: "Print version info", 39 | Long: "Print version and build info in a json format", 40 | RunE: vc.version, 41 | Args: cobra.ExactArgs(0), 42 | } 43 | 44 | return vc 45 | } 46 | 47 | func (c *versionCmd) version(cmd *cobra.Command, args []string) error { 48 | fmt.Println(versionStringWithOverrides(gitversion, gitcommit, builddate)) 49 | return nil 50 | } 51 | 52 | // variables to be overridden by ldflags 53 | var ( 54 | gitversion string 55 | gitcommit string 56 | builddate string 57 | ) 58 | 59 | // versionWithLDFlags takes in the overrides and returns a json compatible 60 | // version string 61 | func versionStringWithOverrides(gitversion string, gitcommit string, builddate string) string { 62 | v := defaultVersionInfo 63 | if gitversion != "" { 64 | v.GitVersion = gitversion 65 | } 66 | if gitcommit != "" { 67 | v.GitCommit = gitcommit 68 | } 69 | if builddate != "" { 70 | v.BuildDate = builddate 71 | } 72 | return fmt.Sprintf(`{"GitVersion":"%s","GitCommit":"%s","BuildDate":"%s","GoVersion":"%s","Platform":"%s","Compiler":"%s"}`, v.GitVersion, v.GitCommit, v.BuildDate, v.GoVersion, v.Platform, v.Compiler) 73 | } 74 | -------------------------------------------------------------------------------- /cmd/version_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "runtime" 7 | "testing" 8 | ) 9 | 10 | func TestVersionStringWithOverrides(t *testing.T) { 11 | tt := map[string]struct { 12 | gitversion string 13 | gitcommit string 14 | builddate string 15 | exp string 16 | }{ 17 | "no overrides": { 18 | exp: fmt.Sprintf(`{"GitVersion":"dev","GitCommit":"dev","BuildDate":"1970-01-01T00:00:00Z","GoVersion":"%s","Platform":"%s","Compiler":"%s"}`, runtime.Version(), fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH), runtime.Compiler), 19 | }, 20 | "gitversion override": { 21 | gitversion: "override", 22 | exp: fmt.Sprintf(`{"GitVersion":"override","GitCommit":"dev","BuildDate":"1970-01-01T00:00:00Z","GoVersion":"%s","Platform":"%s","Compiler":"%s"}`, runtime.Version(), fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH), runtime.Compiler), 23 | }, 24 | "gitcommit override": { 25 | gitcommit: "override", 26 | exp: fmt.Sprintf(`{"GitVersion":"dev","GitCommit":"override","BuildDate":"1970-01-01T00:00:00Z","GoVersion":"%s","Platform":"%s","Compiler":"%s"}`, runtime.Version(), fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH), runtime.Compiler), 27 | }, 28 | "builddate override": { 29 | builddate: "override", 30 | exp: fmt.Sprintf(`{"GitVersion":"dev","GitCommit":"dev","BuildDate":"override","GoVersion":"%s","Platform":"%s","Compiler":"%s"}`, runtime.Version(), fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH), runtime.Compiler), 31 | }, 32 | "all values override": { 33 | gitversion: "override", 34 | gitcommit: "override", 35 | builddate: "override", 36 | exp: fmt.Sprintf(`{"GitVersion":"override","GitCommit":"override","BuildDate":"override","GoVersion":"%s","Platform":"%s","Compiler":"%s"}`, runtime.Version(), fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH), runtime.Compiler), 37 | }, 38 | } 39 | 40 | for name, tc := range tt { 41 | t.Run(name, func(t *testing.T) { 42 | res := versionStringWithOverrides(tc.gitversion, tc.gitcommit, tc.builddate) 43 | 44 | if res != tc.exp { 45 | t.Errorf("Exp res to be '%s', got '%s'", tc.exp, res) 46 | } 47 | 48 | // check if the result is a valid json 49 | js := json.RawMessage{} 50 | if err := json.Unmarshal([]byte(res), &js); err != nil { 51 | t.Errorf("Exp to unmarshal version string without error, but got '%v'", err) 52 | } 53 | }) 54 | } 55 | 56 | } 57 | -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "os" 5 | ) 6 | 7 | var curConf *Config = &Config{} 8 | 9 | // Config describes all values that can currently be configured for konf 10 | type Config struct { 11 | KonfDir string 12 | Silent bool 13 | } 14 | 15 | // DefaultConfig returns an initialized config based on the users HomeDir 16 | func DefaultConfig() (*Config, error) { 17 | c := &Config{} 18 | 19 | home, err := os.UserHomeDir() 20 | if err != nil { 21 | return nil, err 22 | } 23 | 24 | c.KonfDir = home + "/.kube/konfs" 25 | c.Silent = false 26 | 27 | return c, nil 28 | } 29 | 30 | // SetGlobalConfig sets the config to the config supplied as its argument 31 | func SetGlobalConfig(or *Config) { 32 | curConf = or 33 | } 34 | 35 | // Currently there is no need to customize store and active configs individually. 36 | // Setting the konfDir should be enough 37 | 38 | // ActiveDir returns the currently configured active directory 39 | func ActiveDir() string { 40 | return curConf.KonfDir + "/active" 41 | } 42 | 43 | // StoreDir returns the currently configured store directory 44 | func StoreDir() string { 45 | return curConf.KonfDir + "/store" 46 | } 47 | 48 | // LatestKonfFilePath returns the currently configured latest konf file 49 | func LatestKonfFilePath() string { 50 | return curConf.KonfDir + "/latestkonf" 51 | } 52 | -------------------------------------------------------------------------------- /doc/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SimonTheLeg/konf-go/00cd724d1a36e1842d23281bd6c144e739523477/doc/demo.gif -------------------------------------------------------------------------------- /doc/demo_source.cast: -------------------------------------------------------------------------------- 1 | # This contains the raw asciinema files for the demo 2 | # It was exported to the gif using 'asciicast2gif -S 2' and the default theme. Afterwards the result was cropped manually 3 | # Unfortunately setting the row height directly did not work, as it only removes the space from the lower section, but 4 | # not from both of them equally 5 | {"version":2,"width":139,"height":57,"timestamp":1644260729,"theme":{},"env":{"SHELL":"/bin/zsh","TERM":"xterm-256color"}} 6 | [0.214,"o","\u001b]133;C;\u0007"] 7 | [0.2194,"o","\u001b[?1049h\u001b[22;0;0t\u001b[?1h\u001b=\u001b[H\u001b[2J\u001b[?12l\u001b[?25h\u001b[?1000l\u001b[?1002l\u001b[?1003l\u001b[?1006l\u001b[?1005l\u001b(B\u001b[m\u001b[?12l\u001b[?25h\u001b[?1006l\u001b[?1000l\u001b[?1002l\u001b[?1003l\u001b[?2004l\u001b[1;1H\u001b[1;57r\u001b[\u003ec\u001b[\u003eq\u001b[2;3H\u001b[6 q\u001b[?12l\u001b[?25h\u001b[?1006h\u001b[?1002h\u001b[?2004h"] 8 | [0.22060000000000002,"o","\u001b[?25l\u001b[28;1H\u001b[32m──────────────────────────────────────────────────────────────────────\u001b[39m─────────────────────────────────────────────────────────────────────\u001b(B\u001b[m\u001b[32m\u001b[1;1H~/simontheleg/screencast\u001b[39m \u001b[K\u001b[32m\u001b[1m\r\n❯\u001b(B\u001b[m \u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\u001b[32m\u001b[2B~/simontheleg/screencast\u001b[39m \u001b[K\u001b[32m\u001b[1m\r\n❯\u001b(B\u001b[m \u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\u001b[2 q\u001b[?25l\u001b[30m\u001b[42m\r\n \u001b[46m2:zsh*\u001b[42m \u003c'/Users/simonbein/.tmux/plugins/tmux-co\u001b(B\u001b[m\u001b[?12l\u001b[?25"] 9 | [0.22060000000000002,"o","h\u001b[2;3H\u001b[6 q\u001b[?12l\u001b[?25h\u001b(B\u001b[m\u001b[?12l\u001b[?25h\u001b[?1006l\u001b[?1000l\u001b[?1002l\u001b[?1003l\u001b[?2004l\u001b[1;1H\u001b[1;57r\u001b[2;3H\u001b[?1006h\u001b[?1002h\u001b[?2004h"] 10 | [0.2212,"o","\u001b[?25l\u001b[28;1H\u001b[32m──────────────────────────────────────────────────────────────────────\u001b[39m─────────────────────────────────────────────────────────────────────\u001b(B\u001b[m\u001b[32m\u001b[1;1H~/simontheleg/screencast\u001b[39m \u001b[K\u001b[32m\u001b[1m\r\n❯\u001b(B\u001b[m \u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\u001b[32m\u001b[2B~/simontheleg/screencast\u001b[39m \u001b[K\u001b[32m\u001b[1m\r\n❯\u001b(B\u001b[m \u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\u001b[2 q\u001b[?25l\u001b[30m\u001b[42m\r\n \u001b[46m2:zsh*\u001b[42m \u003c'/Users/simonbein/.tmux/plugins/tmux-co\u001b(B\u001b[m\u001b[?12l\u001b[?25"] 11 | [0.2212,"o","h\u001b[2;3H\u001b[6 q\u001b[?12l\u001b[?25h"] 12 | [0.2212,"o","\u001b[?1004h\u001b[?7727h\u001b[?69h\u001b[?1004h\u001b[?7727h\u001b[1;57r\u001b[s\u001b[2;3H"] 13 | [0.2572,"o","\u001bP=1s\u001b\\\u001b[?25l\u001b[2 q\u001b[?25l\u001b[30m\u001b[42m\u001b[57;1H \u001b[46m2:zsh*\u001b[42m \"simonbein@Simons-MBP-\" 20:05 07-Feb-22\u001b(B\u001b[m\u001b[?12l\u001b[?25h\u001b[2;3H\u001b[6 q\u001b[?12l\u001b[?25h\u001bP=2s\u001b\\"] 14 | [0.8523999999999999,"o","\u001b[30m\u001b[1m#\u001b(B\u001b[m"] 15 | [0.9819999999999999,"o","\u0008\u001b[30m\u001b[1m# \u001b(B\u001b[m"] 16 | [1.1074,"o","\u001b[2;3H\u001b[30m\u001b[1m# f\u001b(B\u001b[m"] 17 | [1.2076,"o","\u0008\u001b[30m\u001b[1mfi\u001b(B\u001b[m"] 18 | [1.2694,"o","\u0008\u001b[30m\u001b[1mir\u001b(B\u001b[m"] 19 | [1.3456000000000001,"o","\u0008\u001b[30m\u001b[1mrs\u001b(B\u001b[m"] 20 | [1.4188,"o","\u0008\u001b[30m\u001b[1mst\u001b(B\u001b[m"] 21 | [1.4662000000000002,"o","\u0008\u001b[30m\u001b[1mt \u001b(B\u001b[m"] 22 | [1.5466000000000002,"o","\u0008\u001b[30m\u001b[1m w\u001b(B\u001b[m"] 23 | [1.612,"o","\u0008\u001b[30m\u001b[1mwe\u001b(B\u001b[m"] 24 | [1.6762,"o","\u0008\u001b[30m\u001b[1me \u001b(B\u001b[m"] 25 | [1.753,"o","\u0008\u001b[30m\u001b[1m h\u001b(B\u001b[m"] 26 | [1.813,"o","\u0008\u001b[30m\u001b[1mha\u001b(B\u001b[m"] 27 | [1.8688,"o","\u0008\u001b[30m\u001b[1mav\u001b(B\u001b[m"] 28 | [1.9354000000000002,"o","\u0008\u001b[30m\u001b[1mve\u001b(B\u001b[m"] 29 | [2.047,"o","\u0008\u001b[30m\u001b[1me \u001b(B\u001b[m"] 30 | [2.1616,"o","\u0008\u001b[30m\u001b[1m t\u001b(B\u001b[m"] 31 | [2.2438,"o","\u0008\u001b[30m\u001b[1mto\u001b(B\u001b[m"] 32 | [2.2948,"o","\u0008\u001b[30m\u001b[1mo \u001b(B\u001b[m"] 33 | [2.368,"o","\u0008\u001b[30m\u001b[1m i\u001b(B\u001b[m"] 34 | [2.4568,"o","\u0008\u001b[30m\u001b[1mim\u001b(B\u001b[m"] 35 | [2.569,"o","\u0008\u001b[30m\u001b[1mmp\u001b(B\u001b[m"] 36 | [2.6068,"o","\u0008\u001b[30m\u001b[1mpo\u001b(B\u001b[m"] 37 | [2.6944,"o","\u0008\u001b[30m\u001b[1mor\u001b(B\u001b[m"] 38 | [2.8251999999999997,"o","\u0008\u001b[30m\u001b[1mrt\u001b(B\u001b[m"] 39 | [2.8648,"o","\u0008\u001b[30m\u001b[1mt \u001b(B\u001b[m"] 40 | [2.9584,"o","\u0008\u001b[30m\u001b[1m o\u001b(B\u001b[m"] 41 | [3.0273999999999996,"o","\u0008\u001b[30m\u001b[1mou\u001b(B\u001b[m"] 42 | [3.0946,"o","\u0008\u001b[30m\u001b[1mur\u001b(B\u001b[m"] 43 | [3.1395999999999997,"o","\u0008\u001b[30m\u001b[1mr \u001b(B\u001b[m"] 44 | [3.2236000000000002,"o","\u0008\u001b[30m\u001b[1m k\u001b(B\u001b[m"] 45 | [3.3604000000000003,"o","\u0008\u001b[30m\u001b[1mku\u001b(B\u001b[m"] 46 | [3.388,"o","\u0008\u001b[30m\u001b[1mub\u001b(B\u001b[m"] 47 | [3.4762,"o","\u0008\u001b[30m\u001b[1mbe\u001b(B\u001b[m"] 48 | [3.6178,"o","\u0008\u001b[30m\u001b[1mec\u001b(B\u001b[m"] 49 | [3.6712000000000002,"o","\u0008\u001b[30m\u001b[1mco\u001b(B\u001b[m"] 50 | [3.73,"o","\u0008\u001b[30m\u001b[1mon\u001b(B\u001b[m"] 51 | [3.7894,"o","\u0008\u001b[30m\u001b[1mnf\u001b(B\u001b[m"] 52 | [3.8512,"o","\u0008\u001b[30m\u001b[1mfi\u001b(B\u001b[m"] 53 | [3.9388,"o","\u0008\u001b[30m\u001b[1mig\u001b(B\u001b[m"] 54 | [4.0192000000000005,"o","\u0008\u001b[30m\u001b[1mgs\u001b(B\u001b[m"] 55 | [4.2004,"o","\r\n\u001b[?2004l"] 56 | [4.2010000000000005,"o"," \u001b[3;1H"] 57 | [4.2088,"o","\u001bP=1s\u001b\\\u001b[?25l\u001b[25B\u001b[32m──────────────────────────────────────────────────────────────────────\u001b[39m─────────────────────────────────────────────────────────────────────\u001b(B\u001b[m\u001b[?12l\u001b[?25h\u001b[3;1H\u001bP=2s\u001b\\"] 58 | [4.2358,"o","\u001bP=1s\u001b\\\u001b[1;27r\u001b[1;1H\u001b[4;27r\u001b[24S\u001b[3;1H\u001b[K\u001b[32m~/simontheleg/screencast\u001b[39m \r\n\u001b[32m\u001b[1m❯\u001b(B\u001b[m \u001b[K\u001b[1;57r\u001b[4;3H\u001bP=2s\u001b\\"] 59 | [4.237,"o","\u001b[?2004h"] 60 | [4.6768,"o","\u001b[31m\u001b[1mk\u001b(B\u001b[m"] 61 | [4.792,"o","\u0008\u001b[31m\u001b[1mko\u001b(B\u001b[m"] 62 | [4.851399999999999,"o","\u001b[4;3H\u001b[31m\u001b[1mkon\u001b(B\u001b[m"] 63 | [4.936599999999999,"o","\u001b[3G\u001b[32mkonf\u001b(B\u001b[m"] 64 | [4.984599999999999,"o"," "] 65 | [5.0379999999999985,"o","i"] 66 | [5.137599999999999,"o","m"] 67 | [5.228799999999999,"o","p"] 68 | [5.276199999999999,"o","o"] 69 | [5.384199999999999,"o","r"] 70 | [5.562399999999999,"o","t"] 71 | [5.629,"o"," "] 72 | [5.8767999999999985,"o","\u001b[4mr\u001b(B\u001b[m"] 73 | [5.964999999999999,"o","\u0008\u001b[4mra\u001b(B\u001b[m"] 74 | [6.044199999999999,"o","\u0008\u001b[4mai\u001b(B\u001b[m"] 75 | [6.1234,"o","\u0008\u001b[4min\u001b(B\u001b[m"] 76 | [6.301,"o","\u0008\u001b[4mnbows-and-unicorns.yaml\u001b(B\u001b[m\u001b[1m \u001b(B\u001b[m"] 77 | [6.5644,"o","\u0008 \u0008"] 78 | [6.5674,"o","\r\n\u001b[?2004l"] 79 | [6.5691999999999995,"o","\u001bP=1s\u001b\\\u001b[?25l\u001b[23B\u001b[32m──────────────────────────────────────────────────────────────────────\u001b[39m─────────────────────────────────────────────────────────────────────\u001b[2 q\u001b[?25l\u001b(B\u001b[m\u001b[30m\u001b[42m\u001b[57;1H \u001b[46m2:zsh*\u001b[42m \"konf import rainbows-\" 20:05 07-Feb-22\u001b(B\u001b[m\u001b[?12l\u001b[?25h\u001b[5;1H\u001b[6 q\u001b[?12l\u001b[?25h\u001bP=2s\u001b\\"] 80 | [6.6316,"o","INFO: Imported konf from \"rainbows-and-unicorns.yaml\" successfully into \"/Users/simonbein/.kube/konfs/store/rainbows-and-unicorns_cluster.yaml\"\r\n"] 81 | [6.632199999999999,"o","\r\n"] 82 | [6.632199999999999,"o"," \u001b[8;1H"] 83 | [6.638199999999999,"o","\u001bP=1s\u001b\\\u001b[?25l\u001b[20B\u001b[32m──────────────────────────────────────────────────────────────────────\u001b[39m─────────────────────────────────────────────────────────────────────\u001b[2 q\u001b[?25l\u001b(B\u001b[m\u001b[30m\u001b[42m\u001b[57;1H \u001b[46m2:zsh*\u001b[42m \"simonbein@Simons-MBP-\" 20:05 07-Feb-22\u001b(B\u001b[m\u001b[?12l\u001b[?25h\u001b[8;1H\u001b[6 q\u001b[?12l\u001b[?25h\u001bP=2s\u001b\\"] 84 | [6.664,"o","\u001bP=1s\u001b\\\u001b[1;27r\u001b[1;1H\u001b[9;27r\u001b[19S\u001b[8;1H\u001b[K\u001b[32m~/simontheleg/screencast\u001b[39m \r\n\u001b[32m\u001b[1m❯\u001b(B\u001b[m \u001b[K\u001b[1;57r\u001b[9;3H\u001bP=2s\u001b\\"] 85 | [6.664,"o","\u001b[?2004h"] 86 | [6.9262,"o","\u001b[31m\u001b[1mk\u001b(B\u001b[m"] 87 | [7.0222,"o","\u0008\u001b[31m\u001b[1mko\u001b(B\u001b[m"] 88 | [7.081,"o","\u001b[9;3H\u001b[31m\u001b[1mkon\u001b(B\u001b[m"] 89 | [7.1662,"o","\u001b[3G\u001b[32mkonf\u001b(B\u001b[m"] 90 | [7.2394,"o"," "] 91 | [7.3054,"o","i"] 92 | [7.4026000000000005,"o","m"] 93 | [7.516000000000001,"o","p"] 94 | [7.5628,"o","o"] 95 | [7.6702,"o","r"] 96 | [7.820200000000001,"o","t"] 97 | [7.886800000000001,"o"," "] 98 | [7.990600000000001,"o","\u001b[4ml\u001b(B\u001b[m"] 99 | [8.1226,"o","\u0008\u001b[4mli\u001b(B\u001b[m"] 100 | [8.305,"o","\u0008\u001b[4mit\u001b(B\u001b[m"] 101 | [8.585799999999999,"o","\u0008\u001b[4mttle-dark-age.yaml\u001b(B\u001b[m\u001b[1m \u001b(B\u001b[m"] 102 | [8.887599999999999,"o","\u0008 \u0008"] 103 | [8.8906,"o","\r\n\u001b[?2004l"] 104 | [8.892999999999999,"o","\u001bP=1s\u001b\\\u001b[?25l\u001b[18B\u001b[32m──────────────────────────────────────────────────────────────────────\u001b[39m─────────────────────────────────────────────────────────────────────\u001b[2 q\u001b[?25l\u001b(B\u001b[m\u001b[30m\u001b[42m\u001b[57;1H \u001b[46m2:zsh*\u001b[42m \"konf import little-da\" 20:05 07-Feb-22\u001b(B\u001b[m\u001b[?12l\u001b[?25h\u001b[10;1H\u001b[6 q\u001b[?12l\u001b[?25h\u001bP=2s\u001b\\"] 105 | [8.9554,"o","INFO: Imported konf from \"little-dark-age.yaml\" successfully into \"/Users/simonbein/.kube/konfs/store/little-dark-age_cluster.yaml\"\r\n"] 106 | [8.9554,"o","\r\n"] 107 | [8.956,"o"," \u001b[12;1H"] 108 | [8.9614,"o","\u001bP=1s\u001b\\\u001b[?25l\u001b[16B\u001b[32m──────────────────────────────────────────────────────────────────────\u001b[39m─────────────────────────────────────────────────────────────────────\u001b[2 q\u001b[?25l\u001b(B\u001b[m\u001b[30m\u001b[42m\u001b[57;1H \u001b[46m2:zsh*\u001b[42m \"simonbein@Simons-MBP-\" 20:05 07-Feb-22\u001b(B\u001b[m\u001b[?12l\u001b[?25h\u001b[12;1H\u001b[6 q\u001b[?12l\u001b[?25h\u001bP=2s\u001b\\"] 109 | [8.988399999999999,"o","\u001bP=1s\u001b\\\u001b[1;27r\u001b[1;1H\u001b[13;27r\u001b[15S\u001b[12;1H\u001b[K\u001b[32m~/simontheleg/screencast\u001b[39m \r\n\u001b[32m\u001b[1m❯\u001b(B\u001b[m \u001b[K\u001b[1;57r\u001b[13;3H\u001bP=2s\u001b\\"] 110 | [8.988999999999999,"o","\u001b[?2004h"] 111 | [9.6214,"o","\u001b[30m\u001b[1m#\u001b(B\u001b[m"] 112 | [9.741399999999999,"o","\u0008\u001b[30m\u001b[1m# \u001b(B\u001b[m"] 113 | [9.930399999999999,"o","\u001b[13;3H\u001b[30m\u001b[1m# n\u001b(B\u001b[m"] 114 | [10.071999999999997,"o","\u0008\u001b[30m\u001b[1mno\u001b(B\u001b[m"] 115 | [10.171599999999998,"o","\u0008\u001b[30m\u001b[1mow\u001b(B\u001b[m"] 116 | [10.2532,"o","\u0008\u001b[30m\u001b[1mw \u001b(B\u001b[m"] 117 | [10.361799999999999,"o","\u0008\u001b[30m\u001b[1m w\u001b(B\u001b[m"] 118 | [10.417599999999998,"o","\u0008\u001b[30m\u001b[1mwe\u001b(B\u001b[m"] 119 | [10.527999999999999,"o","\u0008\u001b[30m\u001b[1me \u001b(B\u001b[m"] 120 | [10.673799999999998,"o","\u0008\u001b[30m\u001b[1m c\u001b(B\u001b[m"] 121 | [10.7032,"o","\u0008\u001b[30m\u001b[1mca\u001b(B\u001b[m"] 122 | [10.804599999999999,"o","\u0008\u001b[30m\u001b[1man\u001b(B\u001b[m"] 123 | [10.8712,"o","\u0008\u001b[30m\u001b[1mn \u001b(B\u001b[m"] 124 | [11.0824,"o","\u0008\u001b[30m\u001b[1m e\u001b(B\u001b[m"] 125 | [11.1586,"o","\u0008\u001b[30m\u001b[1mea\u001b(B\u001b[m"] 126 | [11.2918,"o","\u0008\u001b[30m\u001b[1mas\u001b(B\u001b[m"] 127 | [11.371,"o","\u0008\u001b[30m\u001b[1msi\u001b(B\u001b[m"] 128 | [11.5132,"o","\u0008\u001b[30m\u001b[1mil\u001b(B\u001b[m"] 129 | [11.5672,"o","\u0008\u001b[30m\u001b[1mly\u001b(B\u001b[m"] 130 | [11.620000000000001,"o","\u0008\u001b[30m\u001b[1my \u001b(B\u001b[m"] 131 | [11.7292,"o","\u0008\u001b[30m\u001b[1m s\u001b(B\u001b[m"] 132 | [11.833000000000002,"o","\u0008\u001b[30m\u001b[1msw\u001b(B\u001b[m"] 133 | [11.9212,"o","\u0008\u001b[30m\u001b[1mwi\u001b(B\u001b[m"] 134 | [11.9722,"o","\u0008\u001b[30m\u001b[1mit\u001b(B\u001b[m"] 135 | [12.0988,"o","\u0008\u001b[30m\u001b[1mtc\u001b(B\u001b[m"] 136 | [12.163000000000002,"o","\u0008\u001b[30m\u001b[1mch\u001b(B\u001b[m"] 137 | [12.220600000000003,"o","\u0008\u001b[30m\u001b[1mh \u001b(B\u001b[m"] 138 | [12.305200000000001,"o","\u0008\u001b[30m\u001b[1m b\u001b(B\u001b[m"] 139 | [12.412600000000003,"o","\u0008\u001b[30m\u001b[1mbe\u001b(B\u001b[m"] 140 | [12.485800000000003,"o","\u0008\u001b[30m\u001b[1met\u001b(B\u001b[m"] 141 | [12.660400000000001,"o","\u0008\u001b[30m\u001b[1mtw\u001b(B\u001b[m"] 142 | [12.829000000000002,"o","\u0008\u001b[30m\u001b[1mwe\u001b(B\u001b[m"] 143 | [12.934000000000003,"o","\u0008\u001b[30m\u001b[1mee\u001b(B\u001b[m"] 144 | [13.045600000000002,"o","\u0008\u001b[30m\u001b[1men\u001b(B\u001b[m"] 145 | [13.1122,"o","\u0008\u001b[30m\u001b[1mn \u001b(B\u001b[m"] 146 | [13.2448,"o","\u0008\u001b[30m\u001b[1m t\u001b(B\u001b[m"] 147 | [13.2916,"o","\u0008\u001b[30m\u001b[1mth\u001b(B\u001b[m"] 148 | [13.389999999999999,"o","\u0008\u001b[30m\u001b[1mhe\u001b(B\u001b[m"] 149 | [13.5022,"o","\u0008\u001b[30m\u001b[1mem\u001b(B\u001b[m"] 150 | [13.7812,"o","\r\n\u001b[?2004l"] 151 | [13.7818,"o"," \u001b[14;1H"] 152 | [13.7902,"o","\u001bP=1s\u001b\\\u001b[?25l\u001b[14B\u001b[32m──────────────────────────────────────────────────────────────────────\u001b[39m─────────────────────────────────────────────────────────────────────\u001b(B\u001b[m\u001b[?12l\u001b[?25h\u001b[14;1H\u001bP=2s\u001b\\"] 153 | [13.8178,"o","\u001bP=1s\u001b\\\u001b[1;27r\u001b[1;1H\u001b[15;27r\u001b[13S\u001b[14;1H\u001b[K\u001b[32m~/simontheleg/screencast\u001b[39m \r\n\u001b[32m\u001b[1m❯\u001b(B\u001b[m \u001b[K\u001b[1;57r\u001b[15;3H\u001bP=2s\u001b\\"] 154 | [13.8184,"o","\u001b[?2004h"] 155 | [14.1442,"o","\u001b[31m\u001b[1mk\u001b(B\u001b[m"] 156 | [14.230599999999999,"o","\u0008\u001b[31m\u001b[1mko\u001b(B\u001b[m"] 157 | [14.289399999999999,"o","\u001b[15;3H\u001b[31m\u001b[1mkon\u001b(B\u001b[m"] 158 | [14.355400000000001,"o","\u001b[3G\u001b[32mkonf\u001b(B\u001b[m"] 159 | [14.44,"o"," "] 160 | [14.5204,"o","s"] 161 | [14.5954,"o","e"] 162 | [14.716,"o","t"] 163 | [14.9866,"o","\r\n\u001b[?2004l"] 164 | [14.988399999999999,"o","\u001bP=1s\u001b\\\u001b[?25l\u001b[12B\u001b[32m──────────────────────────────────────────────────────────────────────\u001b[39m─────────────────────────────────────────────────────────────────────\u001b[2 q\u001b[?25l\u001b(B\u001b[m\u001b[30m\u001b[42m\u001b[57;1H \u001b[46m2:zsh*\u001b[42m \"konf set\" 20:05 07-Feb-22\u001b(B\u001b[m\u001b[?12l\u001b[?25h\u001b[16;1H\u001b[6 q\u001b[?12l\u001b[?25h\u001bP=2s\u001b\\"] 165 | [15.051999999999998,"o","\u001b[?25l"] 166 | [15.052599999999998,"o","\u001b[2mUse the arrow keys to navigate:\u001b(B\u001b[m \u001b[2m↓\u001b(B\u001b[m \u001b[2m↑\u001b(B\u001b[m \u001b[2m→\u001b(B\u001b[m \u001b[2m←\u001b(B\u001b[m \u001b[2mand\u001b(B\u001b[m \u001b[2m/\u001b(B\u001b[m \u001b[2mtoggles search\r\n\u001b(B\u001b[m\u001b[34m?\u001b[39m Context | Cluster | File : \r\n ▸\u001bP=1s\u001b\\ \u001b[36m\u001b[1mlittle-dark-age \u001b(B\u001b[m | \u001b[36m\u001b[1mcluster \u001b(B\u001b[m | \u001b[36m\u001b[1m/Users/simonbein/.kube/ko\u001b(B\u001b[m |\r\n rainbows-and-unicorns | cluster | /Users/simonbein/.kube/ko |\u001b[1;27r\u001b[1;1H\u001b[21;27r\u001b[7S\u001b[20;1H\u001b[K \u001b[1;57r\u001b[20;1H\u001bP=2s\u001b\\"] 167 | [15.8692,"o","\u001bP=1s\u001b\\\u001b[1;27r\u001b[1;1H\u001b[21;27r\u001b[7S\u001b[20;1H\u001b[Kj\u001b[1;57r\u001b[20;2H\u001bP=2s\u001b\\"] 168 | [15.8692,"o","\u001bP=1s\u001b\\\u001b[1;27r\u001b[1;1H\u001b[21;27r\u001b[7S\u001b[20;2H\u001b[K\u001b[16;1H\u001b[2mUse the arrow keys to navigate:\u001b(B\u001b[m \u001b[K\u001b[20;1H\u001b[K\u001b[16;33H\u001b[2m↓\u001b(B\u001b[m \u001b[2m↑\u001b(B\u001b[m \u001b[2m→\u001b(B\u001b[m \u001b[2m←\u001b(B\u001b[m \u001b[2mand\u001b(B\u001b[m \u001b[2m/\u001b(B\u001b[m \u001b[2mtoggles search\r\n\u001b(B\u001b[m\u001b[34m?\u001b[39m Context | Cluster | File : \u001b[K\r\n little-dark-age | cluster | /Users/simonbein/.kube/ko |\u001b[K\r\n \u001b[K▸ \u001b[36m\u001b[1mrainbows-and-unicorns \u001b(B\u001b[m | \u001b[36m\u001b[1mcluster \u001b(B\u001b[m | \u001b[36m\u001b[1m/Users/simonbein/.kube/ko\u001b(B\u001b[m |\nj\u001b[1;27r\u001b[1;1H\u001b[21;27r\u001b[7S\u001b[20;89H\u001b[K\r \u001b[K\u001b[1;57r\u001b[20;1H\u001bP=2s\u001b\\"] 169 | [16.4122,"o","\u001bP=1s\u001b\\\u001b[1;27r\u001b[1;1H\u001b[21;27r\u001b[7S\u001b[20;1H\u001b[K \u001b[1;27r\u001b[1;1H\u001b[21;27r\u001b[7S\u001b[20;1H\u001b[K \u001b[1;57r\u001b[20;1H\u001bP=2s\u001b\\"] 170 | [16.4122,"o","\u001bP=1s\u001b\\\u001b[4A\u001b[K\u001b[1;27r\u001b[1;1H\u001b[17;27r\u001b[11S\u001b[16;1H\u001b[K\u001b[1;57r\u001b[16;1H\u001b[?12l\u001b[?25h\u001bP=2s\u001b\\"] 171 | [16.4128,"o","INFO: Setting context to \"rainbows-and-unicorns_cluster\"\r\n"] 172 | [16.413400000000003,"o"," \u001b[17;1H"] 173 | [16.423000000000002,"o","\u001bP=1s\u001b\\\u001b[?25l\u001b[11B\u001b[32m──────────────────────────────────────────────────────────────────────\u001b[39m─────────────────────────────────────────────────────────────────────\u001b[2 q\u001b[?25l\u001b(B\u001b[m\u001b[30m\u001b[42m\u001b[57;1H \u001b[46m2:zsh*\u001b[42m \"simonbein@Simons-MBP-\" 20:05 07-Feb-22\u001b(B\u001b[m\u001b[?12l\u001b[?25h\u001b[17;1H\u001b[6 q\u001b[?12l\u001b[?25h\u001bP=2s\u001b\\"] 174 | [16.451800000000002,"o","\u001bP=1s\u001b\\\u001b[1;27r\u001b[1;1H\u001b[18;27r\u001b[10S\u001b[17;1H\u001b[K\u001b[34m☸ rainbows-and-unicorns\u001b[39m in \u001b[32m~/simontheleg/screencast\u001b[39m took \u001b[33m\u001b[1m2s\u001b(B\u001b[m \r\n\u001b[32m\u001b[1m❯\u001b(B\u001b[m \u001b[K\u001b[1;57r\u001b[18;3H\u001bP=2s\u001b\\"] 175 | [16.4524,"o","\u001b[?2004h"] 176 | [17.080000000000002,"o","\u001b[31m\u001b[1mk\u001b(B\u001b[m"] 177 | [17.2162,"o","\u0008\u001b[31m\u001b[1mku\u001b(B\u001b[m"] 178 | [17.281000000000002,"o","\u001b[18;3H\u001b[31m\u001b[1mkub\u001b(B\u001b[m"] 179 | [17.399800000000003,"o","\u0008\u001b[31m\u001b[1mbe\u001b(B\u001b[m"] 180 | [17.549200000000003,"o","\u0008\u001b[31m\u001b[1mec\u001b(B\u001b[m"] 181 | [17.7268,"o","\u0008\u001b[31m\u001b[1mct\u001b(B\u001b[m"] 182 | [17.8288,"o","\u001b[3G\u001b[32mkubectl\u001b(B\u001b[m"] 183 | [17.8966,"o"," "] 184 | [18.223,"o","\u001bP=1s\u001b\\\u001b[?25l\u001b[2 q\u001b[?25l\u001b[30m\u001b[42m\u001b[57;1H \u001b[46m2:zsh*\u001b[42m \"simonbein@Simons-MBP-\" 20:06 07-Feb-22\u001b(B\u001b[m\u001b[?12l\u001b[?25h\u001b[18;11H\u001b[6 q\u001b[?12l\u001b[?25h\u001bP=2s\u001b\\"] 185 | [18.381999999999998,"o","\u001b[4mc\u001b(B\u001b[m"] 186 | [18.4438,"o","\u0008co"] 187 | [18.514,"o","n"] 188 | [18.5722,"o","f"] 189 | [18.662799999999997,"o","i"] 190 | [18.749799999999997,"o","g"] 191 | [18.836799999999997,"o"," "] 192 | [18.998199999999997,"o","v"] 193 | [19.071999999999996,"o","i"] 194 | [19.151799999999998,"o","e"] 195 | [19.220799999999997,"o","w"] 196 | [19.4404,"o","\r\n\u001b[?2004l"] 197 | [19.4422,"o","\u001bP=1s\u001b\\\u001b[?25l\u001b[9B\u001b[32m──────────────────────────────────────────────────────────────────────\u001b[39m─────────────────────────────────────────────────────────────────────\u001b[2 q\u001b[?25l\u001b(B\u001b[m\u001b[30m\u001b[42m\u001b[57;1H \u001b[46m2:zsh*\u001b[42m \"kubectl config view\" 20:06 07-Feb-22\u001b(B\u001b[m\u001b[?12l\u001b[?25h\u001b[19;1H\u001b[6 q\u001b[?12l\u001b[?25h\u001bP=2s\u001b\\"] 198 | [19.473399999999998,"o","\u001bP=1s\u001b\\\u001b[1;27r\u001b[1;1H\u001b[8S\u001b[10BapiVersion: v1\r\nclusters:\r\n- cluster:\r\n server: rainbows-and-unicorns.greatdomain.com\r\n name: cluster\r\ncontexts:\r\n- context:\r\n cluster: cluster\r\n user: default\r\n name: rainbows-and-unicorns\u001b[K\r\ncurrent-context: rainbows-and-unicorns\u001b[K\r\nkind: Config\u001b[K\r\npreferences: {}\u001b[K\r\nusers:\u001b[K\r\n- name: default\u001b[K\r\n user: {}\u001b[K\r\n\u001b[K\u001b[1;57r\u001b[27;1H\u001bP=2s\u001b\\"] 199 | [19.474599999999995,"o"," \u001b[27;1H"] 200 | [19.4806,"o","\u001bP=1s\u001b\\\u001b[?25l\r\n\u001b[32m──────────────────────────────────────────────────────────────────────\u001b[39m─────────────────────────────────────────────────────────────────────\u001b[2 q\u001b[?25l\u001b(B\u001b[m\u001b[30m\u001b[42m\u001b[57;1H \u001b[46m2:zsh*\u001b[42m \"simonbein@Simons-MBP-\" 20:06 07-Feb-22\u001b(B\u001b[m\u001b[?12l\u001b[?25h\u001b[27;1H\u001b[6 q\u001b[?12l\u001b[?25h\u001bP=2s\u001b\\"] 201 | [19.507599999999996,"o","\u001bP=1s\u001b\\\u001b[1;27r\u001b[27;1H\u001b[K\u001b[34m☸\u001b[39m\u001b[27;139H\n\u001b[26;2H\u001b[34m rainbows-and-unicorns\u001b[39m in \u001b[32m~/simontheleg/screencast\u001b[39m \r\n\u001b[K\u001b[32m\u001b[1m❯\u001b(B\u001b[m \u001b[K\u001b[1;57r\u001b[27;3H\u001bP=2s\u001b\\"] 202 | [19.5082,"o","\u001b[?2004h"] 203 | [21.203799999999998,"o","\u001b[30m\u001b[1m#\u001b(B\u001b[m"] 204 | [21.324399999999997,"o","\u0008\u001b[30m\u001b[1m# \u001b(B\u001b[m"] 205 | [21.474399999999996,"o","\u001b[27;3H\u001b[30m\u001b[1m# t\u001b(B\u001b[m"] 206 | [21.560799999999993,"o","\u0008\u001b[30m\u001b[1mth\u001b(B\u001b[m"] 207 | [21.60579999999999,"o","\u0008\u001b[30m\u001b[1mhi\u001b(B\u001b[m"] 208 | [21.688599999999994,"o","\u0008\u001b[30m\u001b[1mis\u001b(B\u001b[m"] 209 | [21.76119999999999,"o","\u0008\u001b[30m\u001b[1ms \u001b(B\u001b[m"] 210 | [21.886599999999994,"o","\u0008\u001b[30m\u001b[1m e\u001b(B\u001b[m"] 211 | [22.018599999999992,"o","\u0008\u001b[30m\u001b[1mev\u001b(B\u001b[m"] 212 | [22.094799999999992,"o","\u0008\u001b[30m\u001b[1mve\u001b(B\u001b[m"] 213 | [22.22499999999999,"o","\u0008\u001b[30m\u001b[1men\u001b(B\u001b[m"] 214 | [22.41519999999999,"o","\u0008\u001b[30m\u001b[1mn \u001b(B\u001b[m"] 215 | [22.48539999999999,"o","\u0008\u001b[30m\u001b[1m w\u001b(B\u001b[m"] 216 | [22.565799999999992,"o","\u0008\u001b[30m\u001b[1mwo\u001b(B\u001b[m"] 217 | [22.635999999999996,"o","\u0008\u001b[30m\u001b[1mor\u001b(B\u001b[m"] 218 | [22.725999999999996,"o","\u0008\u001b[30m\u001b[1mrk\u001b(B\u001b[m"] 219 | [22.970199999999995,"o","\u0008\u001b[30m\u001b[1mks\u001b(B\u001b[m"] 220 | [23.107599999999994,"o","\u0008\u001b[30m\u001b[1ms \u001b(B\u001b[m"] 221 | [23.252799999999997,"o","\u0008\u001b[30m\u001b[1m a\u001b(B\u001b[m"] 222 | [23.395599999999998,"o","\u0008\u001b[30m\u001b[1mac\u001b(B\u001b[m"] 223 | [23.498799999999996,"o","\u0008\u001b[30m\u001b[1mcr\u001b(B\u001b[m"] 224 | [23.571999999999996,"o","\u0008\u001b[30m\u001b[1mro\u001b(B\u001b[m"] 225 | [23.671599999999998,"o","\u0008\u001b[30m\u001b[1mos\u001b(B\u001b[m"] 226 | [23.767599999999995,"o","\u0008\u001b[30m\u001b[1mss\u001b(B\u001b[m"] 227 | [23.847999999999995,"o","\u0008\u001b[30m\u001b[1ms \u001b(B\u001b[m"] 228 | [23.897199999999994,"o","\u0008\u001b[30m\u001b[1m m\u001b(B\u001b[m"] 229 | [24.020199999999992,"o","\u0008\u001b[30m\u001b[1mmu\u001b(B\u001b[m"] 230 | [24.141399999999994,"o","\u0008\u001b[30m\u001b[1mul\u001b(B\u001b[m"] 231 | [24.19959999999999,"o","\u0008\u001b[30m\u001b[1mlt\u001b(B\u001b[m"] 232 | [24.27399999999999,"o","\u0008\u001b[30m\u001b[1mti\u001b(B\u001b[m"] 233 | [24.379599999999993,"o","\u0008\u001b[30m\u001b[1mip\u001b(B\u001b[m"] 234 | [24.501999999999992,"o","\u0008\u001b[30m\u001b[1mpl\u001b(B\u001b[m"] 235 | [24.54519999999999,"o","\u0008\u001b[30m\u001b[1mle\u001b(B\u001b[m"] 236 | [24.66519999999999,"o","\u0008\u001b[30m\u001b[1me \u001b(B\u001b[m"] 237 | [24.92559999999999,"o","\u0008\u001b[30m\u001b[1m s\u001b(B\u001b[m"] 238 | [24.98859999999999,"o","\u0008\u001b[30m\u001b[1msh\u001b(B\u001b[m"] 239 | [25.05639999999999,"o","\u0008\u001b[30m\u001b[1mhe\u001b(B\u001b[m"] 240 | [25.18599999999999,"o","\u0008\u001b[30m\u001b[1mel\u001b(B\u001b[m"] 241 | [25.290999999999986,"o","\u0008\u001b[30m\u001b[1mll\u001b(B\u001b[m"] 242 | [25.434399999999986,"o","\u0008\u001b[30m\u001b[1ml \u001b(B\u001b[m"] 243 | [25.618599999999986,"o","\u0008\u001b[30m\u001b[1m s\u001b(B\u001b[m"] 244 | [25.701999999999988,"o","\u0008\u001b[30m\u001b[1mse\u001b(B\u001b[m"] 245 | [25.902999999999988,"o","\u0008\u001b[30m\u001b[1mes\u001b(B\u001b[m"] 246 | [25.996599999999987,"o","\u0008\u001b[30m\u001b[1mss\u001b(B\u001b[m"] 247 | [26.114799999999985,"o","\u0008\u001b[30m\u001b[1msi\u001b(B\u001b[m"] 248 | [26.19219999999999,"o","\u0008\u001b[30m\u001b[1mio\u001b(B\u001b[m"] 249 | [26.297799999999985,"o","\u0008\u001b[30m\u001b[1mon\u001b(B\u001b[m"] 250 | [26.376399999999986,"o","\u0008\u001b[30m\u001b[1mns\u001b(B\u001b[m"] 251 | [26.878599999999988,"o","\u001bP=1s\u001b\\\u001b[1;27r\u001b[27;139H\n\r\u001b[K\u001b[1;57r\u001b[27;1H\u001b[?2004l\u001bP=2s\u001b\\"] 252 | [26.879199999999987,"o"," \u001b[27;1H"] 253 | [26.886999999999986,"o","\u001bP=1s\u001b\\\u001b[?25l\r\n\u001b[32m──────────────────────────────────────────────────────────────────────\u001b[39m─────────────────────────────────────────────────────────────────────\u001b(B\u001b[m\u001b[?12l\u001b[?25h\u001b[27;1H\u001bP=2s\u001b\\"] 254 | [26.912199999999988,"o","\u001bP=1s\u001b\\\u001b[1;27r\u001b[27;1H\u001b[K\u001b[34m☸\u001b[39m\u001b[27;139H\n\u001b[26;2H\u001b[34m rainbows-and-unicorns\u001b[39m in \u001b[32m~/simontheleg/screencast\u001b[39m \r\n\u001b[K\u001b[32m\u001b[1m❯\u001b(B\u001b[m \u001b[K\u001b[1;57r\u001b[27;3H\u001bP=2s\u001b\\"] 255 | [26.91279999999999,"o","\u001b[?2004h"] 256 | [27.36099999999999,"o","\u001b[30m\u001b[1m#\u001b(B\u001b[m"] 257 | [27.490599999999986,"o","\u0008\u001b[30m\u001b[1m# \u001b(B\u001b[m"] 258 | [27.575199999999988,"o","\u001b[27;3H\u001b[30m\u001b[1m# p\u001b(B\u001b[m"] 259 | [27.688599999999987,"o","\u0008\u001b[30m\u001b[1mpl\u001b(B\u001b[m"] 260 | [27.786399999999986,"o","\u0008\u001b[30m\u001b[1mlu\u001b(B\u001b[m"] 261 | [27.818199999999983,"o","\u0008\u001b[30m\u001b[1mus\u001b(B\u001b[m"] 262 | [27.889599999999987,"o","\u0008\u001b[30m\u001b[1ms \u001b(B\u001b[m"] 263 | [27.996399999999987,"o","\u0008\u001b[30m\u001b[1m y\u001b(B\u001b[m"] 264 | [28.04919999999999,"o","\u0008\u001b[30m\u001b[1myo\u001b(B\u001b[m"] 265 | [28.137999999999987,"o","\u0008\u001b[30m\u001b[1mou\u001b(B\u001b[m"] 266 | [28.185399999999987,"o","\u0008\u001b[30m\u001b[1mu \u001b(B\u001b[m"] 267 | [28.319199999999988,"o","\u0008\u001b[30m\u001b[1m c\u001b(B\u001b[m"] 268 | [28.37859999999999,"o","\u0008\u001b[30m\u001b[1mca\u001b(B\u001b[m"] 269 | [28.469799999999992,"o","\u0008\u001b[30m\u001b[1man\u001b(B\u001b[m"] 270 | [28.53639999999999,"o","\u0008\u001b[30m\u001b[1mn \u001b(B\u001b[m"] 271 | [28.67979999999999,"o","\u0008\u001b[30m\u001b[1m u\u001b(B\u001b[m"] 272 | [28.739199999999993,"o","\u0008\u001b[30m\u001b[1mus\u001b(B\u001b[m"] 273 | [28.81479999999999,"o","\u0008\u001b[30m\u001b[1mse\u001b(B\u001b[m"] 274 | [28.96419999999999,"o","\u0008\u001b[30m\u001b[1me \u001b(B\u001b[m"] 275 | [29.108199999999993,"o","\u0008\u001b[30m\u001b[1m d\u001b(B\u001b[m"] 276 | [29.22639999999999,"o","\u0008\u001b[30m\u001b[1mdi\u001b(B\u001b[m"] 277 | [29.343999999999994,"o","\u0008\u001b[30m\u001b[1mif\u001b(B\u001b[m"] 278 | [29.439399999999992,"o","\u0008\u001b[30m\u001b[1mff\u001b(B\u001b[m"] 279 | [29.52579999999999,"o","\u0008\u001b[30m\u001b[1mfe\u001b(B\u001b[m"] 280 | [29.582799999999988,"o","\u0008\u001b[30m\u001b[1mer\u001b(B\u001b[m"] 281 | [29.64939999999999,"o","\u0008\u001b[30m\u001b[1mre\u001b(B\u001b[m"] 282 | [29.771199999999986,"o","\u0008\u001b[30m\u001b[1men\u001b(B\u001b[m"] 283 | [29.826399999999985,"o","\u0008\u001b[30m\u001b[1mnt\u001b(B\u001b[m"] 284 | [29.874999999999986,"o","\u0008\u001b[30m\u001b[1mt \u001b(B\u001b[m"] 285 | [29.967399999999984,"o","\u0008\u001b[30m\u001b[1m k\u001b(B\u001b[m"] 286 | [30.093999999999987,"o","\u0008\u001b[30m\u001b[1mku\u001b(B\u001b[m"] 287 | [30.122799999999984,"o","\u0008\u001b[30m\u001b[1mub\u001b(B\u001b[m"] 288 | [30.219399999999986,"o","\u0008\u001b[30m\u001b[1mbe\u001b(B\u001b[m"] 289 | [30.35259999999999,"o","\u0008\u001b[30m\u001b[1mec\u001b(B\u001b[m"] 290 | [30.40719999999999,"o","\u0008\u001b[30m\u001b[1mco\u001b(B\u001b[m"] 291 | [30.47439999999999,"o","\u0008\u001b[30m\u001b[1mon\u001b(B\u001b[m"] 292 | [30.532599999999984,"o","\u0008\u001b[30m\u001b[1mnf\u001b(B\u001b[m"] 293 | [30.624399999999987,"o","\u0008\u001b[30m\u001b[1mfi\u001b(B\u001b[m"] 294 | [30.674199999999985,"o","\u0008\u001b[30m\u001b[1mig\u001b(B\u001b[m"] 295 | [30.754599999999986,"o","\u0008\u001b[30m\u001b[1mgs\u001b(B\u001b[m"] 296 | [30.816999999999986,"o","\u0008\u001b[30m\u001b[1ms \u001b(B\u001b[m"] 297 | [30.923199999999987,"o","\u0008\u001b[30m\u001b[1m f\u001b(B\u001b[m"] 298 | [31.023999999999987,"o","\u0008\u001b[30m\u001b[1mfo\u001b(B\u001b[m"] 299 | [31.07499999999999,"o","\u0008\u001b[30m\u001b[1mor\u001b(B\u001b[m"] 300 | [31.139199999999988,"o","\u0008\u001b[30m\u001b[1mr \u001b(B\u001b[m"] 301 | [31.21899999999999,"o","\u0008\u001b[30m\u001b[1m e\u001b(B\u001b[m"] 302 | [31.286199999999987,"o","\u0008\u001b[30m\u001b[1mea\u001b(B\u001b[m"] 303 | [31.399599999999985,"o","\u0008\u001b[30m\u001b[1mac\u001b(B\u001b[m"] 304 | [31.454199999999986,"o","\u0008\u001b[30m\u001b[1mch\u001b(B\u001b[m"] 305 | [31.500999999999987,"o","\u0008\u001b[30m\u001b[1mh \u001b(B\u001b[m"] 306 | [31.54299999999999,"o","\u0008\u001b[30m\u001b[1m s\u001b(B\u001b[m"] 307 | [31.617999999999988,"o","\u0008\u001b[30m\u001b[1mse\u001b(B\u001b[m"] 308 | [31.78059999999999,"o","\u0008\u001b[30m\u001b[1mes\u001b(B\u001b[m"] 309 | [31.865199999999987,"o","\u0008\u001b[30m\u001b[1mss\u001b(B\u001b[m"] 310 | [31.93479999999999,"o","\u0008\u001b[30m\u001b[1msi\u001b(B\u001b[m"] 311 | [31.993599999999986,"o","\u0008\u001b[30m\u001b[1mio\u001b(B\u001b[m"] 312 | [32.07159999999999,"o","\u0008\u001b[30m\u001b[1mon\u001b(B\u001b[m"] 313 | [32.24559999999999,"o","\u001bP=1s\u001b\\\u001b[1;27r\u001b[27;139H\n\r\u001b[K\u001b[1;57r\u001b[27;1H\u001b[?2004l\u001bP=2s\u001b\\"] 314 | [32.24559999999999,"o"," \u001b[27;1H"] 315 | [32.253999999999984,"o","\u001bP=1s\u001b\\\u001b[?25l\r\n\u001b[32m──────────────────────────────────────────────────────────────────────\u001b[39m─────────────────────────────────────────────────────────────────────\u001b(B\u001b[m\u001b[?12l\u001b[?25h\u001b[27;1H\u001bP=2s\u001b\\"] 316 | [32.280999999999985,"o","\u001bP=1s\u001b\\\u001b[1;27r\u001b[27;1H\u001b[K\u001b[34m☸\u001b[39m\u001b[27;139H\n\u001b[26;2H\u001b[34m rainbows-and-unicorns\u001b[39m in \u001b[32m~/simontheleg/screencast\u001b[39m \r\n\u001b[K\u001b[32m\u001b[1m❯\u001b(B\u001b[m \u001b[K\u001b[1;57r\u001b[27;3H\u001bP=2s\u001b\\"] 317 | [32.28159999999998,"o","\u001b[?2004h"] 318 | [32.74659999999999,"o","\u001b[30m\u001b[1m#\u001b(B\u001b[m"] 319 | [32.89479999999999,"o","\u0008\u001b[30m\u001b[1m# \u001b(B\u001b[m"] 320 | [33.05079999999999,"o","\u001b[27;3H\u001b[30m\u001b[1m# w\u001b(B\u001b[m"] 321 | [33.14439999999999,"o","\u0008\u001b[30m\u001b[1mwa\u001b(B\u001b[m"] 322 | [33.179799999999986,"o","\u0008\u001b[30m\u001b[1mat\u001b(B\u001b[m"] 323 | [33.35259999999999,"o","\u0008\u001b[30m\u001b[1mtc\u001b(B\u001b[m"] 324 | [33.42639999999999,"o","\u0008\u001b[30m\u001b[1mch\u001b(B\u001b[m"] 325 | [33.49359999999999,"o","\u0008\u001b[30m\u001b[1mh \u001b(B\u001b[m"] 326 | [33.55059999999999,"o","\u0008\u001b[30m\u001b[1m t\u001b(B\u001b[m"] 327 | [33.63519999999999,"o","\u0008\u001b[30m\u001b[1mth\u001b(B\u001b[m"] 328 | [33.657999999999994,"o","\u0008\u001b[30m\u001b[1mhe\u001b(B\u001b[m"] 329 | [33.749199999999995,"o","\u0008\u001b[30m\u001b[1me \u001b(B\u001b[m"] 330 | [33.815799999999996,"o","\u0008\u001b[30m\u001b[1m l\u001b(B\u001b[m"] 331 | [33.929199999999994,"o","\u0008\u001b[30m\u001b[1mlo\u001b(B\u001b[m"] 332 | [33.97299999999999,"o","\u0008\u001b[30m\u001b[1mow\u001b(B\u001b[m"] 333 | [34.047399999999996,"o","\u0008\u001b[30m\u001b[1mwe\u001b(B\u001b[m"] 334 | [34.123,"o","\u0008\u001b[30m\u001b[1mer\u001b(B\u001b[m"] 335 | [34.1974,"o","\u0008\u001b[30m\u001b[1mr \u001b(B\u001b[m"] 336 | [34.315599999999996,"o","\u0008\u001b[30m\u001b[1m s\u001b(B\u001b[m"] 337 | [34.456599999999995,"o","\u0008\u001b[30m\u001b[1msc\u001b(B\u001b[m"] 338 | [34.5976,"o","\u0008\u001b[30m\u001b[1mcr\u001b(B\u001b[m"] 339 | [34.7692,"o","\u0008\u001b[30m\u001b[1mre\u001b(B\u001b[m"] 340 | [34.864,"o","\u0008\u001b[30m\u001b[1mee\u001b(B\u001b[m"] 341 | [35.022999999999996,"o","\u0008\u001b[30m\u001b[1men\u001b(B\u001b[m"] 342 | [35.2174,"o","\u001bP=1s\u001b\\\u001b[1;27r\u001b[27;139H\n\r\u001b[K\u001b[1;57r\u001b[27;1H\u001b[?2004l\u001bP=2s\u001b\\"] 343 | [35.2174,"o"," \u001b[27;1H"] 344 | [35.2258,"o","\u001bP=1s\u001b\\\u001b[?25l\r\n\u001b[32m──────────────────────────────────────────────────────────────────────\u001b[39m─────────────────────────────────────────────────────────────────────\u001b(B\u001b[m\u001b[?12l\u001b[?25h\u001b[27;1H\u001bP=2s\u001b\\"] 345 | [35.254,"o","\u001bP=1s\u001b\\\u001b[1;27r\u001b[27;1H\u001b[K\u001b[34m☸\u001b[39m\u001b[27;139H\n\u001b[26;2H\u001b[34m rainbows-and-unicorns\u001b[39m in \u001b[32m~/simontheleg/screencast\u001b[39m \r\n\u001b[K\u001b[32m\u001b[1m❯\u001b(B\u001b[m \u001b[K\u001b[1;57r\u001b[27;3H\u001bP=2s\u001b\\"] 346 | [35.2546,"o","\u001b[?2004h"] 347 | [35.6608,"o","\u001bP=1s\u001b\\\u001b[?25l\r\n\u001b[32m──────────────────────────────────────────────────────────────────────\u001b[39m─────────────────────────────────────────────────────────────────────\u001b(B\u001b[m\u001b[1;1HINFO: Setting context to \"rainbows-and-unicorns_cluster\"\u001b[K\u001b[34m\r\n☸ rainbows-and-unicorns\u001b[39m in \u001b[32m~/simontheleg/screencast\u001b[39m took \u001b[33m\u001b[1m2s\u001b(B\u001b[m \u001b[K\u001b[32m\u001b[1m\r\n❯\u001b(B\u001b[m \u001b[32mkubectl\u001b[39m config view\u001b[K\r\napiVersion: v1\u001b[K\r\nclusters:\u001b[K\r\n- cluster:\u001b[K\r\n server: rainbows-and-unicorns.greatdomain.com\u001b[K\r\n name: cluster\u001b[K\r\ncontexts:\u001b[K\r\n- context:\u001b[K\r\n cluster: cluster\u001b[K\r\n user: default\u001b[K\r\n name: rainbows-and-unicorns\u001b[K\r\ncurrent-context: rainbows-and-unicorns\u001b[K\r\nkind: Config\u001b[K\r\npreferences: {}\u001b[K\r\nusers:\u001b[K\r\n- name: default\u001b[K\r\n user: {}\u001b"] 348 | [35.6608,"o","[K\u001b[34m\r\n☸ rainbows-and-unicorns\u001b[39m in \u001b[32m~/simontheleg/screencast\u001b[39m \u001b[K\u001b[32m\u001b[1m\r\n❯\u001b(B\u001b[m \u001b[30m\u001b[1m# this even works across multiple shell sessions\u001b(B\u001b[m\u001b[K\u001b[34m\r\n☸ rainbows-and-unicorns\u001b[39m in \u001b[32m~/simontheleg/screencast\u001b[39m \u001b[K\u001b[32m\u001b[1m\r\n❯\u001b(B\u001b[m \u001b[30m\u001b[1m# plus you can use different kubeconfigs for each session\u001b(B\u001b[m\u001b[K\u001b[34m\r\n☸ rainbows-and-unicorns\u001b[39m in \u001b[32m~/simontheleg/screencast\u001b[39m \u001b[K\u001b[32m\u001b[1m\r\n❯\u001b(B\u001b[m \u001b[30m\u001b[1m# watch the lower screen\u001b(B\u001b[m\u001b[K\u001b[34m\r\n☸ rainbows-and-unicorns\u001b[39m in \u001b[32m~/simontheleg/screencast\u001b[39m \u001b[K\u001b[32m\u001b[1m\r\n❯\u001b(B\u001b[m \u001b[K\u001b[32m\u001b[29;1H~/simontheleg/screencast\u001b[39m \u001b[K\u001b[32m\u001b[1m\r\n❯\u001b(B\u001b[m \u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\u001b[2 q\u001b[?25l\u001b[30m\u001b[42m\r\npaneMgmt \u001b[46m2:zsh*\u001b[42m \"simonbein@Simons-MBP-\" 20:06 07-Feb-22\u001b[39m\u001b[41m\u001b[12;67H\u001b[C\u001b[C\u001b[C\u001b[C \u001b[13;67H\u001b[C\u001b[C\u001b[C\u001b[C \u001b[14;67H\u001b[C\u001b["] 349 | [35.6614,"o","C\u001b[C\u001b[C \u001b[15;67H\u001b[C\u001b[C\u001b[C\u001b[C \u001b[16;67H\u001b[C\u001b[C\u001b[C\u001b[C \u001b[49m\u001b[31m\u001b[1;134H139x27\u001b[1;1H\u001b[39m\u001b[44m\u001b[41;67H \u001b[42;67H\u001b[C\u001b[C\u001b[C\u001b[C \u001b[43;67H \u001b[44;67H \u001b[C\u001b[C\u001b[C\u001b[45;67H \u001b[49m\u001b[34m\u001b[29;134H139x28\u001b[1;1H\u001b(B\u001b[m\u001b[?2004l\u001bP=2s\u001b\\"] 350 | [35.9704,"o","\u001bP=1s\u001b\\\u001b[27B──────────────────────────────────────────────────────────────────────\u001b[32m─────────────────────────────────────────────────────────────────────\u001b[6 q\u001b[?25l\u001b(B\u001b[m\u001b[1;1HINFO: Setting context to \"rainbows-and-unicorns_cluster\"\u001b[K\u001b[34m\r\n☸ rainbows-and-unicorns\u001b[39m in \u001b[32m~/simontheleg/screencast\u001b[39m took \u001b[33m\u001b[1m2s\u001b(B\u001b[m \u001b[K\u001b[32m\u001b[1m\r\n❯\u001b(B\u001b[m \u001b[32mkubectl\u001b[39m config view\u001b[K\r\napiVersion: v1\u001b[K\r\nclusters:\u001b[K\r\n- cluster:\u001b[K\r\n server: rainbows-and-unicorns.greatdomain.com\u001b[K\r\n name: cluster\u001b[K\r\ncontexts:\u001b[K\r\n- context:\u001b[K\r\n cluster: cluster\u001b[K\r\n user: default\u001b[K\r\n name: rainbows-and-unicorns\u001b[K\r\ncurrent-context: rainbows-and-unicorns\u001b[K\r\nkind: Config\u001b[K\r\npreferences: {}\u001b[K\r\nusers:\u001b[K\r\n- name: default\u001b[K\r\n user: "] 351 | [35.9704,"o","{}\u001b[K\u001b[34m\r\n☸ rainbows-and-unicorns\u001b[39m in \u001b[32m~/simontheleg/screencast\u001b[39m \u001b[K\u001b[32m\u001b[1m\r\n❯\u001b(B\u001b[m \u001b[30m\u001b[1m# this even works across multiple shell sessions\u001b(B\u001b[m\u001b[K\u001b[34m\r\n☸ rainbows-and-unicorns\u001b[39m in \u001b[32m~/simontheleg/screencast\u001b[39m \u001b[K\u001b[32m\u001b[1m\r\n❯\u001b(B\u001b[m \u001b[30m\u001b[1m# plus you can use different kubeconfigs for each session\u001b(B\u001b[m\u001b[K\u001b[34m\r\n☸ rainbows-and-unicorns\u001b[39m in \u001b[32m~/simontheleg/screencast\u001b[39m \u001b[K\u001b[32m\u001b[1m\r\n❯\u001b(B\u001b[m \u001b[30m\u001b[1m# watch the lower screen\u001b(B\u001b[m\u001b[K\u001b[34m\r\n☸ rainbows-and-unicorns\u001b[39m in \u001b[32m~/simontheleg/screencast\u001b[39m \u001b[K\u001b[32m\u001b[1m\r\n❯\u001b(B\u001b[m \u001b[K\u001b[32m\u001b[29;1H~/simontheleg/screencast\u001b[39m \u001b[K\u001b[32m\u001b[1m\r\n❯\u001b(B\u001b[m \u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\u001b[2 q\u001b[?25l\u001b[30m\u001b[42m\r\n \u001b[46m2:zsh*\u001b[42m \"simonbein@Simons-MBP-\" 20:06 07-Feb-22\u001b(B\u001b[m\u001b[30;3H\u001b[6 q\u001b[?12l\u001b[?25h\u001b[?2004h\u001bP=2s\u001b\\"] 352 | [36.4798,"o","\u001b[31m\u001b[1mk\u001b(B\u001b[m"] 353 | [36.595,"o","\u0008\u001b[31m\u001b[1mko\u001b(B\u001b[m"] 354 | [36.6532,"o","\u001b[30;3H\u001b[31m\u001b[1mkon\u001b(B\u001b[m"] 355 | [36.7102,"o","\u001b[3G\u001b[32mkonf\u001b(B\u001b[m"] 356 | [36.775600000000004,"o"," "] 357 | [36.8374,"o","s"] 358 | [36.91180000000001,"o","e"] 359 | [37.013200000000005,"o","t"] 360 | [37.15240000000001,"o","\r\n\u001b[?2004l"] 361 | [37.165000000000006,"o","\u001bP=1s\u001b\\\u001b[?25l\u001b[3A──────────────────────────────────────────────────────────────────────\u001b[32m─────────────────────────────────────────────────────────────────────\u001b[2 q\u001b[?25l\u001b(B\u001b[m\u001b[30m\u001b[42m\u001b[57;1H \u001b[46m2:zsh*\u001b[42m \"konf set\" 20:06 07-Feb-22\u001b(B\u001b[m\u001b[?12l\u001b[?25h\u001b[31;1H\u001b[6 q\u001b[?12l\u001b[?25h\u001bP=2s\u001b\\"] 362 | [37.22500000000001,"o","\u001b[?25l"] 363 | [37.22680000000001,"o","\u001b[2mUse the arrow keys to navigate:\u001b(B\u001b[m \u001b[2m↓\u001b(B\u001b[m \u001b[2m↑\u001b(B\u001b[m \u001b[2m→\u001b(B\u001b[m \u001b[2m←\u001b(B\u001b[m \u001b[2mand\u001b(B\u001b[m \u001b[2m/\u001b(B\u001b[m \u001b[2mtoggles search\r\n\u001b(B\u001b[m\u001b[34m?\u001b[39m Context | Cluster | File : \r\n ▸\u001bP=1s\u001b\\ \u001b[36m\u001b[1mlittle-dark-age \u001b(B\u001b[m | \u001b[36m\u001b[1mcluster \u001b(B\u001b[m | \u001b[36m\u001b[1m/Users/simonbein/.kube/ko\u001b(B\u001b[m |\r\n rainbows-and-unicorns | cluster | /Users/simonbein/.kube/ko |\u001b[29;56r\u001b[1;1H\u001b[36;56r\u001b[21S\u001b[35;1H\u001b[K \u001b[1;57r\u001b[35;1H\u001bP=2s\u001b\\"] 364 | [37.802200000000006,"o","\u001bP=1s\u001b\\\u001b[29;56r\u001b[1;1H\u001b[36;56r\u001b[21S\u001b[35;1H\u001b[K \u001b[29;56r\u001b[1;1H\u001b[36;56r\u001b[21S\u001b[35;1H\u001b[K \u001b[1;57r\u001b[35;1H\u001bP=2s\u001b\\"] 365 | [37.802200000000006,"o","\u001bP=1s\u001b\\\u001b[4A\u001b[K\u001b[29;56r\u001b[1;1H\u001b[32;56r\u001b[25S\u001b[31;1H\u001b[K\u001b[1;57r\u001b[31;1H\u001b[?12l\u001b[?25h\u001bP=2s\u001b\\"] 366 | [37.80280000000001,"o","INFO: Setting context to \"little-dark-age_cluster\"\r\n"] 367 | [37.80340000000001,"o"," \u001b[32;1H"] 368 | [37.81240000000001,"o","\u001bP=1s\u001b\\\u001b[?25l\u001b[4A──────────────────────────────────────────────────────────────────────\u001b[32m─────────────────────────────────────────────────────────────────────\u001b[2 q\u001b[?25l\u001b(B\u001b[m\u001b[30m\u001b[42m\u001b[57;1H \u001b[46m2:zsh*\u001b[42m \"simonbein@Simons-MBP-\" 20:06 07-Feb-22\u001b(B\u001b[m\u001b[?12l\u001b[?25h\u001b[32;1H\u001b[6 q\u001b[?12l\u001b[?25h\u001bP=2s\u001b\\"] 369 | [37.841200000000015,"o","\u001bP=1s\u001b\\\u001b[29;56r\u001b[1;1H\u001b[33;56r\u001b[24S\u001b[32;1H\u001b[K\u001b[34m☸ little-dark-age\u001b[39m in \u001b[32m~/simontheleg/screencast\u001b[39m \r\n\u001b[32m\u001b[1m❯\u001b(B\u001b[m \u001b[K\u001b[1;57r\u001b[33;3H\u001bP=2s\u001b\\"] 370 | [37.84180000000001,"o","\u001b[?2004h"] 371 | [38.365000000000016,"o","\u001b[31m\u001b[1mk\u001b(B\u001b[m"] 372 | [38.49160000000002,"o","\u0008\u001b[31m\u001b[1mku\u001b(B\u001b[m"] 373 | [38.52880000000002,"o","\u001b[33;3H\u001b[31m\u001b[1mkub\u001b(B\u001b[m"] 374 | [38.627200000000016,"o","\u0008\u001b[31m\u001b[1mbe\u001b(B\u001b[m"] 375 | [38.75140000000001,"o","\u0008\u001b[31m\u001b[1mec\u001b(B\u001b[m"] 376 | [38.890000000000015,"o","\u0008\u001b[31m\u001b[1mct\u001b(B\u001b[m"] 377 | [39.01120000000002,"o","\u001b[3G\u001b[32mkubectl\u001b(B\u001b[m"] 378 | [39.069400000000016,"o"," "] 379 | [39.24160000000002,"o","\u001b[4mc\u001b(B\u001b[m"] 380 | [39.371200000000016,"o","\u0008co"] 381 | [39.45880000000002,"o","n"] 382 | [39.536800000000014,"o","f"] 383 | [39.59920000000001,"o","i"] 384 | [39.68680000000001,"o","g"] 385 | [39.76300000000002,"o"," "] 386 | [40.01140000000002,"o","v"] 387 | [40.112200000000016,"o","i"] 388 | [40.19320000000002,"o","e"] 389 | [40.26040000000002,"o","w"] 390 | [40.52680000000002,"o","\r\n\u001b[?2004l"] 391 | [40.528600000000026,"o","\u001bP=1s\u001b\\\u001b[?25l\u001b[6A──────────────────────────────────────────────────────────────────────\u001b[32m─────────────────────────────────────────────────────────────────────\u001b[2 q\u001b[?25l\u001b(B\u001b[m\u001b[30m\u001b[42m\u001b[57;1H \u001b[46m2:zsh*\u001b[42m \"kubectl config view\" 20:06 07-Feb-22\u001b(B\u001b[m\u001b[?12l\u001b[?25h\u001b[34;1H\u001b[6 q\u001b[?12l\u001b[?25h\u001bP=2s\u001b\\"] 392 | [40.55800000000002,"o","apiVersion: v1\r\nclusters:\r\n- cluster:\r\n server: little-dark-age.greatdomain.com\r\n name: cluster\r\ncontexts:\r\n- context:\r\n cluster: cluster\r\n user: default\r\n name: little-dark-age\r\ncurrent-context: little-dark-age\r\nkind: Config\r\npreferences: {}\r\nusers:\r\n- name: default\r\n user: {}\r\n"] 393 | [40.559200000000025,"o"," \u001b[50;1H"] 394 | [40.565800000000024,"o","\u001bP=1s\u001b\\\u001b[?25l\u001b[22A──────────────────────────────────────────────────────────────────────\u001b[32m─────────────────────────────────────────────────────────────────────\u001b[2 q\u001b[?25l\u001b(B\u001b[m\u001b[30m\u001b[42m\u001b[57;1H \u001b[46m2:zsh*\u001b[42m \"simonbein@Simons-MBP-\" 20:06 07-Feb-22\u001b(B\u001b[m\u001b[?12l\u001b[?25h\u001b[50;1H\u001b[6 q\u001b[?12l\u001b[?25h\u001bP=2s\u001b\\"] 395 | [40.592800000000025,"o","\u001bP=1s\u001b\\\u001b[29;56r\u001b[1;1H\u001b[51;56r\u001b[6S\u001b[50;1H\u001b[K\u001b[34m☸ little-dark-age\u001b[39m in \u001b[32m~/simontheleg/screencast\u001b[39m \r\n\u001b[32m\u001b[1m❯\u001b(B\u001b[m \u001b[K\u001b[1;57r\u001b[51;3H\u001bP=2s\u001b\\"] 396 | [40.59340000000003,"o","\u001b[?2004h"] 397 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/simontheleg/konf-go 2 | 3 | go 1.24 4 | 5 | require ( 6 | github.com/google/go-cmp v0.5.5 7 | github.com/lithammer/fuzzysearch v1.1.3 8 | github.com/manifoldco/promptui v0.9.0 9 | github.com/mitchellh/go-ps v1.0.0 10 | github.com/spf13/afero v1.6.0 11 | github.com/spf13/cobra v1.2.1 12 | k8s.io/api v0.22.3 13 | k8s.io/apimachinery v0.22.3 14 | k8s.io/client-go v0.22.3 15 | k8s.io/utils v0.0.0-20210819203725-bdf08cb9a70a 16 | sigs.k8s.io/yaml v1.2.0 17 | ) 18 | 19 | require ( 20 | github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e // indirect 21 | github.com/davecgh/go-spew v1.1.1 // indirect 22 | github.com/evanphx/json-patch v4.11.0+incompatible // indirect 23 | github.com/go-logr/logr v0.4.0 // indirect 24 | github.com/gogo/protobuf v1.3.2 // indirect 25 | github.com/golang/protobuf v1.5.2 // indirect 26 | github.com/google/gofuzz v1.1.0 // indirect 27 | github.com/googleapis/gnostic v0.5.5 // indirect 28 | github.com/imdario/mergo v0.3.11 // indirect 29 | github.com/inconshreveable/mousetrap v1.0.0 // indirect 30 | github.com/json-iterator/go v1.1.11 // indirect 31 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 32 | github.com/modern-go/reflect2 v1.0.1 // indirect 33 | github.com/pkg/errors v0.9.1 // indirect 34 | github.com/spf13/pflag v1.0.5 // indirect 35 | golang.org/x/net v0.0.0-20210520170846-37e1c6afe023 // indirect 36 | golang.org/x/oauth2 v0.0.0-20210402161424-2e8d93401602 // indirect 37 | golang.org/x/sys v0.0.0-20220422013727-9388b58f7150 // indirect 38 | golang.org/x/term v0.0.0-20210503060354-a79de5458b56 // indirect 39 | golang.org/x/text v0.3.7 // indirect 40 | golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac // indirect 41 | google.golang.org/appengine v1.6.7 // indirect 42 | google.golang.org/protobuf v1.26.0 // indirect 43 | gopkg.in/inf.v0 v0.9.1 // indirect 44 | gopkg.in/yaml.v2 v2.4.0 // indirect 45 | gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect 46 | k8s.io/klog/v2 v2.9.0 // indirect 47 | k8s.io/kube-openapi v0.0.0-20210421082810-95288971da7e // indirect 48 | sigs.k8s.io/structured-merge-diff/v4 v4.1.2 // indirect 49 | ) 50 | -------------------------------------------------------------------------------- /konf/id.go: -------------------------------------------------------------------------------- 1 | package konf 2 | 3 | import ( 4 | "fmt" 5 | "io/fs" 6 | "path/filepath" 7 | "strings" 8 | ) 9 | 10 | // ID unifies ID management that konf uses 11 | // Currently an ID is defined by the context and clustername of the config, separated by an underscore 12 | // I have chosen this combination as it is fairly unique among multiple configs. I decided against using just context.name as a lot of times the context is just called "default", which results in lots of naming collisions 13 | // Some special characters that are reserved by the filesystem, will be replaced by a "-" character 14 | type KonfID string 15 | 16 | // IDFromClusterAndContext creates an id based on the cluster and context 17 | // It escapes any illegal file characters and is filesafe 18 | func IDFromClusterAndContext(cluster, context string) KonfID { 19 | id := context + "_" + cluster 20 | 21 | illegalChars := []string{"/", ":"} 22 | for _, c := range illegalChars { 23 | id = strings.ReplaceAll(id, c, "-") 24 | } 25 | 26 | return KonfID(id) 27 | } 28 | 29 | // IDFromProcessID creates a KonfID based on the supplied processID 30 | func IDFromProcessID(pid int) KonfID { 31 | // since the pid is an int, no illegal character replacement is needed 32 | return KonfID(fmt.Sprint(pid)) 33 | } 34 | 35 | // IDFromFileInfo creates an ID from the name of a file 36 | func IDFromFileInfo(fi fs.FileInfo) KonfID { 37 | return KonfID(strings.TrimSuffix(fi.Name(), filepath.Ext(fi.Name()))) 38 | } 39 | -------------------------------------------------------------------------------- /konf/id_test.go: -------------------------------------------------------------------------------- 1 | package konf 2 | 3 | import ( 4 | "fmt" 5 | "io/fs" 6 | "testing" 7 | "time" 8 | 9 | "github.com/spf13/afero" 10 | ) 11 | 12 | // IntegrationtestDir describes the directory to place files from IntegrationTests 13 | const IntegrationtestDir = "/tmp/konfs" 14 | 15 | var validCombos = []struct { 16 | context string 17 | cluster string 18 | id KonfID 19 | }{ 20 | {"dev-eu", "dev-eu-1", "dev-eu_dev-eu-1"}, 21 | {"con", "mygreathost.com-443", "con_mygreathost.com-443"}, 22 | {"host.com-443/with/slashes", "danger", "host.com-443-with-slashes_danger"}, 23 | {"host.com-443@something.nice", "danger", "host.com-443@something.nice_danger"}, 24 | {"this:would:break:on:windows", "danger", "this-would-break-on-windows_danger"}, 25 | } 26 | 27 | func TestIDFromClusterAndContext(t *testing.T) { 28 | for _, co := range validCombos { 29 | res := IDFromClusterAndContext(co.cluster, co.context) 30 | if res != co.id { 31 | t.Errorf("Exp ID %q, got %q", co.id, res) 32 | } 33 | } 34 | } 35 | 36 | type mockFileInfo struct{ name string } 37 | 38 | func (m *mockFileInfo) Name() string { return m.name } 39 | func (m *mockFileInfo) Size() int64 { return 0 } 40 | func (m *mockFileInfo) Mode() fs.FileMode { return 0 } 41 | func (m *mockFileInfo) ModTime() time.Time { return time.Time{} } 42 | func (m *mockFileInfo) IsDir() bool { return false } 43 | func (m *mockFileInfo) Sys() interface{} { return nil } 44 | 45 | func TestIDFromFileInfo(t *testing.T) { 46 | 47 | tt := map[string]struct { 48 | In fs.FileInfo 49 | Exp KonfID 50 | }{ 51 | "yaml extension": { 52 | &mockFileInfo{"mygreatid.yaml"}, 53 | "mygreatid", 54 | }, 55 | "no extension": { 56 | &mockFileInfo{"noextension"}, 57 | "noextension", 58 | }, 59 | "some other extension": { 60 | &mockFileInfo{"mygreatid.json"}, 61 | "mygreatid", 62 | }, 63 | } 64 | 65 | for name, tc := range tt { 66 | t.Run(name, func(t *testing.T) { 67 | res := IDFromFileInfo(tc.In) 68 | if res != tc.Exp { 69 | t.Errorf("Expected ID %q, got %q", tc.Exp, res) 70 | } 71 | }) 72 | } 73 | } 74 | 75 | func TestIDFromProcessID(t *testing.T) { 76 | in := 1234 77 | expOut := KonfID("1234") 78 | out := IDFromProcessID(in) 79 | if out != expOut { 80 | t.Errorf("Exp out to be %s, got %s", expOut, out) 81 | } 82 | } 83 | 84 | // this test simply checks if an ID is valid, by writing a file of that name to the os filesystem 85 | // this test should be treated as an Integration test and run by CI on all OS supported by konf 86 | func TestIDFileValidityIntegration(t *testing.T) { 87 | if testing.Short() { 88 | t.Skip("Skipping TestIDFileValidityIntegration integration test") 89 | } 90 | 91 | f := afero.NewOsFs() 92 | dir := IntegrationtestDir + "/TestIDFileValidityIntegration" 93 | 94 | t.Cleanup( 95 | func() { 96 | err := f.RemoveAll(IntegrationtestDir) 97 | if err != nil { 98 | t.Errorf("Cleanup failed %q", err) 99 | } 100 | }, 101 | ) 102 | 103 | // should be ok to use the perm manually here, as we are inside an integration test 104 | // TODO later remove this part, as ID should have nothing to do with file storage 105 | err := f.MkdirAll(dir, 0700) 106 | if err != nil { 107 | t.Errorf("could not create dir for test %q", err) 108 | } 109 | 110 | for _, co := range validCombos { 111 | id := IDFromClusterAndContext(co.cluster, co.context) 112 | fpath := fmt.Sprintf("%s/%s.yaml", dir, id) 113 | 114 | // it should be fine to write empty 115 | err := afero.WriteFile(f, fpath, []byte{}, 0600) 116 | if err != nil { 117 | t.Errorf("Exp filename %q to work, but got error %q", fpath, err) 118 | } 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /konf/konfig.go: -------------------------------------------------------------------------------- 1 | package konf 2 | 3 | import ( 4 | k8s "k8s.io/client-go/tools/clientcmd/api/v1" 5 | ) 6 | 7 | type Konfig struct { 8 | Id KonfID 9 | Kubeconfig k8s.Config 10 | } 11 | -------------------------------------------------------------------------------- /konf/split.go: -------------------------------------------------------------------------------- 1 | package konf 2 | 3 | import ( 4 | "io" 5 | 6 | k8s "k8s.io/client-go/tools/clientcmd/api/v1" 7 | "sigs.k8s.io/yaml" 8 | ) 9 | 10 | // KonfsFromKubeconfig takes in the content of a kubeconfig and splits it into 11 | // one or multiple konfs. 12 | // 13 | // No error is being returned if the kubeconfig contains no contexts, instead 14 | // konfs is simply an empty slice 15 | func KonfsFromKubeconfig(kubeconfig io.Reader) (konfs []*Konfig, err error) { 16 | konfs = []*Konfig{} 17 | 18 | b, err := io.ReadAll(kubeconfig) 19 | if err != nil { 20 | return nil, err 21 | } 22 | 23 | var origConf k8s.Config 24 | err = yaml.Unmarshal(b, &origConf) 25 | if err != nil { 26 | return nil, err 27 | } 28 | 29 | // basically should be as simple as 30 | // 1. Loop through all the contexts 31 | // 2. Find the corresponding cluster for each context 32 | // 3. Find the corresponding user for each context 33 | // 4. Create a new konfigFile for each context mapped to its cluster 34 | 35 | for _, curCon := range origConf.Contexts { 36 | 37 | cluster := k8s.NamedCluster{} 38 | for _, curCl := range origConf.Clusters { 39 | if curCl.Name == curCon.Context.Cluster { 40 | cluster = curCl 41 | break 42 | } 43 | } 44 | user := k8s.NamedAuthInfo{} 45 | for _, curU := range origConf.AuthInfos { 46 | if curU.Name == curCon.Context.AuthInfo { 47 | user = curU 48 | break 49 | } 50 | } 51 | 52 | var k Konfig 53 | id := IDFromClusterAndContext(cluster.Name, curCon.Name) 54 | k.Id = id 55 | k.Kubeconfig.AuthInfos = append(k.Kubeconfig.AuthInfos, user) 56 | k.Kubeconfig.Clusters = append(k.Kubeconfig.Clusters, cluster) 57 | k.Kubeconfig.Contexts = append(k.Kubeconfig.Contexts, curCon) 58 | 59 | k.Kubeconfig.APIVersion = origConf.APIVersion 60 | k.Kubeconfig.Kind = origConf.Kind 61 | k.Kubeconfig.CurrentContext = curCon.Name 62 | 63 | konfs = append(konfs, &k) 64 | } 65 | 66 | return konfs, nil 67 | } 68 | -------------------------------------------------------------------------------- /konf/split_test.go: -------------------------------------------------------------------------------- 1 | package konf 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | 7 | "github.com/google/go-cmp/cmp" 8 | k8s "k8s.io/client-go/tools/clientcmd/api/v1" 9 | ) 10 | 11 | var singleClusterSingleContext = ` 12 | apiVersion: v1 13 | clusters: 14 | - cluster: 15 | server: https://10.1.1.0 16 | name: dev-eu-1 17 | contexts: 18 | - context: 19 | namespace: kube-public 20 | cluster: dev-eu-1 21 | user: dev-eu 22 | name: dev-eu 23 | current-context: dev-eu 24 | kind: Config 25 | preferences: {} 26 | users: 27 | - name: dev-eu 28 | user: {} 29 | ` 30 | 31 | var multiClusterMultiContext = ` 32 | apiVersion: v1 33 | clusters: 34 | - cluster: 35 | server: https://192.168.0.1 36 | name: dev-asia-1 37 | - cluster: 38 | server: https://10.1.1.0 39 | name: dev-eu-1 40 | contexts: 41 | - context: 42 | namespace: kube-system 43 | cluster: dev-asia-1 44 | user: dev-asia 45 | name: dev-asia 46 | - context: 47 | namespace: kube-public 48 | cluster: dev-eu-1 49 | user: dev-eu 50 | name: dev-eu 51 | current-context: dev-eu 52 | kind: Config 53 | preferences: {} 54 | users: 55 | - name: dev-asia 56 | user: {} 57 | - name: dev-eu 58 | user: {} 59 | ` 60 | 61 | var noContext = ` 62 | apiVersion: v1 63 | clusters: 64 | - cluster: 65 | server: https://10.1.1.0 66 | name: dev-eu-1 67 | kind: Config 68 | preferences: {} 69 | users: 70 | - name: dev-eu 71 | user: {} 72 | ` 73 | 74 | func TestKonfsFromKubeConfig(t *testing.T) { 75 | tt := map[string]struct { 76 | kubeconfig string 77 | expKonfs []*Konfig 78 | }{ 79 | // TODO multi-context, no context 80 | "single context": { 81 | kubeconfig: singleClusterSingleContext, 82 | expKonfs: []*Konfig{ 83 | { 84 | Id: "dev-eu_dev-eu-1", 85 | Kubeconfig: k8s.Config{ 86 | APIVersion: "v1", 87 | Kind: "Config", 88 | CurrentContext: "dev-eu", 89 | Clusters: []k8s.NamedCluster{ 90 | { 91 | Name: "dev-eu-1", 92 | Cluster: k8s.Cluster{ 93 | Server: "https://10.1.1.0", 94 | }, 95 | }, 96 | }, 97 | Contexts: []k8s.NamedContext{ 98 | { 99 | Name: "dev-eu", 100 | Context: k8s.Context{ 101 | Cluster: "dev-eu-1", 102 | Namespace: "kube-public", 103 | AuthInfo: "dev-eu", 104 | }, 105 | }, 106 | }, 107 | AuthInfos: []k8s.NamedAuthInfo{ 108 | { 109 | Name: "dev-eu", 110 | }, 111 | }, 112 | }, 113 | }, 114 | }, 115 | }, 116 | "multi context": { 117 | kubeconfig: multiClusterMultiContext, 118 | expKonfs: []*Konfig{ 119 | { 120 | Id: "dev-asia_dev-asia-1", 121 | Kubeconfig: k8s.Config{ 122 | APIVersion: "v1", 123 | Kind: "Config", 124 | CurrentContext: "dev-asia", 125 | Clusters: []k8s.NamedCluster{ 126 | { 127 | Name: "dev-asia-1", 128 | Cluster: k8s.Cluster{ 129 | Server: "https://192.168.0.1", 130 | }, 131 | }, 132 | }, 133 | Contexts: []k8s.NamedContext{ 134 | { 135 | Name: "dev-asia", 136 | Context: k8s.Context{ 137 | Cluster: "dev-asia-1", 138 | Namespace: "kube-system", 139 | AuthInfo: "dev-asia", 140 | }, 141 | }, 142 | }, 143 | AuthInfos: []k8s.NamedAuthInfo{ 144 | { 145 | Name: "dev-asia", 146 | }, 147 | }, 148 | }, 149 | }, 150 | { 151 | Id: "dev-eu_dev-eu-1", 152 | Kubeconfig: k8s.Config{ 153 | APIVersion: "v1", 154 | Kind: "Config", 155 | CurrentContext: "dev-eu", 156 | Clusters: []k8s.NamedCluster{ 157 | { 158 | Name: "dev-eu-1", 159 | Cluster: k8s.Cluster{ 160 | Server: "https://10.1.1.0", 161 | }, 162 | }, 163 | }, 164 | Contexts: []k8s.NamedContext{ 165 | { 166 | Name: "dev-eu", 167 | Context: k8s.Context{ 168 | Cluster: "dev-eu-1", 169 | Namespace: "kube-public", 170 | AuthInfo: "dev-eu", 171 | }, 172 | }, 173 | }, 174 | AuthInfos: []k8s.NamedAuthInfo{ 175 | { 176 | Name: "dev-eu", 177 | }, 178 | }, 179 | }, 180 | }, 181 | }, 182 | }, 183 | "no context": { 184 | kubeconfig: noContext, 185 | expKonfs: []*Konfig{}, 186 | }, 187 | } 188 | 189 | for name, tc := range tt { 190 | t.Run(name, func(t *testing.T) { 191 | res, err := KonfsFromKubeconfig(strings.NewReader(tc.kubeconfig)) 192 | if err != nil { 193 | t.Fatal(err) 194 | } 195 | 196 | if !cmp.Equal(res, tc.expKonfs) { 197 | t.Errorf("Exp and given konfs differ: \n '%s'", cmp.Diff(tc.expKonfs, res)) 198 | } 199 | 200 | }) 201 | } 202 | } 203 | -------------------------------------------------------------------------------- /log/log.go: -------------------------------------------------------------------------------- 1 | package log 2 | 3 | import ( 4 | "io" 5 | "log" 6 | "os" 7 | ) 8 | 9 | var infoL *log.Logger 10 | var warnL *log.Logger 11 | 12 | // InitLogger initializes a new logger 13 | // Initialization must be done, before logging funcs can be called 14 | func InitLogger(info, warn io.Writer) { 15 | infoL = log.New(info, "INFO: ", 0) 16 | warnL = log.New(warn, "WARN: ", 0) 17 | } 18 | 19 | // Info prints the supplied format string using the Info logger 20 | func Info(format string, v ...interface{}) { 21 | infoL.Printf(format, v...) 22 | } 23 | 24 | // Warn prints the supplied format string using the Warn logger 25 | func Warn(format string, v ...interface{}) { 26 | warnL.Printf(format, v...) 27 | } 28 | 29 | func init() { 30 | InitLogger(os.Stderr, os.Stderr) 31 | } 32 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/simontheleg/konf-go/cmd" 8 | ) 9 | 10 | func main() { 11 | if err := cmd.Execute(); err != nil { 12 | fmt.Fprintf(os.Stderr, "konf execution has failed: %q\n", err) 13 | os.Exit(1) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /prompt/prompt.go: -------------------------------------------------------------------------------- 1 | package prompt 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "text/template" 7 | "unicode/utf8" 8 | 9 | "github.com/lithammer/fuzzysearch/fuzzy" 10 | "github.com/manifoldco/promptui" 11 | "github.com/simontheleg/konf-go/store" 12 | ) 13 | 14 | // RunFunc describes a generic function of a prompt. It returns the selected item. 15 | // Its main purpose is to be easily mockable for unit-tests 16 | type RunFunc func(*promptui.Select) (int, error) 17 | 18 | // Terminal runs a given prompt in the terminal of the user and 19 | // returns the selected items position 20 | func Terminal(prompt *promptui.Select) (sel int, err error) { 21 | pos, _, err := prompt.Run() 22 | if err != nil { 23 | return -1, fmt.Errorf("prompt failed %v", err) 24 | } 25 | return pos, nil 26 | } 27 | 28 | // FuzzyFilterKonf allows fuzzy searching of a list of konf metadata in the form of store.TableOutput 29 | func FuzzyFilterKonf(searchTerm string, curItem *store.Metadata) bool { 30 | // since there is no weight on any of the table entries, we can just combine them to one string 31 | // and run the contains on it, which automatically is going to match any of the three values 32 | r := fmt.Sprintf("%s %s %s", curItem.Context, curItem.Cluster, curItem.File) 33 | return fuzzy.Match(searchTerm, r) 34 | } 35 | 36 | // NewTableOutputTemplates returns templating strings for creating a nicely 37 | // formatted table out of an store.Metadata. Additionally it returns a 38 | // template.FuncMap with all required templating funcs for the strings. Maximum 39 | // length per column can be configured. 40 | func NewTableOutputTemplates(maxColumnLen int) (inactive, active, label string, fmap template.FuncMap) { 41 | // minColumnLen is determined by the length of the largest word in the label line 42 | minColumnLen := 7 43 | if maxColumnLen < minColumnLen { 44 | maxColumnLen = minColumnLen 45 | } 46 | 47 | fmap = template.FuncMap{} 48 | fmap["trunc"] = trunc 49 | fmap["repeat"] = repeat 50 | fmap["cyan"] = promptui.Styler(promptui.FGCyan) 51 | fmap["bold"] = promptui.Styler(promptui.FGBold) 52 | fmap["faint"] = promptui.Styler(promptui.FGFaint) // needed to display promptui tooltip https://github.com/manifoldco/promptui/blob/v0.9.0/select.go#L473 53 | fmap["green"] = promptui.Styler(promptui.FGGreen) // needed to display the successful selection https://github.com/manifoldco/promptui/blob/v0.9.0/select.go#L454 54 | 55 | // TODO figure out if we can do abbreviation using '...' somehow 56 | inactive = fmt.Sprintf(` {{ repeat %[1]d " " | print .Context | trunc %[1]d | %[2]s }} | {{ repeat %[1]d " " | print .Cluster | trunc %[1]d | %[2]s }} | {{ repeat %[1]d " " | print .File | trunc %[1]d | %[2]s }} |`, maxColumnLen, "") 57 | active = fmt.Sprintf(`▸ {{ repeat %[1]d " " | print .Context | trunc %[1]d | %[2]s }} | {{ repeat %[1]d " " | print .Cluster | trunc %[1]d | %[2]s }} | {{ repeat %[1]d " " | print .File | trunc %[1]d | %[2]s }} |`, maxColumnLen, "bold | cyan") 58 | label = fmt.Sprint(" Context" + strings.Repeat(" ", maxColumnLen-7) + " | " + "Cluster" + strings.Repeat(" ", maxColumnLen-7) + " | " + "File" + strings.Repeat(" ", maxColumnLen-4) + " ") // repeat = trunc - length of the word before it 59 | return inactive, active, label, fmap 60 | } 61 | 62 | func trunc(len int, str string) string { 63 | if len <= 0 { 64 | return str 65 | } 66 | if utf8.RuneCountInString(str) < len { 67 | return str 68 | } 69 | 70 | return string([]rune(str)[:len]) 71 | } 72 | 73 | func repeat(count int, str string) string { 74 | return strings.Repeat(str, count) 75 | } 76 | 77 | -------------------------------------------------------------------------------- /prompt/prompt_test.go: -------------------------------------------------------------------------------- 1 | package prompt 2 | 3 | import ( 4 | "bytes" 5 | "strings" 6 | "testing" 7 | "text/template" 8 | 9 | "github.com/simontheleg/konf-go/store" 10 | ) 11 | 12 | func TestFuzzyFilterKonf(t *testing.T) { 13 | tt := map[string]struct { 14 | search string 15 | item *store.Metadata 16 | expRes bool 17 | }{ 18 | "full match across all": { 19 | "a b c", 20 | &store.Metadata{Context: "a", Cluster: "b", File: "c"}, 21 | true, 22 | }, 23 | "full match across all - fuzzy": { 24 | "abc", 25 | &store.Metadata{Context: "a", Cluster: "b", File: "c"}, 26 | true, 27 | }, 28 | "partial match across fields": { 29 | "textclu", 30 | &store.Metadata{Context: "context", Cluster: "cluster", File: "file"}, 31 | true, 32 | }, 33 | "no match": { 34 | "oranges", 35 | &store.Metadata{Context: "apples", Cluster: "and", File: "bananas"}, 36 | false, 37 | }, 38 | } 39 | 40 | for name, tc := range tt { 41 | t.Run(name, func(t *testing.T) { 42 | res := FuzzyFilterKonf(tc.search, tc.item) 43 | if res != tc.expRes { 44 | t.Errorf("Exp res to be %t got %t", tc.expRes, res) 45 | } 46 | }) 47 | } 48 | } 49 | 50 | func TestPrepareTemplates(t *testing.T) { 51 | tt := map[string]struct { 52 | Values store.Metadata 53 | Trunc int 54 | ExpInactive string 55 | ExpActive string 56 | ExpLabel string 57 | }{ 58 | "values < trunc": { 59 | store.Metadata{ 60 | Context: "kind-eu", 61 | Cluster: "cluster-eu", 62 | File: "kind-eu.cluster-eu.yaml", 63 | }, 64 | 25, 65 | " kind-eu | cluster-eu | kind-eu.cluster-eu.yaml |", 66 | "▸ kind-eu | cluster-eu | kind-eu.cluster-eu.yaml |", 67 | " Context | Cluster | File ", 68 | }, 69 | "values == trunc": { 70 | store.Metadata{ 71 | Context: "0123456789", 72 | Cluster: "0123456789", 73 | File: "xyz.yaml", 74 | }, 75 | 10, 76 | " 0123456789 | 0123456789 | xyz.yaml |", 77 | "▸ 0123456789 | 0123456789 | xyz.yaml |", 78 | " Context | Cluster | File ", 79 | }, 80 | "values > trunc": { 81 | store.Metadata{ 82 | Context: "0123456789-andlotsmore", 83 | Cluster: "0123456789-andlotsmore", 84 | File: "xyz.yaml", 85 | }, 86 | 10, 87 | " 0123456789 | 0123456789 | xyz.yaml |", 88 | "▸ 0123456789 | 0123456789 | xyz.yaml |", 89 | " Context | Cluster | File ", 90 | }, 91 | "trunc is below minLength": { 92 | store.Metadata{ 93 | Context: "0123456789", 94 | Cluster: "0123456789", 95 | File: "xyz.yaml", 96 | }, 97 | 5, 98 | " 0123456 | 0123456 | xyz.yam |", 99 | "▸ 0123456 | 0123456 | xyz.yam |", 100 | " Context | Cluster | File ", 101 | }, 102 | } 103 | 104 | for name, tc := range tt { 105 | t.Run(name, func(t *testing.T) { 106 | inactive, active, label, fmap := NewTableOutputTemplates(tc.Trunc) 107 | 108 | checkTemplate(t, inactive, tc.Values, tc.ExpInactive, fmap) 109 | checkTemplate(t, active, tc.Values, tc.ExpActive, fmap) 110 | checkTemplate(t, label, tc.Values, tc.ExpLabel, fmap) 111 | }) 112 | } 113 | } 114 | 115 | func checkTemplate(t *testing.T, stpl string, val store.Metadata, exp string, fmap template.FuncMap) { 116 | 117 | tmpl, err := template.New("t").Funcs(fmap).Parse(stpl) 118 | if err != nil { 119 | t.Fatalf("Could not create template for test '%v'. Please check test code", err) 120 | } 121 | 122 | buf := new(bytes.Buffer) 123 | err = tmpl.Execute(buf, val) 124 | if err != nil { 125 | t.Fatalf("Could not execute template for test '%v'. Please check test code", err) 126 | } 127 | 128 | res := buf.String() 129 | // remove any formatting as we do not care about that 130 | cyan := "\x1b[36m" 131 | bold := "\x1b[1m" 132 | normal := "\x1b[0m" 133 | res = strings.Replace(res, cyan, "", -1) 134 | res = strings.Replace(res, bold, "", -1) 135 | res = strings.Replace(res, normal, "", -1) 136 | if exp != res { 137 | t.Errorf("Exp res: '%s', got: '%s'", exp, res) 138 | } 139 | } 140 | 141 | func TestTrunc(t *testing.T) { 142 | tt := []struct { 143 | str string 144 | len int 145 | exp string 146 | }{ 147 | {"12345678", 4, "1234"}, 148 | {"12345678", 0, "12345678"}, 149 | {"お前はもう死んでいる-何", 10, "お前はもう死んでいる"}, 150 | } 151 | 152 | for _, tc := range tt { 153 | res := trunc(tc.len, tc.str) 154 | if res != tc.exp { 155 | t.Errorf("Expected string %q, got %q", tc.exp, res) 156 | } 157 | } 158 | } 159 | 160 | // This is of course slightly silly, since we just use standard strings.Repeat 161 | // in our func, but the things we do for coverage ;) 162 | func TestRepeat(t *testing.T) { 163 | tt := []struct { 164 | str string 165 | count int 166 | exp string 167 | }{ 168 | {"1", 4, "1111"}, 169 | {"1 2", 5, "1 21 21 21 21 2"}, 170 | } 171 | 172 | for _, tc := range tt { 173 | res := repeat(tc.count, tc.str) 174 | if res != tc.exp { 175 | t.Errorf("Expected string %q, got %q", tc.exp, res) 176 | } 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /store/error.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | // KubeConfigOverload describes a state in which a kubeconfig has multiple Contexts or Clusters 8 | // This can be undesirable for konf when such a kubeconfig is in its store 9 | type KubeConfigOverload struct { 10 | path string 11 | } 12 | 13 | func (k *KubeConfigOverload) Error() string { 14 | return fmt.Sprintf("Impure Store: The kubeconfig %q contains multiple contexts and/or clusters. Please only use 'konf import' for populating the store\n", k.path) 15 | } 16 | 17 | // EmptyStore describes a state in which no kubeconfig is inside the store 18 | // It makes sense to have this in a separate case as it does not matter for some operations (e.g. importing) but detrimental for others (e.g. running the selection prompt) 19 | type EmptyStore struct { 20 | storepath string 21 | } 22 | 23 | func (e *EmptyStore) Error() string { 24 | return fmt.Sprintf("The konf store at %q is empty. Please run 'konf import' to populate it", e.storepath) 25 | } 26 | 27 | // NoMatch describes a state in which no konf was found matching the supplied glob 28 | // It makes sense to have this in a separate case as it does not matter for some operations (e.g. importing) but detrimental for others (e.g. running the selection prompt) 29 | type NoMatch struct { 30 | Pattern string 31 | } 32 | 33 | func (k *NoMatch) Error() string { 34 | return fmt.Sprintf("No konf file matched your search pattern %q", k.Pattern) 35 | } 36 | -------------------------------------------------------------------------------- /store/store.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | import ( 4 | "fmt" 5 | "io/fs" 6 | "path/filepath" 7 | "sort" 8 | "strings" 9 | 10 | "github.com/simontheleg/konf-go/konf" 11 | "github.com/simontheleg/konf-go/log" 12 | "github.com/simontheleg/konf-go/utils" 13 | "github.com/spf13/afero" 14 | k8s "k8s.io/client-go/tools/clientcmd/api/v1" 15 | "sigs.k8s.io/yaml" 16 | ) 17 | 18 | // Metadata describes a formatting of kubekonf information. 19 | // It is mainly being used to present the user a nice table selection 20 | type Metadata struct { 21 | Context string 22 | Cluster string 23 | File string 24 | } 25 | 26 | type Storemanager struct { 27 | Activedir string 28 | Storedir string 29 | LatestKonfPath string 30 | Fs afero.Fs 31 | } 32 | 33 | // FetchAllKonfs retrieves metadata for all konfs currently in the store 34 | func (s *Storemanager) FetchAllKonfs() ([]*Metadata, error) { 35 | return s.FetchKonfsForGlob("*") 36 | } 37 | 38 | // FetchKonfsForGlob returns all konfs whose name matches the supplied pattern. 39 | // Pattern matching is done using [filepath.Match]. The pattern should only 40 | // include the name of the file itself not its full path. Also it should not 41 | // include the extension of the file. All relation to the konfs StoreDir will be 42 | // handled automatically. 43 | // 44 | // [filepath.Match]: https://pkg.go.dev/path/filepath#Match 45 | func (s *Storemanager) FetchKonfsForGlob(pattern string) ([]*Metadata, error) { 46 | var konfs []fs.FileInfo 47 | var filesChecked int 48 | 49 | err := afero.Walk(s.Fs, s.Storedir, func(path string, info fs.FileInfo, errPath error) error { 50 | // do not add directories. This is important as later we check the number of items in konf to determine whether store is empty or not 51 | // without this check we would display an empty prompt if the user has only directories in their storeDir 52 | if info.IsDir() && path != s.Storedir { 53 | return filepath.SkipDir 54 | } 55 | 56 | // skip any hidden files 57 | if strings.HasPrefix(info.Name(), ".") { 58 | // I have decided to not print any log line on this, which differs from the logic 59 | // for malformed kubeconfigs. I think this makes sense as konf import will never produce 60 | // a hidden file and the purpose of this check is rather to protect against 61 | // automatically created files like the .DS_Store on MacOs. On the other side however 62 | // it is quite easy to create a malformed kubeconfig without noticing 63 | return nil 64 | } 65 | 66 | // only increment filesChecked after we have sorted out directories and hidden files 67 | filesChecked++ 68 | 69 | // skip any files that do not match our glob 70 | patternPath := s.Storedir + "/" + pattern + ".yaml" 71 | patternPath = strings.TrimPrefix(patternPath, "./") // we need this as afero.Walk trims out any leading "./" 72 | match, err := filepath.Match(patternPath, path) 73 | if err != nil { 74 | return fmt.Errorf("Could not apply glob %q: %v", pattern, err) 75 | } 76 | if !match { 77 | return nil 78 | } 79 | 80 | konfs = append(konfs, info) 81 | return nil 82 | }) 83 | 84 | if err != nil { 85 | return nil, err 86 | } 87 | 88 | // at this point it is worth mentioning, that we do not need to remove the 89 | // root element from the list of konfs anymore. This is because filepath.Match 90 | // never matches for the root element, and therefore the root iself is not 91 | // part of the list anymore 92 | 93 | // if the walkfunc only ran once, it means that the storedir does not contain any file which could be a kubeconfig 94 | // It will always run at least once because we do not skip the rootDir 95 | if filesChecked == 1 { 96 | return nil, &EmptyStore{storepath: s.Storedir} 97 | } 98 | 99 | // similar to fs.ReadDir, sort the entries for easier viewing for the user and to 100 | // be consistent with what shells return during auto-completion 101 | sort.Slice(konfs, func(i, j int) bool { return konfs[i].Name() < konfs[j].Name() }) 102 | 103 | if len(konfs) == 0 { 104 | return nil, &NoMatch{Pattern: pattern} 105 | } 106 | 107 | out := []*Metadata{} 108 | // TODO the logic of this loop should be extracted into the walkFn above to avoid looping twice 109 | // TODO (possibly the walkfunction should also be extracted into its own function) 110 | for _, k := range konfs { 111 | 112 | id := konf.IDFromFileInfo(k) 113 | path := s.StorePathFromID(id) 114 | file, err := s.Fs.Open(path) 115 | if err != nil { 116 | return nil, err 117 | } 118 | val, err := afero.ReadAll(file) 119 | if err != nil { 120 | return nil, err 121 | } 122 | kubeconf := &k8s.Config{} 123 | err = yaml.Unmarshal(val, kubeconf) 124 | if err != nil { 125 | log.Warn("file %q does not contain a valid kubeconfig. Skipping for evaluation", path) 126 | continue 127 | } 128 | 129 | if len(kubeconf.Contexts) > 1 || len(kubeconf.Clusters) > 1 { 130 | // This directly returns, as an impure store is a danger for other usage down the road 131 | return nil, &KubeConfigOverload{path} 132 | } 133 | 134 | t := Metadata{} 135 | t.Context = kubeconf.Contexts[0].Name 136 | t.Cluster = kubeconf.Clusters[0].Name 137 | t.File = path 138 | out = append(out, &t) 139 | } 140 | return out, nil 141 | } 142 | 143 | // WriteKonfToStore writes the config to the store according to the store manager 144 | func (s *Storemanager) WriteKonfToStore(konf *konf.Konfig) (storepath string, err error) { 145 | b, err := yaml.Marshal(konf.Kubeconfig) 146 | if err != nil { 147 | return "", err 148 | } 149 | 150 | storepath = s.StorePathFromID(konf.Id) 151 | 152 | err = afero.WriteFile(s.Fs, storepath, b, utils.KonfPerm) 153 | if err != nil { 154 | return "", err 155 | } 156 | 157 | return storepath, nil 158 | } 159 | 160 | // ActivePathForID returns the active filepath for an id 161 | func (s *Storemanager) ActivePathFromID(id konf.KonfID) string { 162 | return genIDPath(s.Activedir, string(id)) 163 | } 164 | 165 | // StorePathFromID returns the active filepath for an id 166 | func (s *Storemanager) StorePathFromID(id konf.KonfID) string { 167 | return genIDPath(s.Storedir, string(id)) 168 | } 169 | 170 | func genIDPath(path, id string) string { 171 | return path + "/" + id + ".yaml" 172 | } 173 | -------------------------------------------------------------------------------- /store/store_test.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/google/go-cmp/cmp" 7 | "github.com/simontheleg/konf-go/konf" 8 | "github.com/simontheleg/konf-go/testhelper" 9 | "github.com/spf13/afero" 10 | "k8s.io/client-go/kubernetes" 11 | "k8s.io/client-go/tools/clientcmd" 12 | k8s "k8s.io/client-go/tools/clientcmd/api/v1" 13 | ) 14 | 15 | func TestFetchAllKonfs(t *testing.T) { 16 | storeDir := "./konf/store" 17 | activeDir := "./konf/active" 18 | fm := testhelper.FilesystemManager{Storedir: storeDir, Activedir: activeDir} 19 | 20 | tt := map[string]struct { 21 | fsCreator func() afero.Fs 22 | checkError func(*testing.T, error) // currently this convoluted mess is needed so we can accurately check for types. errors.As does not work in our case 23 | expTableOut []*Metadata 24 | }{ 25 | "empty store": { 26 | fsCreator: testhelper.FSWithFiles(fm.StoreDir), 27 | checkError: expEmptyStore, 28 | expTableOut: nil, 29 | }, 30 | "valid konfs and a wrong konf": { 31 | fsCreator: testhelper.FSWithFiles(fm.StoreDir, fm.SingleClusterSingleContextEU, fm.SingleClusterSingleContextASIA, fm.InvalidYaml), 32 | checkError: expNil, 33 | expTableOut: []*Metadata{ 34 | { 35 | Context: "dev-asia", 36 | Cluster: "dev-asia-1", 37 | File: "./konf/store/dev-asia_dev-asia-1.yaml", 38 | }, 39 | { 40 | Context: "dev-eu", 41 | Cluster: "dev-eu-1", 42 | File: "./konf/store/dev-eu_dev-eu-1.yaml", 43 | }, 44 | }, 45 | }, 46 | "overloaded konf (cluster)": { 47 | fsCreator: testhelper.FSWithFiles(fm.StoreDir, fm.MultiClusterSingleContext), 48 | checkError: expKubeConfigOverload, 49 | expTableOut: nil, 50 | }, 51 | "overloaded konf (context)": { 52 | fsCreator: testhelper.FSWithFiles(fm.StoreDir, fm.SingleClusterMultiContext), 53 | checkError: expKubeConfigOverload, 54 | expTableOut: nil, 55 | }, 56 | "the nice MacOS .DS_Store file": { 57 | fsCreator: testhelper.FSWithFiles(fm.StoreDir, fm.DSStore, fm.SingleClusterSingleContextEU), 58 | checkError: expNil, 59 | expTableOut: []*Metadata{ 60 | { 61 | Context: "dev-eu", 62 | Cluster: "dev-eu-1", 63 | File: "./konf/store/dev-eu_dev-eu-1.yaml", 64 | }, 65 | }, 66 | }, 67 | "ignore directories": { 68 | fsCreator: testhelper.FSWithFiles(fm.StoreDir, fm.SingleClusterSingleContextEU, fm.EmptyDir), 69 | checkError: expNil, 70 | expTableOut: []*Metadata{ 71 | { 72 | Context: "dev-eu", 73 | Cluster: "dev-eu-1", 74 | File: "./konf/store/dev-eu_dev-eu-1.yaml", 75 | }, 76 | }, 77 | }, 78 | "only directories in store": { 79 | fsCreator: testhelper.FSWithFiles(fm.StoreDir, fm.EmptyDir), 80 | checkError: expEmptyStore, 81 | expTableOut: nil, 82 | }, 83 | } 84 | 85 | for name, tc := range tt { 86 | 87 | t.Run(name, func(t *testing.T) { 88 | sm := Storemanager{Activedir: activeDir, Storedir: storeDir, Fs: tc.fsCreator()} 89 | out, err := sm.FetchAllKonfs() 90 | 91 | tc.checkError(t, err) 92 | 93 | if !cmp.Equal(tc.expTableOut, out) { 94 | t.Errorf("Exp and given Tableoutputs differ:\n'%s'", cmp.Diff(tc.expTableOut, out)) 95 | } 96 | }) 97 | } 98 | } 99 | 100 | // This test is mainly required due to an interesting design by afero. More specifically afero 101 | // parses out any leading "./" characters for all files inside the folder, but not for the root 102 | // element itself. But because konfDir can be configured to anything we want, we need to test 103 | // for these cases as well 104 | func TestFetchAllKonfsCustomKonfDir(t *testing.T) { 105 | storeDir := "konf/store" 106 | activeDir := "konf/active" 107 | fm := testhelper.FilesystemManager{Storedir: storeDir, Activedir: activeDir} 108 | 109 | tt := map[string]struct { 110 | fsCreator func() afero.Fs 111 | checkError func(*testing.T, error) // currently this convoluted mess is needed so we can accurately check for types. errors.As does not work in our case 112 | expTableOutput []*Metadata 113 | }{ 114 | "empty store": { 115 | fsCreator: testhelper.FSWithFiles(fm.StoreDir), 116 | checkError: expEmptyStore, 117 | expTableOutput: nil, 118 | }, 119 | "valid konfs and a wrong konf": { 120 | fsCreator: testhelper.FSWithFiles(fm.StoreDir, fm.SingleClusterSingleContextEU, fm.SingleClusterSingleContextASIA, fm.InvalidYaml), 121 | checkError: expNil, 122 | expTableOutput: []*Metadata{ 123 | { 124 | Context: "dev-asia", 125 | Cluster: "dev-asia-1", 126 | File: "konf/store/dev-asia_dev-asia-1.yaml", 127 | }, 128 | { 129 | Context: "dev-eu", 130 | Cluster: "dev-eu-1", 131 | File: "konf/store/dev-eu_dev-eu-1.yaml", 132 | }, 133 | }, 134 | }, 135 | "overloaded konf (cluster)": { 136 | fsCreator: testhelper.FSWithFiles(fm.StoreDir, fm.MultiClusterSingleContext), 137 | checkError: expKubeConfigOverload, 138 | expTableOutput: nil, 139 | }, 140 | "overloaded konf (context)": { 141 | fsCreator: testhelper.FSWithFiles(fm.StoreDir, fm.SingleClusterMultiContext), 142 | checkError: expKubeConfigOverload, 143 | expTableOutput: nil, 144 | }, 145 | "the nice MacOS .DS_Store file": { 146 | fsCreator: testhelper.FSWithFiles(fm.StoreDir, fm.DSStore, fm.SingleClusterSingleContextEU), 147 | checkError: expNil, 148 | expTableOutput: []*Metadata{ 149 | { 150 | Context: "dev-eu", 151 | Cluster: "dev-eu-1", 152 | File: "konf/store/dev-eu_dev-eu-1.yaml", 153 | }, 154 | }, 155 | }, 156 | "ignore directories": { 157 | fsCreator: testhelper.FSWithFiles(fm.StoreDir, fm.SingleClusterSingleContextEU, fm.EmptyDir), 158 | checkError: expNil, 159 | expTableOutput: []*Metadata{ 160 | { 161 | Context: "dev-eu", 162 | Cluster: "dev-eu-1", 163 | File: "konf/store/dev-eu_dev-eu-1.yaml", 164 | }, 165 | }, 166 | }, 167 | "only directories in store": { 168 | fsCreator: testhelper.FSWithFiles(fm.StoreDir, fm.EmptyDir), 169 | checkError: expEmptyStore, 170 | expTableOutput: nil, 171 | }, 172 | } 173 | 174 | for name, tc := range tt { 175 | 176 | t.Run(name, func(t *testing.T) { 177 | sm := Storemanager{Activedir: activeDir, Storedir: storeDir, Fs: tc.fsCreator()} 178 | out, err := sm.FetchAllKonfs() 179 | 180 | tc.checkError(t, err) 181 | 182 | if !cmp.Equal(tc.expTableOutput, out) { 183 | t.Errorf("Exp and given Tableoutputs differ:\n'%s'", cmp.Diff(tc.expTableOutput, out)) 184 | } 185 | }) 186 | } 187 | } 188 | 189 | func TestFetchKonfsForGlob(t *testing.T) { 190 | storeDir := "./konf/store" 191 | activeDir := "./konf/active" 192 | fm := testhelper.FilesystemManager{Storedir: storeDir, Activedir: activeDir} 193 | 194 | tt := map[string]struct { 195 | fsCreator func() afero.Fs 196 | checkError func(*testing.T, error) // currently this convoluted mess is needed so we can accurately check for types. errors.As does not work in our case 197 | glob string 198 | expTableOut []*Metadata 199 | }{ 200 | "match eu konf": { 201 | fsCreator: testhelper.FSWithFiles(fm.StoreDir, fm.SingleClusterSingleContextEU, fm.SingleClusterSingleContextASIA, fm.InvalidYaml), 202 | checkError: expNil, 203 | glob: "dev-eu*", 204 | expTableOut: []*Metadata{ 205 | { 206 | Context: "dev-eu", 207 | Cluster: "dev-eu-1", 208 | File: "./konf/store/dev-eu_dev-eu-1.yaml", 209 | }, 210 | }, 211 | }, 212 | "match eu konf no expansion": { 213 | fsCreator: testhelper.FSWithFiles(fm.StoreDir, fm.SingleClusterSingleContextEU, fm.SingleClusterSingleContextASIA, fm.InvalidYaml), 214 | checkError: expNil, 215 | glob: "dev-eu_dev-eu-1", 216 | expTableOut: []*Metadata{ 217 | { 218 | Context: "dev-eu", 219 | Cluster: "dev-eu-1", 220 | File: "./konf/store/dev-eu_dev-eu-1.yaml", 221 | }, 222 | }, 223 | }, 224 | "match asia konf": { 225 | fsCreator: testhelper.FSWithFiles(fm.StoreDir, fm.SingleClusterSingleContextEU, fm.SingleClusterSingleContextASIA, fm.InvalidYaml), 226 | checkError: expNil, 227 | glob: "dev-asia*", 228 | expTableOut: []*Metadata{ 229 | { 230 | Context: "dev-asia", 231 | Cluster: "dev-asia-1", 232 | File: "./konf/store/dev-asia_dev-asia-1.yaml", 233 | }, 234 | }, 235 | }, 236 | "directory ignore takes precedence over glob": { 237 | fsCreator: testhelper.FSWithFiles(fm.StoreDir, fm.EUDir, fm.SingleClusterSingleContextEU, fm.SingleClusterSingleContextASIA), 238 | checkError: expNil, 239 | glob: "dev-eu*", 240 | expTableOut: []*Metadata{ 241 | { 242 | Context: "dev-eu", 243 | Cluster: "dev-eu-1", 244 | File: "./konf/store/dev-eu_dev-eu-1.yaml", 245 | }, 246 | }, 247 | }, 248 | "no match, but valid konfs exist": { 249 | fsCreator: testhelper.FSWithFiles(fm.SingleClusterSingleContextEU), 250 | checkError: expNoMatch, 251 | glob: "no-match", 252 | expTableOut: nil, 253 | }, 254 | } 255 | 256 | for name, tc := range tt { 257 | 258 | t.Run(name, func(t *testing.T) { 259 | fs := tc.fsCreator() 260 | sm := &Storemanager{Activedir: activeDir, Storedir: storeDir, Fs: fs} 261 | 262 | out, err := sm.FetchKonfsForGlob(tc.glob) 263 | 264 | tc.checkError(t, err) 265 | 266 | if !cmp.Equal(tc.expTableOut, out) { 267 | t.Errorf("Exp and given Tableoutputs differ:\n'%s'", cmp.Diff(tc.expTableOut, out)) 268 | } 269 | }) 270 | } 271 | } 272 | 273 | func expEmptyStore(t *testing.T, err error) { 274 | if _, ok := err.(*EmptyStore); !ok { 275 | t.Errorf("Expected err to be of type EmptyStore") 276 | } 277 | } 278 | 279 | func expKubeConfigOverload(t *testing.T, err error) { 280 | if _, ok := err.(*KubeConfigOverload); !ok { 281 | t.Errorf("Expected err to be of type KubeConfigOverload") 282 | } 283 | } 284 | 285 | func expNil(t *testing.T, err error) { 286 | if err != nil { 287 | t.Errorf("Expected err to be nil, but got %q", err) 288 | } 289 | } 290 | 291 | func expNoMatch(t *testing.T, err error) { 292 | if _, ok := err.(*NoMatch); !ok { 293 | t.Errorf("Expected err to be of type NoMatch") 294 | } 295 | } 296 | 297 | func TestWriteKonfToStore(t *testing.T) { 298 | storeDir := "./konf/store" 299 | activeDir := "./konf/active" 300 | fm := testhelper.FilesystemManager{Storedir: storeDir, Activedir: activeDir} 301 | f := testhelper.FSWithFiles(fm.ActiveDir, fm.StoreDir)() 302 | sm := &Storemanager{Activedir: activeDir, Storedir: storeDir, Fs: f} 303 | 304 | expContent := `apiVersion: v1 305 | clusters: 306 | - cluster: 307 | server: https://10.1.1.0 308 | name: dev-eu-1 309 | contexts: 310 | - context: 311 | cluster: dev-eu-1 312 | namespace: kube-public 313 | user: dev-eu 314 | name: dev-eu 315 | current-context: dev-eu 316 | kind: Config 317 | preferences: {} 318 | users: 319 | - name: dev-eu 320 | user: {} 321 | ` 322 | 323 | expPath := "./konf/store/dev-eu_dev-eu-1.yaml" 324 | 325 | var devEUControlGroup = &konf.Konfig{ 326 | Id: konf.IDFromClusterAndContext("dev-eu-1", "dev-eu"), 327 | Kubeconfig: k8s.Config{ 328 | APIVersion: "v1", 329 | Kind: "Config", 330 | CurrentContext: "dev-eu", 331 | Clusters: []k8s.NamedCluster{ 332 | { 333 | Name: "dev-eu-1", 334 | Cluster: k8s.Cluster{ 335 | Server: "https://10.1.1.0", 336 | }, 337 | }, 338 | }, 339 | Contexts: []k8s.NamedContext{ 340 | { 341 | Name: "dev-eu", 342 | Context: k8s.Context{ 343 | Cluster: "dev-eu-1", 344 | Namespace: "kube-public", 345 | AuthInfo: "dev-eu", 346 | }, 347 | }, 348 | }, 349 | AuthInfos: []k8s.NamedAuthInfo{ 350 | { 351 | Name: "dev-eu", 352 | }, 353 | }, 354 | }, 355 | } 356 | 357 | p, err := sm.WriteKonfToStore(devEUControlGroup) 358 | if err != nil { 359 | t.Errorf("Exp err to be nil but got %q", err) 360 | } 361 | 362 | if p != expPath { 363 | t.Errorf("Exp path to be %q, but got %q", expPath, p) 364 | } 365 | 366 | b, err := afero.ReadFile(f, expPath) 367 | if err != nil { 368 | t.Errorf("Exp read in file without any issues, but got %q", err) 369 | } 370 | 371 | res := string(b) 372 | if res != expContent { 373 | t.Errorf("\nExp:\n%s\ngot\n%s\n", expContent, res) 374 | } 375 | 376 | // check if the konf is also valid for creating a clientset 377 | conf, err := clientcmd.NewClientConfigFromBytes(b) 378 | if err != nil { 379 | t.Errorf("Exp to create clientconfig, but got %q", err) 380 | } 381 | cc, err := conf.ClientConfig() 382 | if err != nil { 383 | t.Errorf("Exp to extract rest.config, but got %q", err) 384 | } 385 | _, err = kubernetes.NewForConfig(cc) 386 | if err != nil { 387 | t.Errorf("Exp to create clientset, but got %q", err) 388 | } 389 | 390 | } 391 | 392 | func TestActivePathFromID(t *testing.T) { 393 | sm := Storemanager{Activedir: "something/active", Storedir: "something/store"} 394 | konfID := konf.IDFromClusterAndContext("mycluster", "mycontext") 395 | res := sm.ActivePathFromID(konfID) 396 | expRes := "something/active/mycontext_mycluster.yaml" 397 | if res != expRes { 398 | t.Errorf("wanted id %q, got %q", expRes, res) 399 | } 400 | } 401 | 402 | func TestStorePathFromID(t *testing.T) { 403 | sm := Storemanager{Activedir: "something/active", Storedir: "something/store"} 404 | konfID := konf.IDFromClusterAndContext("mycluster", "mycontext") 405 | res := sm.StorePathFromID(konfID) 406 | expRes := "something/store/mycontext_mycluster.yaml" 407 | if res != expRes { 408 | t.Errorf("wanted id %q, got %q", expRes, res) 409 | } 410 | } 411 | -------------------------------------------------------------------------------- /testhelper/shellwrapper.sh: -------------------------------------------------------------------------------- 1 | # This script tests the compatibility of the konf-go shellwrapper with different shells 2 | # Therefore it has no shebang line and is intended to be executed directly by the shell to test 3 | 4 | set -o errexit 5 | set -o pipefail 6 | 7 | 8 | if [[ -n ${ZSH_VERSION} ]] 9 | then 10 | shell="zsh" 11 | autoload -U add-zsh-hook # this is required so the shellwrapper can be sourced 12 | elif [[ -n ${BASH_VERSION} ]] 13 | then 14 | shell="bash" 15 | else 16 | echo "no valid shell detected" 17 | exit 1 18 | fi 19 | 20 | echo "${shell} detected. Running tests using ${shell} wrapper" 21 | 22 | source <(konf-go shellwrapper ${shell}) 23 | 24 | KONFDIR=$(mktemp -d) 25 | mkdir ${KONFDIR}/store 26 | KONF=${KONFDIR}/store/test_test.yaml 27 | touch ${KONF} 28 | konf --konf-dir=${KONFDIR} set test_test 29 | 30 | # if kubeconfig points to something in the active 31 | if [[ $KUBECONFIG != "${KONFDIR}/active"* ]]; then 32 | echo "Expected KUBECONFIG to point to a file inside '${KONFDIR}/active', but got '${KUBECONFIG}'" 33 | fi 34 | 35 | echo "KUBECONFIG points to '${KUBECONFIG}', which looks fine" -------------------------------------------------------------------------------- /testhelper/unit.go: -------------------------------------------------------------------------------- 1 | package testhelper 2 | 3 | import ( 4 | "github.com/simontheleg/konf-go/utils" 5 | "github.com/spf13/afero" 6 | v1 "k8s.io/api/core/v1" 7 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 8 | ) 9 | 10 | // EqualError reports whether errors a and b are considered equal. 11 | // They're equal if both are nil, or both are not nil and a.Error() == b.Error(). 12 | func EqualError(a, b error) bool { 13 | return a == nil && b == nil || a != nil && b != nil && a.Error() == b.Error() 14 | } 15 | 16 | type filefunc = func(afero.Fs) 17 | 18 | // FSWithFiles is a testhelper that can be used to quickly setup a MemMapFs with required Files 19 | func FSWithFiles(ff ...filefunc) func() afero.Fs { 20 | return func() afero.Fs { 21 | fs := afero.NewMemMapFs() 22 | 23 | for _, f := range ff { 24 | f(fs) 25 | } 26 | return fs 27 | } 28 | } 29 | 30 | // FilesystemManager is used to manage filefuncs. It is feature identical to 31 | // its string counterpart SampleKonfManager 32 | type FilesystemManager struct { 33 | Storedir string 34 | Activedir string 35 | LatestKonfPath string 36 | } 37 | 38 | // it makes sense to reimplement the path generation for store and active, so this package does 39 | // not need to depend on the konf or id package and we can freely use it. 40 | // TODO remove this once id and store pkg are properly separated 41 | func (f *FilesystemManager) storePathForID(id string) string { 42 | return f.Storedir + "/" + id + ".yaml" 43 | } 44 | 45 | func (f *FilesystemManager) activePathForID(id string) string { 46 | return f.Activedir + "/" + id + ".yaml" 47 | } 48 | 49 | // StoreDir creates standard konf store 50 | func (f *FilesystemManager) StoreDir(fs afero.Fs) { 51 | fs.MkdirAll(f.Storedir, utils.KonfPerm) 52 | } 53 | 54 | // ActiveDir creates standard konf active 55 | func (f *FilesystemManager) ActiveDir(fs afero.Fs) { 56 | fs.MkdirAll(f.Activedir, utils.KonfPerm) 57 | } 58 | 59 | // SingleClusterSingleContextEU creates a valid kubeconfig in store and active 60 | func (f *FilesystemManager) SingleClusterSingleContextEU(fs afero.Fs) { 61 | afero.WriteFile(fs, f.storePathForID("dev-eu_dev-eu-1"), []byte(singleClusterSingleContextEU), utils.KonfPerm) 62 | afero.WriteFile(fs, f.activePathForID("dev-eu_dev-eu-1"), []byte(singleClusterSingleContextEU), utils.KonfPerm) 63 | } 64 | 65 | // SingleClusterSingleContextASIA creates a valid kubeconfig in store and active 66 | func (f *FilesystemManager) SingleClusterSingleContextASIA(fs afero.Fs) { 67 | afero.WriteFile(fs, f.storePathForID("dev-asia_dev-asia-1"), []byte(singleClusterSingleContextASIA), utils.KonfPerm) 68 | afero.WriteFile(fs, f.activePathForID("dev-asia_dev-asia-1"), []byte(singleClusterSingleContextASIA), utils.KonfPerm) 69 | } 70 | 71 | // SingleClusterSingleContextEU2 creates a second valid kubeconfig in store and active. It is mainly used for glob testing 72 | func (f *FilesystemManager) SingleClusterSingleContextEU2(fs afero.Fs) { 73 | afero.WriteFile(fs, f.storePathForID("dev-eu_dev-eu-2"), []byte(singleClusterSingleContextEU2), utils.KonfPerm) 74 | afero.WriteFile(fs, f.activePathForID("dev-eu_dev-eu-2"), []byte(singleClusterSingleContextEU2), utils.KonfPerm) 75 | } 76 | 77 | // SingleClusterSingleContextASIA2 creates a second valid kubeconfig in store and active. It is mainly used for glob testing 78 | func (f *FilesystemManager) SingleClusterSingleContextASIA2(fs afero.Fs) { 79 | afero.WriteFile(fs, f.storePathForID("dev-asia_dev-asia-2"), []byte(singleClusterSingleContextASIA2), utils.KonfPerm) 80 | afero.WriteFile(fs, f.activePathForID("dev-asia_dev-asia-2"), []byte(singleClusterSingleContextASIA2), utils.KonfPerm) 81 | } 82 | 83 | // InvalidYaml creates an invalidYaml in store and active 84 | func (f *FilesystemManager) InvalidYaml(fs afero.Fs) { 85 | afero.WriteFile(fs, f.storePathForID("no-konf"), []byte("I am no valid yaml"), utils.KonfPerm) 86 | afero.WriteFile(fs, f.activePathForID("no-konf"), []byte("I am no valid yaml"), utils.KonfPerm) 87 | } 88 | 89 | // MultiClusterMultiContext creates a kubeconfig with multiple clusters and contexts in store, resulting in an impure konfstore 90 | func (f *FilesystemManager) MultiClusterMultiContext(fs afero.Fs) { 91 | afero.WriteFile(fs, f.storePathForID("multi_multi_konf"), []byte(multiClusterMultiContext), utils.KonfPerm) 92 | } 93 | 94 | // MultiClusterSingleContext creates a kubeconfig with multiple clusters and one context in store, resulting in an impure konfstore 95 | func (f *FilesystemManager) MultiClusterSingleContext(fs afero.Fs) { 96 | afero.WriteFile(fs, f.storePathForID("multi_konf"), []byte(multiClusterSingleContext), utils.KonfPerm) 97 | } 98 | 99 | // SingleClusterMultiContext creates a kubeconfig with one cluster and multiple contexts in store, resulting in an impure konfstore 100 | func (f *FilesystemManager) SingleClusterMultiContext(fs afero.Fs) { 101 | afero.WriteFile(fs, f.storePathForID("multi_konf"), []byte(singleClusterMultiContext), utils.KonfPerm) 102 | } 103 | 104 | // LatestKonf creates a latestKonfFile pointing to an imaginary context and cluster 105 | func (f *FilesystemManager) LatestKonf(fs afero.Fs) { 106 | afero.WriteFile(fs, f.LatestKonfPath, []byte("context_cluster"), utils.KonfPerm) 107 | } 108 | 109 | // KonfWithoutContext creates a kubeconfig which has no context, but still is valid 110 | func (f *FilesystemManager) KonfWithoutContext(fs afero.Fs) { 111 | var noContext = ` 112 | apiVersion: v1 113 | clusters: 114 | - cluster: 115 | server: https://10.1.1.0 116 | name: dev-eu-1 117 | kind: Config 118 | preferences: {} 119 | users: 120 | - name: dev-eu 121 | user: {} 122 | ` 123 | 124 | afero.WriteFile(fs, f.storePathForID("no-context"), []byte(noContext), utils.KonfPerm) 125 | afero.WriteFile(fs, f.activePathForID("no-context"), []byte(noContext), utils.KonfPerm) 126 | } 127 | 128 | // KonfWithoutContext2 creates a kubeconfig which has no context, but still is valid 129 | func (f *FilesystemManager) KonfWithoutContext2(fs afero.Fs) { 130 | var noContext = ` 131 | apiVersion: v1 132 | clusters: 133 | - cluster: 134 | server: https://10.1.1.0 135 | name: dev-eu-2 136 | kind: Config 137 | preferences: {} 138 | users: 139 | - name: dev-eu 140 | user: {} 141 | ` 142 | 143 | afero.WriteFile(fs, f.storePathForID("no-context-2"), []byte(noContext), utils.KonfPerm) 144 | afero.WriteFile(fs, f.activePathForID("no-context-2"), []byte(noContext), utils.KonfPerm) 145 | } 146 | 147 | // DSStore creates a .DS_Store file, that has caused quite some problems in the past 148 | func (f *FilesystemManager) DSStore(fs afero.Fs) { 149 | // in this case we cannot use StorePathForID, as this would append .yaml 150 | afero.WriteFile(fs, f.Storedir+"/.DS_Store", nil, utils.KonfPerm) 151 | afero.WriteFile(fs, f.Activedir+"/.DS_Store", nil, utils.KonfPerm) 152 | } 153 | 154 | // EmptyDir creates an EmptyDir in StoreDir and ActiveDir 155 | func (f *FilesystemManager) EmptyDir(fs afero.Fs) { 156 | // in this case we cannot use StorePathForID, as this would append .yaml 157 | fs.Mkdir(f.Storedir+"empty-dir", utils.KonfDirPerm) 158 | fs.Mkdir(f.Activedir+"empty-dir", utils.KonfDirPerm) 159 | } 160 | 161 | // EUDir creates an dir called "eu" in StoreDir and ActiveDir. It is mainly used to test globing 162 | func (f *FilesystemManager) EUDir(fs afero.Fs) { 163 | // in this case we cannot use StorePathForID, as this would append .yaml 164 | fs.Mkdir(f.Storedir+"eu", utils.KonfDirPerm) 165 | fs.Mkdir(f.Activedir+"eu", utils.KonfDirPerm) 166 | } 167 | 168 | // SampleKonfManager is used to manage kubeconfig strings. It is feature identical to 169 | // its file counterpart FilesystemManager 170 | type SampleKonfManager struct{} 171 | 172 | // SingleClusterSingleContextEU returns a valid kubeconfig 173 | func (*SampleKonfManager) SingleClusterSingleContextEU() string { 174 | return singleClusterSingleContextEU 175 | } 176 | 177 | // SingleClusterSingleContextASIA returns a valid kubeconfig 178 | func (*SampleKonfManager) SingleClusterSingleContextASIA() string { 179 | return singleClusterSingleContextASIA 180 | } 181 | 182 | // MultiClusterMultiContext returns a valid kubeconfig, that is unprocessed 183 | func (*SampleKonfManager) MultiClusterMultiContext() string { 184 | return multiClusterMultiContext 185 | } 186 | 187 | // MultiClusterSingleContext returns a valid kubeconfig, that is unprocessed 188 | func (*SampleKonfManager) MultiClusterSingleContext() string { 189 | return multiClusterSingleContext 190 | } 191 | 192 | var singleClusterSingleContextEU = ` 193 | apiVersion: v1 194 | clusters: 195 | - cluster: 196 | server: https://10.1.1.0 197 | name: dev-eu-1 198 | contexts: 199 | - context: 200 | namespace: kube-public 201 | cluster: dev-eu-1 202 | user: dev-eu 203 | name: dev-eu 204 | current-context: dev-eu 205 | kind: Config 206 | preferences: {} 207 | users: 208 | - name: dev-eu 209 | user: {} 210 | ` 211 | var singleClusterSingleContextEU2 = ` 212 | apiVersion: v1 213 | clusters: 214 | - cluster: 215 | server: https://10.1.1.0 216 | name: dev-eu-2 217 | contexts: 218 | - context: 219 | namespace: kube-public 220 | cluster: dev-eu-2 221 | user: dev-eu 222 | name: dev-eu 223 | current-context: dev-eu 224 | kind: Config 225 | preferences: {} 226 | users: 227 | - name: dev-eu 228 | user: {} 229 | ` 230 | 231 | var singleClusterSingleContextASIA = ` 232 | apiVersion: v1 233 | clusters: 234 | - cluster: 235 | server: https://10.1.1.0 236 | name: dev-asia-1 237 | contexts: 238 | - context: 239 | namespace: kube-public 240 | cluster: dev-asia-1 241 | user: dev-asia 242 | name: dev-asia 243 | current-context: dev-asia 244 | kind: Config 245 | preferences: {} 246 | users: 247 | - name: dev-asia 248 | user: {} 249 | ` 250 | var singleClusterSingleContextASIA2 = ` 251 | apiVersion: v1 252 | clusters: 253 | - cluster: 254 | server: https://10.1.1.0 255 | name: dev-asia-2 256 | contexts: 257 | - context: 258 | namespace: kube-public 259 | cluster: dev-asia-2 260 | user: dev-asia 261 | name: dev-asia 262 | current-context: dev-asia 263 | kind: Config 264 | preferences: {} 265 | users: 266 | - name: dev-asia 267 | user: {} 268 | ` 269 | 270 | var multiClusterMultiContext = ` 271 | apiVersion: v1 272 | clusters: 273 | - cluster: 274 | server: https://192.168.0.1 275 | name: dev-asia-1 276 | - cluster: 277 | server: https://10.1.1.0 278 | name: dev-eu-1 279 | contexts: 280 | - context: 281 | namespace: kube-system 282 | cluster: dev-asia-1 283 | user: dev-asia 284 | name: dev-asia 285 | - context: 286 | namespace: kube-public 287 | cluster: dev-eu-1 288 | user: dev-eu 289 | name: dev-eu 290 | current-context: dev-eu 291 | kind: Config 292 | preferences: {} 293 | users: 294 | - name: dev-asia 295 | user: {} 296 | - name: dev-eu 297 | user: {} 298 | ` 299 | 300 | var singleClusterMultiContext = ` 301 | apiVersion: v1 302 | clusters: 303 | - cluster: 304 | server: https://10.1.1.0 305 | name: dev-eu-1 306 | contexts: 307 | - context: 308 | namespace: kube-system 309 | cluster: dev-asia-1 310 | user: dev-asia 311 | name: dev-asia 312 | - context: 313 | namespace: kube-public 314 | cluster: dev-eu-1 315 | user: dev-eu 316 | name: dev-eu 317 | current-context: dev-eu 318 | kind: Config 319 | preferences: {} 320 | users: 321 | - name: dev-asia 322 | user: {} 323 | - name: dev-eu 324 | user: {} 325 | ` 326 | 327 | var multiClusterSingleContext = ` 328 | apiVersion: v1 329 | clusters: 330 | - cluster: 331 | server: https://192.168.0.1 332 | name: dev-asia-1 333 | - cluster: 334 | server: https://10.1.1.0 335 | name: dev-eu-1 336 | contexts: 337 | - context: 338 | namespace: kube-system 339 | cluster: dev-asia-1 340 | user: dev-asia 341 | name: dev-asia 342 | # Purposefully kept this wrong 343 | current-context: dev-eu 344 | kind: Config 345 | preferences: {} 346 | users: 347 | - name: dev-asia 348 | user: {} 349 | ` 350 | 351 | // NamespaceFromName creates a simple namespace object for a name 352 | func NamespaceFromName(name string) *v1.Namespace { 353 | return &v1.Namespace{ 354 | ObjectMeta: metav1.ObjectMeta{ 355 | Name: name, 356 | }, 357 | } 358 | } 359 | -------------------------------------------------------------------------------- /utils/dir.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "io/fs" 5 | 6 | "github.com/simontheleg/konf-go/config" 7 | "github.com/spf13/afero" 8 | ) 9 | 10 | // KonfPerm describes the file-permissions for konf files 11 | const KonfPerm fs.FileMode = 0600 // based on the standard file-permissions for .kube/config 12 | 13 | // KonfDirPerm describes the file-permissions for konf directories 14 | const KonfDirPerm fs.FileMode = 0700 // needed so we can create folders inside 15 | 16 | // EnsureDir makes sure that konf store and active dirs exist 17 | func EnsureDir(f afero.Fs) error { 18 | 19 | err := f.MkdirAll(config.StoreDir()+"/", KonfDirPerm) 20 | if err != nil { 21 | return err 22 | } 23 | 24 | err = f.MkdirAll(config.ActiveDir()+"/", KonfDirPerm) 25 | if err != nil { 26 | return err 27 | } 28 | 29 | return nil 30 | } 31 | -------------------------------------------------------------------------------- /utils/dir_test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/simontheleg/konf-go/config" 7 | "github.com/spf13/afero" 8 | ) 9 | 10 | func TestEnsureDir(t *testing.T) { 11 | // since ensureDir is being run long before any storemanager is created, 12 | // we need to manually set a config here 13 | c := &config.Config{ 14 | KonfDir: "./konf", 15 | } 16 | config.SetGlobalConfig(c) 17 | f := afero.NewMemMapFs() 18 | err := EnsureDir(f) 19 | if err != nil { 20 | t.Errorf("Unexpected error while running EnsureDir: %q", err) 21 | } 22 | 23 | r, err := f.Stat("./konf/active") 24 | if err != nil { 25 | t.Errorf("Could not run stat, please check tests: %v", err) 26 | } 27 | if r.IsDir() != true { 28 | t.Errorf("Expected %s to be a dir, but it is not %q", r.Name(), r) 29 | } 30 | 31 | r, err = f.Stat("./konf/store") 32 | if err != nil { 33 | t.Errorf("Could not run stat, please check tests: %v", err) 34 | } 35 | if r.IsDir() != true { 36 | t.Errorf("Expected %s to be a dir, but it is not %q", r.Name(), r) 37 | } 38 | } 39 | --------------------------------------------------------------------------------