diff --git a/.gitignore b/.gitignore index 5ef8ec7..08e1034 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,12 @@ # Vim *.swp +# IntelliJ +.idea/ + #Output out/ +dist/ # user configs config.yaml diff --git a/.goreleaser.yml b/.goreleaser.yml new file mode 100644 index 0000000..388f39a --- /dev/null +++ b/.goreleaser.yml @@ -0,0 +1,21 @@ +builds: + - goos: + - darwin + - linux + - windows + goarch: + - amd64 +archive: + replacements: + darwin: Darwin + linux: Linux + windows: Windows + amd64: x86_64 +checksum: + name_template: "checksums.txt" +snapshot: + name_template: "{{ .Tag }}-next" +changelog: + sort: asc +release: + draft: true diff --git a/.talismanrc b/.talismanrc new file mode 100644 index 0000000..86f190d --- /dev/null +++ b/.talismanrc @@ -0,0 +1,4 @@ +fileignoreconfig: +- filename: go.sum + checksum: 228e96793e8630f807ad3ebd8f814802aa89d59121ce1c08907d9beff36efcad + ignore_detectors: [] diff --git a/.travis.yml b/.travis.yml index 0b8f4b3..d93259d 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,11 +1,16 @@ language: go go: - - "1.11.x" - - master - + - 1.13.x env: - - GO111MODULE=on - + global: + - GO111MODULE=on + - secure: azgsx/4nSKps0b1cPxNt1p5eke8m84U+gpUD0r+bjwj+7anhDkaTMzj4sBwPNf56h1+AGmKVBBflcjuJg34ONLLOZhNQ1QwM8nypuX0pn4Y42JkTnAFVSGA7Scw9F3/gdBU1aqnqj4Hb3q72RzVyY+LI6SZpXebpv5PiTdOv0uffrYmxtmZ/9HRFlBRTGEUws+oOXoNr5fK1bcvpDBHbdUAsEpj/CJocpJ5U85XU51E38InEmx1fiFxcIuddC6IkuWRL9rTLw4xMNVz8QUcEX7yLAkbMi8UMB9BYB91qh8NMcd6sKsrCy4a8ue+H3VWpVc634/fAkfp9N3YFmQBlDUQ0tt41UhXPoTqABrjv+TLt42ydUItqdokSVwvMPc5ucQ3hwpqt2JMKE5/Pj36PwFqUE4mC7woAWqO4YnEf5jC0Rek2JMxqbljVlfANH9RI3ormEWmdtO6/VKjuauCCq3BIUogygCoZ1iJQgKVzhjc8V9o1cSyW/PxfL/uBUQr32toYd4tGGHqiZeHUCwALY1khNPN+8miWlXExdjkjPjWdyoYXDiO3S4JNY0Q7cRQZs9nwI4+FCsNR9/Borq/l1/LlFbD56vyhywLBpRkODAsX/+zRPlgZOOcPtTgzMN+OGeC1WNCrr8A5YJ/w5avM91Z0M00w1a45u/KkX437fbI= install: make setup modules - script: make tests tests-cover-html build +deploy: + - provider: script + skip_cleanup: false + script: curl -sL https://git.io/goreleaser | bash + on: + tags: true + condition: "$TRAVIS_OS_NAME = linux" diff --git a/Makefile b/Makefile index f5a8c19..398ec0e 100644 --- a/Makefile +++ b/Makefile @@ -5,6 +5,7 @@ help: ## prints help (only for tasks with comment) APP=kube-tmuxp SRC_PACKAGES=$(shell go list ./...) APP_EXECUTABLE="./out/$(APP)" +BUILD?=$(shell git describe --tags --always --dirty) RICHGO=$(shell command -v richgo 2> /dev/null) ifeq ($(RICHGO),) @@ -22,10 +23,10 @@ modules: ## add missing and remove unused modules go mod tidy compile: ensure-out-dir ## compiles kube-tmuxp for this platform - $(GOBIN) build -o $(APP_EXECUTABLE) ./main.go + $(GOBIN) build -ldflags "-X main.version=${BUILD}" -o $(APP_EXECUTABLE) ./main.go compile-linux: ensure-out-dir ## compiles kube-tmuxp for linux - GOOS=linux GOARCH=amd64 $(GOBIN) build -o $(APP_EXECUTABLE) ./main.go + GOOS=linux GOARCH=amd64 $(GOBIN) build -ldflags "-X main.version=${BUILD}" -o $(APP_EXECUTABLE) ./main.go fmt: ## format go code $(GOBIN) fmt $(SRC_PACKAGES) diff --git a/README.md b/README.md index 19a1741..0c0268b 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,19 @@ # kube-tmuxp -Easier way to work with multiple [Kubernetes](https://kubernetes.io/) clusters. +[![Build Status](https://travis-ci.org/thecasualcoder/kube-tmuxp.svg?branch=master)](https://travis-ci.org/thecasualcoder/kube-tmuxp) + +Tool to generate tmuxp configs that help to switch between multiple [Kubernetes](https://kubernetes.io/) contexts +safely. ## Introduction -When working with multiple Kubernetes clusters its painful to switch context using [`kubectl`](https://github.com/kubernetes/kubernetes/tree/master/cmd/kubectl) or [`kubectx`](https://github.com/ahmetb/kubectx). There are also possibilities of making unintentional changes. +When working with multiple Kubernetes clusters its painful to switch context +using [`kubectl`](https://github.com/kubernetes/kubernetes/tree/master/cmd/kubectl) +or [`kubectx`](https://github.com/ahmetb/kubectx). There are also possibilities of making unintentional changes. -`kube-tmuxp` solves this by using one preconfigured `tmux` session per Kubernetes cluster. Each `tmux` session contains only one Kubernetes context thus preventing accidental context switching inside a session. Contexts can be switched by switching `tmux` sessions. For example: `[tmux prefix] + S`. +`kube-tmuxp` solves this by using one preconfigured `tmux` session per Kubernetes cluster. Each `tmux` session contains +only one Kubernetes context thus preventing accidental context switching inside a session. Contexts can be switched by +switching `tmux` sessions. For example: `[tmux prefix] + S`. Given a config similar to [config.sample.yaml](./config.sample.yaml), `kube-tmuxp` generates: @@ -26,12 +33,15 @@ The generated `tmuxp` configs can be used to start preconfigured `tmux` sessions ### Homebrew -To be updated +``` +brew tap thecasualcoder/stable +brew install kube-tmuxp +``` ### Manual ``` -git clone https://github.com/arunvelsriram/kube-tmuxp.git +git clone https://github.com/thecasualcoder/kube-tmuxp.git cd kube-tmuxp make build cp ./out/kube-tmuxp /usr/local/bin/kube-tmuxp @@ -52,7 +62,63 @@ cp ./out/kube-tmuxp /usr/local/bin/kube-tmuxp kube-tmuxp gen ``` -Default config path is `$HOME/.kube-tmuxp.yaml`. If you are using a different path, then use the `--config` flag to specify that path. Refer `kube-tmuxp --help` for more details. +Default config path is `$HOME/.kube-tmuxp.yaml`. If you are using a different path, then use the `--config` flag to +specify that path. Refer `kube-tmuxp --help` for more details. + +## Generate kube-tmuxp config file for gcloud + +```bash +$ kube-tmuxp gcloud-generate --help +Generates configs for kube-tmuxp based on gcloud account + +Usage: + kube-tmuxp gcloud-generate [flags] + +Flags: + --additional-envs strings Additional envs to be populated + --all-projects Skip confirmation for projects + --apply Directly create the tmuxp configs for selected projects + -h, --help help for gcloud-generate + --project-ids strings Comma separated Project IDs to which the configurations need to be fetched + +``` + +#### Examples: + +1) Interactively select projects: + +```bash +$ kube-tmuxp gcloud-generate +# this will prompt for the projectIDs selection. Type to filter and select using space +# fuzzy search will work +``` + +2) Specify projectIDs: + +```bash +$ kube-tmuxp gcloud-generate --projectIDs project1,project2 +``` + +3) For all projects: + +```bash +$ kube-tmuxp gcloud-generate --allProjects +``` + +4) Use env variables in kube-tmuxp: + +> kube-tmuxp provides four envs: `KUBETMUXP_CLUSTER_NAME`, `KUBETMUXP_CLUSTER_LOCATION`, `KUBETMUXP_CLUSTER_IS_REGIONAL`, `GCP_PROJECT_ID`. We can pass additional envs also. + +```bash +$ kube-tmuxp gcloud-generate --additionalEnvs 'NEW_KEY=new_value,NEW_ENV=$HOME,KUBE_CONFIG=$HOME/.kube/configs/$KUBETMUXP_CLUSTER_NAME' +# each tmux session will have 7 envs (4 predefined, 3 additionalEnvs passed as argument) +``` + +5) Directly create the kubeconfigs and tmuxp files (instead of kube-tmuxp config files): + +```bash +$ kube-tmuxp gcloud-generate --apply +``` ## Start a session @@ -62,6 +128,14 @@ tmuxp load my-context-name Now you will be inside a `tmux` session preconfigured with Kubernetes context `my-context-name`. +## Handy bash functions + +Use the `bash` functions +available [here](https://github.com/arunvelsriram/dotfiles/blob/master/bash_it_custom/plugins/kube-tmuxp.plugin.bash) to +switch, kill sessions easily. Special thanks to [@jskswamy](https://github.com/jskswamy) for writing these awesome +functions. + ## Limitations -* Currently works for Google Kubernetes Engine (GKE) only. However, it can be extended to work with any Kubernetes clusters. Feel free to submit a PR for this. +* Currently works for Google Kubernetes Engine (GKE) only. However, it can be extended to work with any Kubernetes + clusters. Feel free to submit a PR for this. diff --git a/cmd/generate.go b/cmd/generate.go index eaa6f2d..6a60b81 100644 --- a/cmd/generate.go +++ b/cmd/generate.go @@ -3,12 +3,14 @@ package cmd import ( "fmt" "os" + "path" + + "github.com/mitchellh/go-homedir" + "github.com/thecasualcoder/kube-tmuxp/pkg/generator" - "github.com/arunvelsriram/kube-tmuxp/pkg/commander" - "github.com/arunvelsriram/kube-tmuxp/pkg/filesystem" - "github.com/arunvelsriram/kube-tmuxp/pkg/kubeconfig" - "github.com/arunvelsriram/kube-tmuxp/pkg/kubetmuxp" "github.com/spf13/cobra" + "github.com/thecasualcoder/kube-tmuxp/pkg/commander" + "github.com/thecasualcoder/kube-tmuxp/pkg/filesystem" ) var generateCmd = &cobra.Command{ @@ -16,27 +18,47 @@ var generateCmd = &cobra.Command{ Aliases: []string{"gen"}, Short: "Generates tmuxp configs for various Kubernetes contexts", Run: func(cmd *cobra.Command, args []string) { + options := generator.Options{ + From: from, + AllProjects: allProjects, + ProjectIDs: projectIDs, + AdditionalEnvs: additionalEnvs, + Apply: apply, + CfgFile: cfgFile, + } fs := &filesystem.Default{} cmdr := &commander.Default{} - kubeCfg, err := kubeconfig.New(fs, cmdr) - if err != nil { - fmt.Println(err) - os.Exit(1) - } - kubetmuxpCfg, err := kubetmuxp.NewConfig(cfgFile, fs, kubeCfg) + generator, err := generator.NewGenerator(options, fs, cmdr) if err != nil { - fmt.Println(err) - os.Exit(1) - } - - if err = kubetmuxpCfg.Process(); err != nil { - fmt.Println(err) + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), err.Error()) os.Exit(1) } + generator.Generate(cmd.OutOrStderr(), cmd.ErrOrStderr()) }, } +var cfgFile string +var from string +var allProjects, apply bool +var additionalEnvs, projectIDs []string + func init() { + generateCmd.Flags().StringVar(&cfgFile, "config", getDefaultConfigPath(), "config file") + generateCmd.Flags().StringVar(&from, "from", "file", "source from which the tmuxp config files are generated") + generateCmd.Flags().BoolVar(&allProjects, "all-projects", false, "Skip confirmation for projects") + generateCmd.Flags().StringSliceVar(&projectIDs, "project-ids", nil, "Comma separated Project IDs to which the configurations need to be fetched") + generateCmd.Flags().BoolVar(&apply, "apply", false, "Directly create the tmuxp configs for selected projects") + generateCmd.Flags().StringSliceVar(&additionalEnvs, "additional-envs", nil, "Additional envs to be populated") rootCmd.AddCommand(generateCmd) } + +func getDefaultConfigPath() string { + home, err := homedir.Dir() + if err != nil { + fmt.Println(err) + os.Exit(1) + } + configFileName := ".kube-tmuxp.yaml" + return path.Join(home, configFileName) +} diff --git a/cmd/root.go b/cmd/root.go index 87d06a2..9df5879 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -4,13 +4,9 @@ import ( "fmt" "os" - homedir "github.com/mitchellh/go-homedir" "github.com/spf13/cobra" - "github.com/spf13/viper" ) -var cfgFile string - var rootCmd = &cobra.Command{ Use: "kube-tmuxp", Short: `Tool to generate tmuxp configs that help to switch between multiple Kubernetes contexts safely`, @@ -24,29 +20,3 @@ func Execute() { os.Exit(1) } } - -func init() { - cobra.OnInitialize(initConfig) - rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.kube-tmuxp.yaml)") -} - -func initConfig() { - if cfgFile != "" { - viper.SetConfigFile(cfgFile) - } else { - home, err := homedir.Dir() - if err != nil { - fmt.Println(err) - os.Exit(1) - } - - viper.AddConfigPath(home) - viper.SetConfigName(".kube-tmuxp") - } - - viper.AutomaticEnv() - - if err := viper.ReadInConfig(); err == nil { - fmt.Println("Using config file:", viper.ConfigFileUsed()) - } -} diff --git a/cmd/version.go b/cmd/version.go new file mode 100644 index 0000000..6933f24 --- /dev/null +++ b/cmd/version.go @@ -0,0 +1,26 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +var buildVersion string + +// SetVersion set the major and minor version +func SetVersion(version string) { + buildVersion = version +} + +var versionCmd = &cobra.Command{ + Use: "version", + Short: "Print the current version", + Run: func(cmd *cobra.Command, args []string) { + fmt.Println(buildVersion) + }, +} + +func init() { + rootCmd.AddCommand(versionCmd) +} diff --git a/go.mod b/go.mod index e380e25..121d18d 100644 --- a/go.mod +++ b/go.mod @@ -1,14 +1,17 @@ -module github.com/arunvelsriram/kube-tmuxp +module github.com/thecasualcoder/kube-tmuxp + +go 1.13 require ( - github.com/BurntSushi/toml v0.3.1 // indirect - github.com/golang/mock v1.1.1 - github.com/inconshreveable/mousetrap v1.0.0 // indirect - github.com/mitchellh/go-homedir v1.0.0 - github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/spf13/cobra v0.0.3 - github.com/spf13/viper v1.2.1 - github.com/stretchr/testify v1.2.2 - golang.org/x/net v0.0.0-20181108082009-03003ca0c849 // indirect - gopkg.in/yaml.v2 v2.2.1 + github.com/golang/mock v1.3.1 + github.com/kr/pretty v0.1.0 // indirect + github.com/lithammer/fuzzysearch v1.1.0 + github.com/mitchellh/go-homedir v1.1.0 + github.com/spf13/cobra v0.0.5 + github.com/stretchr/testify v1.4.0 + golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550 // indirect + golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8 // indirect + gopkg.in/AlecAivazis/survey.v1 v1.8.7 + gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect + gopkg.in/yaml.v2 v2.2.7 ) diff --git a/go.sum b/go.sum index a850dcf..67c25e1 100644 --- a/go.sum +++ b/go.sum @@ -1,46 +1,96 @@ github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/Netflix/go-expect v0.0.0-20180615182759-c93bf25de8e8 h1:xzYJEypr/85nBpB11F9br+3HUrpgb+fcm5iADzXXYEw= +github.com/Netflix/go-expect v0.0.0-20180615182759-c93bf25de8e8/go.mod h1:oX5x61PbNXchhh0oikYAH+4Pcfw5LKv21+Jnpr6r6Pc= +github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= +github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= +github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk= +github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= +github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= -github.com/golang/mock v1.1.1 h1:G5FRp8JnTd7RQH5kemVNlMeyXQAztQ3mOWV95KxsXH8= -github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.3.1 h1:qGJ6qTW+x6xX/my+8YUVl4WNpX9B7+/l2tRsHGZ7f2s= +github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/hinshun/vt10x v0.0.0-20180616224451-1954e6464174 h1:WlZsjVhE8Af9IcZDGgJGQpNflI3+MJSBhsgT5PCtzBQ= +github.com/hinshun/vt10x v0.0.0-20180616224451-1954e6464174/go.mod h1:DqJ97dSdRW1W22yXSB90986pcOyQ7r45iio1KN2ez1A= github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= -github.com/magiconair/properties v1.8.0 h1:LLgXmsheXeRoUOBOjtwPQCWIYqM/LU1ayDtDePerRcY= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= +github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1 h1:VkoXIwSboBpnk99O/KFauAEILuNHv5DVFKZMBN/gUgw= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/lithammer/fuzzysearch v1.1.0 h1:go9v8tLCrNTTlH42OAaq4eHFe81TDHEnlrMEb6R4f+A= +github.com/lithammer/fuzzysearch v1.1.0/go.mod h1:Bqx4wo8lTOFcJr3ckpY6HA9lEIOO0H5HrkJ5CsN56HQ= github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= -github.com/mitchellh/go-homedir v1.0.0 h1:vKb8ShqSby24Yrqr/yDYkuFz8d0WUjys40rvnGC8aR0= -github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= -github.com/mitchellh/mapstructure v1.0.0 h1:vVpGvMXJPqSDh2VYHF7gsfQj8Ncx+Xw5Y1KHeTRY+7I= -github.com/mitchellh/mapstructure v1.0.0/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/mattn/go-colorable v0.0.9 h1:UVL0vNpWh04HeJXV0KLcaT7r06gOH2l4OW6ddYRUIY4= +github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= +github.com/mattn/go-isatty v0.0.3 h1:ns/ykhmWi7G9O+8a448SecJU3nSMBXJfqQkl0upE1jI= +github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= +github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4= +github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE= +github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/pelletier/go-toml v1.2.0 h1:T5zMGML61Wp+FlcbWjRDT7yAxhJNAiPPLOFECq181zc= github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= github.com/spf13/afero v1.1.2 h1:m8/z1t7/fwjysjQRYbP0RD+bUIF/8tJwPdEZsI83ACI= github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= -github.com/spf13/cast v1.2.0 h1:HHl1DSRbEQN2i8tJmtS6ViPyHx35+p51amrdsiTCrkg= -github.com/spf13/cast v1.2.0/go.mod h1:r2rcYCSwa1IExKTDiTfzaxqT2FNHs8hODu4LnUfgKEg= -github.com/spf13/cobra v0.0.3 h1:ZlrZ4XsMRm04Fr5pSFxBgfND2EBVa1nLpiy1stUsX/8= -github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ= +github.com/spf13/cast v1.3.0 h1:oget//CVOEoFewqQxwr0Ej5yjygnqGkvggSE/gB35Q8= +github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= +github.com/spf13/cobra v0.0.5 h1:f0B+LkLX6DtmRH1isoNA9VTtNUK9K8xYd28JNNfOv/s= +github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU= github.com/spf13/jwalterweatherman v1.0.0 h1:XHEdyB+EcvlqZamSM4ZOMGlc93t6AcsBEu9Gc1vn7yk= github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= -github.com/spf13/pflag v1.0.2 h1:Fy0orTDgHdbnzHcsOgfCN4LtHf0ec3wwtiwJqwvf3Gc= -github.com/spf13/pflag v1.0.2/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= -github.com/spf13/viper v1.2.1 h1:bIcUwXqLseLF3BDAZduuNfekWG87ibtFxi59Bq+oI9M= -github.com/spf13/viper v1.2.1/go.mod h1:P4AexN0a+C9tGAnUFNwDMYYZv3pjFuvmeiMyKRaNVlI= +github.com/spf13/pflag v1.0.3 h1:zPAT6CGy6wXeQ7NtTnaTerfKOsV6V6F8agHXFiazDkg= +github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.1/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= -golang.org/x/net v0.0.0-20181108082009-03003ca0c849 h1:FSqE2GGG7wzsYUsWiQ8MZrvEd1EOyU3NCF0AW3Wtltg= -golang.org/x/net v0.0.0-20181108082009-03003ca0c849/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/sys v0.0.0-20180906133057-8cf3aee42992 h1:BH3eQWeGbwRU2+wxxuuPOdFBmaiBH81O8BugSjHeTFg= -golang.org/x/sys v0.0.0-20180906133057-8cf3aee42992/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0= +github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= +golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190123085648-057139ce5d2b/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550 h1:ObdrDkeb4kJdCP557AjRjq69pTHfNouLtWZG7j9rPN8= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180606202747-9527bec2660b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a h1:1BGLXjeY4akVXGgbC9HugT3Jv3hCI0z56oJR5vAMgBU= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8 h1:JA8d3MPx/IToSyXZG/RhwYEtfrKO1Fxrqe8KrkiLXKM= +golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +gopkg.in/AlecAivazis/survey.v1 v1.8.7 h1:oBJqtgsyBLg9K5FK9twNUbcPnbCPoh+R9a+7nag3qJM= +gopkg.in/AlecAivazis/survey.v1 v1.8.7/go.mod h1:iBNOmqKz/NUbZx3bA+4hAGLRC7fSK7tgtVDT4tB22XA= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v2 v2.2.1 h1:mUhvW9EsL+naU5Q3cakzfE91YhliOondGd6ZrsDBHQE= -gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.7 h1:VUgggvou5XRW9mHwD/yXxIYSMtY0zoKQf/v226p2nyo= +gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/main.go b/main.go index edfb318..c6c67c8 100644 --- a/main.go +++ b/main.go @@ -1,7 +1,10 @@ package main -import "github.com/arunvelsriram/kube-tmuxp/cmd" +import "github.com/thecasualcoder/kube-tmuxp/cmd" + +var version string func main() { + cmd.SetVersion(version) cmd.Execute() } diff --git a/pkg/commander/commander.go b/pkg/commander/commander.go index a8bbca8..bbe46ff 100644 --- a/pkg/commander/commander.go +++ b/pkg/commander/commander.go @@ -17,6 +17,7 @@ type Default struct{} // Execute executes a command on the actual machine func (d *Default) Execute(cmdStr string, args []string, envs []string) (string, error) { cmd := exec.Command(cmdStr, args...) + cmd.Stderr = os.Stderr cmd.Env = os.Environ() cmd.Env = append(cmd.Env, envs...) out, err := cmd.Output() diff --git a/pkg/commander/commander_test.go b/pkg/commander/commander_test.go index 2a3a076..2445de3 100644 --- a/pkg/commander/commander_test.go +++ b/pkg/commander/commander_test.go @@ -3,8 +3,8 @@ package commander_test import ( "testing" - "github.com/arunvelsriram/kube-tmuxp/pkg/commander" "github.com/stretchr/testify/assert" + "github.com/thecasualcoder/kube-tmuxp/pkg/commander" ) func TestExecute(t *testing.T) { diff --git a/pkg/file/generator.go b/pkg/file/generator.go new file mode 100644 index 0000000..c256e2a --- /dev/null +++ b/pkg/file/generator.go @@ -0,0 +1,42 @@ +package file + +import ( + "fmt" + "io" + "os" + + "github.com/thecasualcoder/kube-tmuxp/pkg/commander" + "github.com/thecasualcoder/kube-tmuxp/pkg/filesystem" + "github.com/thecasualcoder/kube-tmuxp/pkg/kubeconfig" + "github.com/thecasualcoder/kube-tmuxp/pkg/kubetmuxp" +) + +type Generator struct { + fs filesystem.FileSystem + cmdr commander.Commander + cfgFile string +} + +func NewGenerator(fs filesystem.FileSystem, cmdr commander.Commander, cfgFile string) Generator { + return Generator{fs: fs, cmdr: cmdr, cfgFile: cfgFile} +} + +func (g Generator) Generate(_, errStream io.Writer) { + kubeCfg, err := kubeconfig.New(g.fs, g.cmdr) + if err != nil { + _, _ = fmt.Fprintf(errStream, err.Error()) + os.Exit(1) + } + + fmt.Println("Using config file:", g.cfgFile) + kubetmuxpCfg, err := kubetmuxp.NewConfig(g.cfgFile, g.fs, kubeCfg) + if err != nil { + _, _ = fmt.Fprintf(errStream, err.Error()) + os.Exit(1) + } + + if err = kubetmuxpCfg.Process(); err != nil { + _, _ = fmt.Fprintf(errStream, err.Error()) + os.Exit(1) + } +} diff --git a/pkg/filesystem/filesystem.go b/pkg/filesystem/filesystem.go index 9361d0c..e639f43 100644 --- a/pkg/filesystem/filesystem.go +++ b/pkg/filesystem/filesystem.go @@ -13,6 +13,7 @@ type FileSystem interface { HomeDir() (string, error) Open(file string) (io.Reader, error) Create(file string) (io.Writer, error) + CreateDirIfNotExist(dir string) error } // Default represents the Operating System's filesystem @@ -55,3 +56,11 @@ func (d *Default) Create(file string) (io.Writer, error) { return writer, nil } + +// CreateDirIfNotExist creates a new directory if it already exists +func (d *Default) CreateDirIfNotExist(dir string) error { + if _, err := os.Stat(dir); os.IsNotExist(err) { + return os.Mkdir(dir, 0755) + } + return nil +} diff --git a/pkg/gcloud/client.go b/pkg/gcloud/client.go new file mode 100644 index 0000000..357849f --- /dev/null +++ b/pkg/gcloud/client.go @@ -0,0 +1,108 @@ +package gcloud + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/thecasualcoder/kube-tmuxp/pkg/commander" +) + +// ListProjects lists the projects for logged-in user +func ListProjects(commander commander.Commander) (Projects, error) { + args := []string{ + "projects", + "list", + "--format=json", + } + response, err := commander.Execute("gcloud", args, nil) + fullCommand := strings.Join(append([]string{"gcloud"}, args...), " ") + if err != nil { + return nil, fmt.Errorf("error executing %s: %v", fullCommand, err) + } + var projects []Project + err = json.Unmarshal([]byte(response), &projects) + if err != nil { + return nil, fmt.Errorf("error unmarshaling the response from command %s: %v", fullCommand, err) + } + + return projects, nil +} + +// Project represent the GCP project +type Project struct { + ProjectId string `json:"projectId"` +} + +// Projects represent the list of GCP projects +type Projects []Project + +func (p Projects) IDs() []string { + acc := make([]string, 0, len(p)) + for _, project := range p { + acc = append(acc, project.ProjectId) + } + return acc +} + +func (p Projects) Filter(projectIDs []string) Projects { + projectMap := map[string]Project{} + for _, project := range p { + projectMap[project.ProjectId] = project + } + result := make(Projects, 0, len(p)) + for _, id := range projectIDs { + if project, ok := projectMap[id]; ok { + result = append(result, project) + } + } + return result +} + +// Cluster represent the GKE Cluster +type Cluster struct { + Name string + Location string + Locations []string +} + +func (cluster Cluster) IsRegional() bool { + return !Contains(cluster.Locations, cluster.Location) +} + +// Clusters represents the list of Cluster +type Clusters []Cluster + +// ListClusters for the given projectId +func ListClusters(cmdr commander.Commander, projectId string) (Clusters, error) { + args := []string{ + "container", + "clusters", + "list", + "--project", + projectId, + "--format=json", + } + response, err := cmdr.Execute("gcloud", args, nil) + fullCommand := strings.Join(append([]string{"gcloud"}, args...), " ") + if err != nil { + return nil, fmt.Errorf("error executing %s: %v", fullCommand, err) + } + var clusters []Cluster + err = json.Unmarshal([]byte(response), &clusters) + if err != nil { + return nil, fmt.Errorf("error unmarshaling the response from command %s: %v", fullCommand, err) + } + + return clusters, nil +} + +// Contains checks if the given string array contains given string +func Contains(items []string, input string) bool { + for _, item := range items { + if item == input { + return true + } + } + return false +} diff --git a/pkg/gcloud/client_test.go b/pkg/gcloud/client_test.go new file mode 100644 index 0000000..ea2d205 --- /dev/null +++ b/pkg/gcloud/client_test.go @@ -0,0 +1,190 @@ +package gcloud + +import ( + "fmt" + "testing" + + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/assert" + "github.com/thecasualcoder/kube-tmuxp/pkg/internal/mock" +) + +func TestListProjects(t *testing.T) { + t.Run("should return error if there is an error executing gcloud command", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + commander := mock.NewCommander(ctrl) + commander.EXPECT().Execute("gcloud", []string{"projects", "list", "--format=json"}, nil).Return("", fmt.Errorf("please login")) + + projects, err := ListProjects(commander) + + assert.EqualError(t, err, "error executing gcloud projects list --format=json: please login") + assert.Empty(t, projects) + }) + + t.Run("should return error if there is an unmarshal from gcloud command", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + commander := mock.NewCommander(ctrl) + commander.EXPECT().Execute("gcloud", []string{"projects", "list", "--format=json"}, nil).Return("invalid json response", nil) + + projects, err := ListProjects(commander) + + assert.EqualError(t, err, "error unmarshaling the response from command gcloud projects list --format=json: invalid character 'i' looking for beginning of value") + assert.Empty(t, projects) + }) + + t.Run("should return projects from gcloud command", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + commander := mock.NewCommander(ctrl) + commander.EXPECT().Execute("gcloud", []string{"projects", "list", "--format=json"}, nil).Return(`[ + { + "createTime": "2016-08-20T04:30:54.605Z", + "lifecycleState": "ACTIVE", + "name": "My Project", + "projectId": "clean-pottery", + "projectNumber": "10", + "users": [ + "someuser" + ] + } +]`, nil) + + projects, err := ListProjects(commander) + + assert.NoError(t, err) + assert.Equal(t, Projects{Project{ProjectId: "clean-pottery"}}, projects) + }) +} + +func TestListClusters(t *testing.T) { + projectId := "projectId" + + t.Run("should return error if there is an error executing gcloud command", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + commander := mock.NewCommander(ctrl) + commander.EXPECT().Execute("gcloud", []string{"container", + "clusters", + "list", + "--project", + projectId, + "--format=json", + }, nil).Return("", fmt.Errorf("please login")) + + projects, err := ListClusters(commander, projectId) + + assert.EqualError(t, err, "error executing gcloud container clusters list --project projectId --format=json: please login") + assert.Empty(t, projects) + }) + + t.Run("should return error if there is an unmarshal from gcloud command", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + commander := mock.NewCommander(ctrl) + commander.EXPECT().Execute("gcloud", []string{"container", + "clusters", + "list", + "--project", + projectId, + "--format=json", + }, nil).Return("invalid json response", nil) + + projects, err := ListClusters(commander, projectId) + + assert.EqualError(t, err, "error unmarshaling the response from command gcloud container clusters list --project projectId --format=json: invalid character 'i' looking for beginning of value") + assert.Empty(t, projects) + }) + + t.Run("should return clusters from gcloud command", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + commander := mock.NewCommander(ctrl) + commander.EXPECT().Execute("gcloud", []string{"container", + "clusters", + "list", + "--project", + projectId, + "--format=json", + }, nil).Return(`[ + { + "currentNodeCount": 8, + "databaseEncryption": { + "state": "DECRYPTED" + }, + "defaultMaxPodsConstraint": { + "maxPodsPerNode": "110" + }, + "location": "asia-southeast1", + "locations": [ + "asia-southeast1-a", + "asia-southeast1-c", + "asia-southeast1-b" + ], + "name": "cluster-one", + "network": "default", + "nodeIpv4CidrSize": 24, + "status": "RUNNING", + "subnetwork": "default-subnet", + "zone": "asia-southeast1" + }, + { + "currentNodeCount": 8, + "databaseEncryption": { + "state": "DECRYPTED" + }, + "defaultMaxPodsConstraint": { + "maxPodsPerNode": "110" + }, + "location": "asia-southeast1", + "locations": [ + "asia-southeast1-a", + "asia-southeast1-c", + "asia-southeast1-b" + ], + "name": "cluster-two", + "network": "default", + "nodeIpv4CidrSize": 24, + "status": "RUNNING", + "subnetwork": "default-subnet", + "zone": "asia-southeast1" + } +]`, nil) + + expectedClusters := Clusters{ + Cluster{Name: "cluster-one", Location: "asia-southeast1", Locations: []string{"asia-southeast1-a", "asia-southeast1-c", "asia-southeast1-b"}}, + Cluster{Name: "cluster-two", Location: "asia-southeast1", Locations: []string{"asia-southeast1-a", "asia-southeast1-c", "asia-southeast1-b"}}} + + projects, err := ListClusters(commander, projectId) + + assert.NoError(t, err) + assert.Equal(t, expectedClusters, projects) + }) +} + +func TestProjects_IDs(t *testing.T) { + projects := Projects{Project{ProjectId: "project_one"}, Project{ProjectId: "project_two"}} + + assert.Equal(t, []string{"project_one", "project_two"}, projects.IDs()) +} + +func TestProjects_Filter(t *testing.T) { + projects := Projects{Project{ProjectId: "project_one"}, Project{ProjectId: "project_two"}} + + assert.Equal(t, Projects{Project{ProjectId: "project_one"}}, projects.Filter([]string{"project_one", "invalid_project"})) +} + +func TestCluster_IsRegional(t *testing.T) { + t.Run("should return true for regional clusters", func(t *testing.T) { + cluster := Cluster{Name: "cluster-one", Location: "asia-southeast1", Locations: []string{"asia-southeast1-a", "asia-southeast1-c", "asia-southeast1-b"}} + + assert.True(t, cluster.IsRegional()) + }) + + t.Run("should return false for zonal clusters", func(t *testing.T) { + cluster := Cluster{Name: "cluster-one", Location: "asia-southeast1-a", Locations: []string{"asia-southeast1-a", "asia-southeast1-c", "asia-southeast1-b"}} + + assert.False(t, cluster.IsRegional()) + }) +} diff --git a/pkg/gcloud/generator.go b/pkg/gcloud/generator.go new file mode 100644 index 0000000..efab23b --- /dev/null +++ b/pkg/gcloud/generator.go @@ -0,0 +1,189 @@ +package gcloud + +import ( + "fmt" + "io" + "os" + "strings" + + "github.com/lithammer/fuzzysearch/fuzzy" + "github.com/thecasualcoder/kube-tmuxp/pkg/commander" + "github.com/thecasualcoder/kube-tmuxp/pkg/filesystem" + "github.com/thecasualcoder/kube-tmuxp/pkg/kubeconfig" + "github.com/thecasualcoder/kube-tmuxp/pkg/kubetmuxp" + "gopkg.in/AlecAivazis/survey.v1" + "gopkg.in/yaml.v2" +) + +type Generator struct { + projectIDs []string + allProjects bool + additionalEnvs []string + apply bool +} + +func NewGenerator(projectIDs []string, allProjects bool, additionalEnvs []string, apply bool) Generator { + return Generator{ + projectIDs: projectIDs, + allProjects: allProjects, + additionalEnvs: additionalEnvs, + apply: apply, + } +} + +func (g Generator) Generate(outStream, errStream io.Writer) { + cmdr := &commander.Default{} + projects, err := g.getProjects(cmdr) + if err != nil { + _, _ = fmt.Fprintf(errStream, err.Error()) + os.Exit(1) + } + if !g.apply { + g.printConfigFiles(projects, outStream) + _, _ = fmt.Fprintf(errStream, "Run with --apply to directly generate tmuxp configs for various Kubernetes contexts\n") + return + } + err = g.generateKubeTmuxpFiles(cmdr, projects) + if err != nil { + _, _ = fmt.Fprintf(errStream, err.Error()) + os.Exit(1) + } +} + +func (g Generator) generateKubeTmuxpFiles(cmdr commander.Commander, projects kubetmuxp.Projects) error { + fs := &filesystem.Default{} + kubeCfg, err := kubeconfig.New(fs, cmdr) + + config, err := kubetmuxp.NewConfigWithProjects(projects, fs, kubeCfg) + if err != nil { + return err + } + return config.Process() +} + +func (g Generator) printConfigFiles(projects kubetmuxp.Projects, outStream io.Writer) { + bytes, err := yaml.Marshal(map[string]kubetmuxp.Projects{"projects": projects}) + if err != nil { + _, _ = fmt.Fprintln(outStream, err) + os.Exit(1) + } + fmt.Println(string(bytes)) +} + +func (g Generator) getProjects(cmdr commander.Commander) (kubetmuxp.Projects, error) { + gCloudProjects := Projects{} + if g.projectIDs != nil && len(g.projectIDs) > 0 { + for _, projectID := range g.projectIDs { + gCloudProjects = append(gCloudProjects, Project{ProjectId: projectID}) + } + } else { + gCloudProjects = getGCloudProjects(cmdr, g.allProjects) + } + additionalEnvsMap := map[string]string{} + for _, env := range g.additionalEnvs { + envKeyValue := strings.Split(env, "=") + if len(envKeyValue) != 2 { + return nil, fmt.Errorf("wrong env format: should be key=value") + } + additionalEnvsMap[envKeyValue[0]] = envKeyValue[1] + } + projects := make(kubetmuxp.Projects, 0, len(gCloudProjects)) + for _, gCloudProject := range gCloudProjects { + clusters, err := ListClusters(cmdr, gCloudProject.ProjectId) + if err != nil { + return nil, err + } + _, _ = fmt.Fprintf(os.Stderr, "Number of clusters for %s project: %d\n", gCloudProject, len(clusters)) + + kubetmuxpClusters := make(kubetmuxp.Clusters, 0, len(clusters)) + for _, cluster := range clusters { + zone := "" + region := "" + isRegional := cluster.IsRegional() + if isRegional { + region = cluster.Location + } else { + zone = cluster.Location + } + baseEnvs := map[string]string{ + "KUBETMUXP_CLUSTER_NAME": cluster.Name, + "KUBETMUXP_CLUSTER_LOCATION": cluster.Location, + "KUBETMUXP_CLUSTER_IS_REGIONAL": fmt.Sprintf("%v", isRegional), + "GCP_PROJECT_ID": gCloudProject.ProjectId, + } + kubetmuxpClusters = append(kubetmuxpClusters, kubetmuxp.Cluster{ + Name: cluster.Name, + Zone: zone, + Region: region, + Context: cluster.Name, + Envs: mergeEnvs(baseEnvs, additionalEnvsMap), + }) + } + projects = append(projects, kubetmuxp.Project{ + Name: gCloudProject.ProjectId, + Clusters: kubetmuxpClusters, + }) + } + return projects, nil +} + +func mergeEnvs(base, additionalEnvsMap map[string]string) map[string]string { + for k, v := range additionalEnvsMap { + expandedValue := os.Expand(v, func(s string) string { + value, ok := base[s] + if ok { + return value + } else { + return os.Getenv(s) + } + }) + base[k] = expandedValue + } + return base +} + +func getGCloudProjects(cmdr commander.Commander, allProjects bool) Projects { + projects, err := ListProjects(cmdr) + if err != nil { + _, _ = fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + _, _ = fmt.Fprintf(os.Stderr, "Number of gcloud projects: %d\n", len(projects)) + if allProjects { + return projects + } + selectedProjects, err := getSelectedProjects(projects) + if err != nil { + _, _ = fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + _, _ = fmt.Fprintf(os.Stderr, "Number of selected gcloud projects: %d\n", len(selectedProjects)) + return selectedProjects +} + +func getSelectedProjects(projects Projects) (Projects, error) { + var selectedProjectIDs []string + prompt := &survey.MultiSelect{ + Message: "Select gcloud projects that you want to configure:", + Options: projects.IDs(), + FilterFn: func(s string, options []string) []string { + var acc []string + for _, option := range options { + if fuzzy.Match(s, option) { + acc = append(acc, option) + } + } + return acc + }, + } + opt := func(options *survey.AskOptions) error { + options.Stdio.Out = os.Stderr + return nil + } + validator := func(ans interface{}) error { return nil } + err := survey.AskOne(prompt, &selectedProjectIDs, validator, opt) + if err != nil { + return nil, fmt.Errorf("error selecting project: %v", err) + } + return projects.Filter(selectedProjectIDs), nil +} diff --git a/pkg/gcloud/generator_test.go b/pkg/gcloud/generator_test.go new file mode 100644 index 0000000..d443c9d --- /dev/null +++ b/pkg/gcloud/generator_test.go @@ -0,0 +1,59 @@ +package gcloud + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_mergeEnvs(t *testing.T) { + t.Run("merge simple envs", func(t *testing.T) { + result := mergeEnvs(map[string]string{ + "key": "value", + "key1": "oldValue", + }, map[string]string{ + "key1": "newValue", + "key2": "value", + }) + + assert.Equal(t, map[string]string{ + "key": "value", + "key1": "newValue", + "key2": "value", + }, result) + }) + + t.Run("merge additional envs containing base envs", func(t *testing.T) { + result := mergeEnvs(map[string]string{ + "key": "someValue", + "key1": "oldValue", + }, map[string]string{ + "key1": "$key", + "key2": "$key", + }) + + assert.Equal(t, map[string]string{ + "key": "someValue", + "key1": "someValue", + "key2": "someValue", + }, result) + }) + + t.Run("merge additional envs containing process/os envs", func(t *testing.T) { + assert.NoError(t, os.Setenv("SOME_KEY", "someEnvValue")) + + result := mergeEnvs(map[string]string{ + "key": "someValue", + }, map[string]string{ + "key1": "$SOME_KEY$SOME_KEY", + "key2": "$key$SOME_KEY", + }) + + assert.Equal(t, map[string]string{ + "key": "someValue", + "key1": "someEnvValuesomeEnvValue", + "key2": "someValuesomeEnvValue", + }, result) + }) +} diff --git a/pkg/generator/generator.go b/pkg/generator/generator.go new file mode 100644 index 0000000..d21dc90 --- /dev/null +++ b/pkg/generator/generator.go @@ -0,0 +1,59 @@ +package generator + +import ( + "fmt" + "io" + + "github.com/thecasualcoder/kube-tmuxp/pkg/commander" + "github.com/thecasualcoder/kube-tmuxp/pkg/file" + "github.com/thecasualcoder/kube-tmuxp/pkg/filesystem" + "github.com/thecasualcoder/kube-tmuxp/pkg/gcloud" +) + +type Generator interface { + Generate(outStream, errStream io.Writer) +} + +type Options struct { + From string + AllProjects bool + ProjectIDs []string + AdditionalEnvs []string + Apply bool + CfgFile string +} + +func NewGenerator(options Options, fs filesystem.FileSystem, cmdr commander.Commander) (Generator, error) { + switch options.From { + case "file": + if err := areFlagsValidForSourceFile(options.AllProjects, options.ProjectIDs, options.AdditionalEnvs); err != nil { + return nil, fmt.Errorf("error in the flags for source type 'file': %s", err) + } + return file.NewGenerator(fs, cmdr, options.CfgFile), nil + case "gcloud": + return gcloud.NewGenerator(options.ProjectIDs, options.AllProjects, options.AdditionalEnvs, options.Apply), nil + default: + return nil, fmt.Errorf("invalid source provided: valid sources are file,gcloud") + } +} + +func areFlagsValidForSourceFile(allProjects bool, projectIDs, additionalEnvs []string) error { + err := "" + counter := 1 + if projectIDs != nil { + err += fmt.Sprintf("\n %d) %s", counter, "project-ids should be empty for source file") + counter++ + } + if allProjects { + err += fmt.Sprintf("\n %d) %s", counter, "all-projects should be false for source file") + counter++ + } + if additionalEnvs != nil { + err += fmt.Sprintf("\n %d) %s", counter, "additional-envs should be empty for source file") + } + + if err != "" { + return fmt.Errorf("%s\n", err) + } + return nil +} diff --git a/pkg/generator/generator_test.go b/pkg/generator/generator_test.go new file mode 100644 index 0000000..b5691f0 --- /dev/null +++ b/pkg/generator/generator_test.go @@ -0,0 +1,40 @@ +package generator + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/thecasualcoder/kube-tmuxp/pkg/file" + "github.com/thecasualcoder/kube-tmuxp/pkg/gcloud" +) + +func TestNewGenerator(t *testing.T) { + + t.Run("should fail if invalid from source is given", func(t *testing.T) { + generator, err := NewGenerator(Options{From: "invalid"}, nil, nil) + + assert.EqualError(t, err, "invalid source provided: valid sources are file,gcloud") + assert.Nil(t, generator) + }) + + t.Run("should create gcloud generator for gcloud option", func(t *testing.T) { + generator, err := NewGenerator(Options{From: "gcloud"}, nil, nil) + + assert.Nil(t, err) + assert.Equal(t, generator, gcloud.NewGenerator(nil, false, nil, false)) + }) + + t.Run("should create file generator if options are valid", func(t *testing.T) { + generator, err := NewGenerator(Options{From: "file"}, nil, nil) + + assert.Nil(t, err) + assert.Equal(t, generator, file.NewGenerator(nil, nil, "")) + }) + + t.Run("should fail if if options are invalid for file generator", func(t *testing.T) { + generator, err := NewGenerator(Options{From: "file", AllProjects: true, ProjectIDs: []string{"project1"}, AdditionalEnvs: []string{"a=1"}}, nil, nil) + + assert.Nil(t, generator) + assert.EqualError(t, err, "error in the flags for source type 'file': \n 1) project-ids should be empty for source file\n 2) all-projects should be false for source file\n 3) additional-envs should be empty for source file\n") + }) +} diff --git a/pkg/internal/mock/filesystem.go b/pkg/internal/mock/filesystem.go index 1f02941..7b178fc 100644 --- a/pkg/internal/mock/filesystem.go +++ b/pkg/internal/mock/filesystem.go @@ -83,3 +83,15 @@ func (m *FileSystem) Create(file string) (io.Writer, error) { func (mr *FileSystemMockRecorder) Create(file interface{}) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*FileSystem)(nil).Create), file) } + +// CreateDirIfNotExist mocks base method +func (m *FileSystem) CreateDirIfNotExist(dir string) error { + ret := m.ctrl.Call(m, "CreateDirIfNotExist", dir) + ret0, _ := ret[0].(error) + return ret0 +} + +// CreateDirIfNotExist indicates an expected call of CreateDirIfNotExist +func (mr *FileSystemMockRecorder) CreateDirIfNotExist(dir interface{}) *gomock.Call { + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateDirIfNotExist", reflect.TypeOf((*FileSystem)(nil).CreateDirIfNotExist), dir) +} diff --git a/pkg/kubeconfig/kubeconfig.go b/pkg/kubeconfig/kubeconfig.go index 4530ef0..89b00c9 100644 --- a/pkg/kubeconfig/kubeconfig.go +++ b/pkg/kubeconfig/kubeconfig.go @@ -5,8 +5,8 @@ import ( "os" "path" - "github.com/arunvelsriram/kube-tmuxp/pkg/commander" - "github.com/arunvelsriram/kube-tmuxp/pkg/filesystem" + "github.com/thecasualcoder/kube-tmuxp/pkg/commander" + "github.com/thecasualcoder/kube-tmuxp/pkg/filesystem" ) // KubeConfig exposes methods to perform actions on kubeconfig @@ -38,8 +38,6 @@ func (k *KubeConfig) AddRegionalCluster(project string, cluster string, region s fmt.Sprintf("--project=%s", project), } envs := []string{ - "CLOUDSDK_CONTAINER_USE_V1_API_CLIENT=false", - "CLOUDSDK_CONTAINER_USE_V1_API=false", fmt.Sprintf("KUBECONFIG=%s", kubeCfgFile), } if _, err := k.commander.Execute("gcloud", args, envs); err != nil { diff --git a/pkg/kubeconfig/kubeconfig_test.go b/pkg/kubeconfig/kubeconfig_test.go index 46b1fd5..9705489 100644 --- a/pkg/kubeconfig/kubeconfig_test.go +++ b/pkg/kubeconfig/kubeconfig_test.go @@ -5,10 +5,10 @@ import ( "os" "testing" - "github.com/arunvelsriram/kube-tmuxp/pkg/internal/mock" - "github.com/arunvelsriram/kube-tmuxp/pkg/kubeconfig" "github.com/golang/mock/gomock" "github.com/stretchr/testify/assert" + "github.com/thecasualcoder/kube-tmuxp/pkg/internal/mock" + "github.com/thecasualcoder/kube-tmuxp/pkg/kubeconfig" ) func TestNew(t *testing.T) { @@ -105,8 +105,6 @@ func TestAddRegionalCluster(t *testing.T) { "--project=test-project", } envs := []string{ - "CLOUDSDK_CONTAINER_USE_V1_API_CLIENT=false", - "CLOUDSDK_CONTAINER_USE_V1_API=false", "KUBECONFIG=/Users/test/.kube/configs/test-context", } mockCmdr.EXPECT().Execute("gcloud", args, envs).Return("Context added successfully", nil) @@ -135,8 +133,6 @@ func TestAddRegionalCluster(t *testing.T) { "--project=test-project", } envs := []string{ - "CLOUDSDK_CONTAINER_USE_V1_API_CLIENT=false", - "CLOUDSDK_CONTAINER_USE_V1_API=false", "KUBECONFIG=/Users/test/.kube/configs/test-context", } mockCmdr.EXPECT().Execute("gcloud", args, envs).Return("", fmt.Errorf("some error")) diff --git a/pkg/kubetmuxp/kubetmuxp.go b/pkg/kubetmuxp/kubetmuxp.go index 51a6192..6ca201f 100644 --- a/pkg/kubetmuxp/kubetmuxp.go +++ b/pkg/kubetmuxp/kubetmuxp.go @@ -5,10 +5,10 @@ import ( "io/ioutil" "path" - "github.com/arunvelsriram/kube-tmuxp/pkg/filesystem" - "github.com/arunvelsriram/kube-tmuxp/pkg/kubeconfig" - "github.com/arunvelsriram/kube-tmuxp/pkg/tmuxp" - yaml "gopkg.in/yaml.v2" + "github.com/thecasualcoder/kube-tmuxp/pkg/filesystem" + "github.com/thecasualcoder/kube-tmuxp/pkg/kubeconfig" + "github.com/thecasualcoder/kube-tmuxp/pkg/tmuxp" + yamlV2 "gopkg.in/yaml.v2" ) // Envs reprensents environemnt variables @@ -17,10 +17,10 @@ type Envs map[string]string //Cluster represents a Kubernetes cluster type Cluster struct { Name string `yaml:"name"` - Zone string `yaml:"zone"` - Region string `yaml:"region"` + Zone string `yaml:"zone,omitempty"` + Region string `yaml:"region,omitempty"` Context string `yaml:"context"` - Envs `yaml:"envs"` + Envs `yaml:"envs,omitempty"` } // DefaultContextName returns default context name @@ -77,7 +77,7 @@ func (c *Config) load(cfgFile string) error { return err } - err = yaml.Unmarshal(data, c) + err = yamlV2.Unmarshal(data, c) if err != nil { return err } @@ -121,9 +121,13 @@ func (c *Config) Process() error { if regional, err := cluster.IsRegional(); err != nil { return err } else if regional { - c.kubeCfg.AddRegionalCluster(project.Name, cluster.Name, cluster.Region, kubeCfgFile) + if err := c.kubeCfg.AddRegionalCluster(project.Name, cluster.Name, cluster.Region, kubeCfgFile); err != nil { + return err + } } else { - c.kubeCfg.AddZonalCluster(project.Name, cluster.Name, cluster.Zone, kubeCfgFile) + if err := c.kubeCfg.AddZonalCluster(project.Name, cluster.Name, cluster.Zone, kubeCfgFile); err != nil { + return err + } } fmt.Println("Renaming context...") @@ -156,3 +160,13 @@ func NewConfig(cfgFile string, fs filesystem.FileSystem, kubeCfg kubeconfig.Kube return cfg, nil } + +// NewConfig creates a new kube-tmuxp Config for the given projects +func NewConfigWithProjects(projects Projects, fs filesystem.FileSystem, kubeCfg kubeconfig.KubeConfig) (Config, error) { + cfg := Config{ + filesystem: fs, + kubeCfg: kubeCfg, + Projects: projects, + } + return cfg, nil +} diff --git a/pkg/kubetmuxp/kubetmuxp_test.go b/pkg/kubetmuxp/kubetmuxp_test.go index b71bb09..d3ccd0d 100644 --- a/pkg/kubetmuxp/kubetmuxp_test.go +++ b/pkg/kubetmuxp/kubetmuxp_test.go @@ -5,11 +5,11 @@ import ( "strings" "testing" - "github.com/arunvelsriram/kube-tmuxp/pkg/internal/mock" - "github.com/arunvelsriram/kube-tmuxp/pkg/kubeconfig" - "github.com/arunvelsriram/kube-tmuxp/pkg/kubetmuxp" "github.com/golang/mock/gomock" "github.com/stretchr/testify/assert" + "github.com/thecasualcoder/kube-tmuxp/pkg/internal/mock" + "github.com/thecasualcoder/kube-tmuxp/pkg/kubeconfig" + "github.com/thecasualcoder/kube-tmuxp/pkg/kubetmuxp" ) func getKubeCfg(ctrl *gomock.Controller) kubeconfig.KubeConfig { diff --git a/pkg/tmuxp/tmuxp.go b/pkg/tmuxp/tmuxp.go index 1058a6e..593b384 100644 --- a/pkg/tmuxp/tmuxp.go +++ b/pkg/tmuxp/tmuxp.go @@ -3,7 +3,7 @@ package tmuxp import ( "path" - "github.com/arunvelsriram/kube-tmuxp/pkg/filesystem" + "github.com/thecasualcoder/kube-tmuxp/pkg/filesystem" yaml "gopkg.in/yaml.v2" ) @@ -60,6 +60,10 @@ func NewConfig(sessionName string, windows Windows, environment Environment, fs } tmuxpCfgsDir := path.Join(home, ".tmuxp") + err = fs.CreateDirIfNotExist(tmuxpCfgsDir) + if err != nil { + return nil, err + } return &Config{ SessionName: sessionName, Windows: windows, diff --git a/pkg/tmuxp/tmuxp_test.go b/pkg/tmuxp/tmuxp_test.go index f2b547a..53bfd9d 100644 --- a/pkg/tmuxp/tmuxp_test.go +++ b/pkg/tmuxp/tmuxp_test.go @@ -5,10 +5,10 @@ import ( "fmt" "testing" - "github.com/arunvelsriram/kube-tmuxp/pkg/internal/mock" - "github.com/arunvelsriram/kube-tmuxp/pkg/tmuxp" "github.com/golang/mock/gomock" "github.com/stretchr/testify/assert" + "github.com/thecasualcoder/kube-tmuxp/pkg/internal/mock" + "github.com/thecasualcoder/kube-tmuxp/pkg/tmuxp" ) func TestNewConfig(t *testing.T) { @@ -18,6 +18,7 @@ func TestNewConfig(t *testing.T) { mockFS := mock.NewFileSystem(ctrl) mockFS.EXPECT().HomeDir().Return("/Users/test", nil) + mockFS.EXPECT().CreateDirIfNotExist(gomock.Eq("/Users/test/.tmuxp")).Return(nil) tmuxpCfg, err := tmuxp.NewConfig("session", tmuxp.Windows{}, tmuxp.Environment{}, mockFS) assert.Nil(t, err) @@ -34,6 +35,18 @@ func TestNewConfig(t *testing.T) { assert.EqualError(t, err, "some error") }) + + t.Run("should return error in create .tmuxp dir is failed", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockFS := mock.NewFileSystem(ctrl) + mockFS.EXPECT().HomeDir().Return("/Users/test", nil) + mockFS.EXPECT().CreateDirIfNotExist(gomock.Eq("/Users/test/.tmuxp")).Return(fmt.Errorf("error creating .tmuxp dir")) + _, err := tmuxp.NewConfig("session", tmuxp.Windows{}, tmuxp.Environment{}, mockFS) + + assert.EqualError(t, err, "error creating .tmuxp dir") + }) } func TestTmuxpConfigsDir(t *testing.T) { @@ -42,6 +55,7 @@ func TestTmuxpConfigsDir(t *testing.T) { mockFS := mock.NewFileSystem(ctrl) mockFS.EXPECT().HomeDir().Return("/Users/test", nil) + mockFS.EXPECT().CreateDirIfNotExist(gomock.Eq("/Users/test/.tmuxp")).Return(nil) tmuxpCfg, _ := tmuxp.NewConfig("session", tmuxp.Windows{}, tmuxp.Environment{}, mockFS) assert.Equal(t, "/Users/test/.tmuxp", tmuxpCfg.TmuxpConfigsDir()) @@ -55,6 +69,7 @@ func TestSave(t *testing.T) { mockFS := mock.NewFileSystem(ctrl) mockFS.EXPECT().HomeDir().Return("/Users/test", nil) var writer bytes.Buffer + mockFS.EXPECT().CreateDirIfNotExist(gomock.Eq("/Users/test/.tmuxp")).Return(nil) mockFS.EXPECT().Create("tmuxp-config.yaml").Return(&writer, nil) tmuxpCfg, _ := tmuxp.NewConfig("session", tmuxp.Windows{{Name: "window"}}, tmuxp.Environment{"TEST_ENV": "value", "ANOTHER_TEST_ENV": "another-value"}, mockFS) @@ -79,6 +94,7 @@ environment: mockFS := mock.NewFileSystem(ctrl) mockFS.EXPECT().HomeDir().Return("/Users/test", nil) + mockFS.EXPECT().CreateDirIfNotExist(gomock.Eq("/Users/test/.tmuxp")).Return(nil) mockFS.EXPECT().Create("tmuxp-config.yaml").Return(nil, fmt.Errorf("some error")) tmuxpCfg, _ := tmuxp.NewConfig("session", tmuxp.Windows{{Name: "window"}}, tmuxp.Environment{"TEST_ENV": "value", "ANOTHER_TEST_ENV": "another-value"}, mockFS)