From 5483ee53600fe32b3f06e963c150557bfd945c35 Mon Sep 17 00:00:00 2001 From: wenqian Date: Mon, 9 Dec 2024 14:22:13 +0800 Subject: [PATCH] all: basic commands done (#1) --- .github/dependabot.yml | 14 ++ .github/workflows/build.yml | 24 +++ .github/workflows/check.yml | 27 +++ .github/workflows/release.yml | 32 +++ .github/workflows/typos.yml | 17 ++ .gitignore | 2 + Makefile | 45 ++++ build.sh | 49 +++++ cmd/cmd.go | 64 ++++++ cmd/completion.go | 102 +++++++++ cmd/config/completion.go | 38 ++++ cmd/config/config.go | 387 ++++++++++++++++++++++++++++++++++ cmd/cp/completion.go | 38 ++++ cmd/cp/cp.go | 89 ++++++++ cmd/exec/exec.go | 53 +++++ cmd/init/init.go | 59 ++++++ cmd/login/login.go | 44 ++++ cmd/nodeshell.go | 67 ++++++ cmd/ns/completion.go | 33 +++ cmd/ns/ns.go | 186 ++++++++++++++++ cmd/show/show.go | 47 +++++ cmd/source/source.go | 37 ++++ config/config.go | 179 ++++++++++++++++ config/defaults.toml | 21 ++ go.mod | 18 ++ go.sum | 27 +++ hack/hack.go | 13 ++ hack/kw.sh | 30 +++ main.go | 82 +++++++ pkg/dirs/dirs.go | 26 +++ pkg/edit/edit.go | 54 +++++ pkg/fzf/fzf.go | 53 +++++ pkg/history/history.go | 20 ++ pkg/history/manager.go | 183 ++++++++++++++++ pkg/kubeconfig/kubeconfig.go | 81 +++++++ pkg/kubeconfig/manager.go | 199 +++++++++++++++++ pkg/kubectl/cmd.go | 164 ++++++++++++++ pkg/kubectl/kubectl.go | 44 ++++ pkg/nodeshell/nodeshell.go | 155 ++++++++++++++ pkg/nodeshell/nodeshell.yaml | 20 ++ pkg/source/source.go | 46 ++++ pkg/term/term.go | 49 +++++ 42 files changed, 2918 insertions(+) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/build.yml create mode 100644 .github/workflows/check.yml create mode 100644 .github/workflows/release.yml create mode 100644 .github/workflows/typos.yml create mode 100644 Makefile create mode 100644 build.sh create mode 100644 cmd/cmd.go create mode 100644 cmd/completion.go create mode 100644 cmd/config/completion.go create mode 100644 cmd/config/config.go create mode 100644 cmd/cp/completion.go create mode 100644 cmd/cp/cp.go create mode 100644 cmd/exec/exec.go create mode 100644 cmd/init/init.go create mode 100644 cmd/login/login.go create mode 100644 cmd/nodeshell.go create mode 100644 cmd/ns/completion.go create mode 100644 cmd/ns/ns.go create mode 100644 cmd/show/show.go create mode 100644 cmd/source/source.go create mode 100644 config/config.go create mode 100644 config/defaults.toml create mode 100644 go.mod create mode 100644 go.sum create mode 100644 hack/hack.go create mode 100644 hack/kw.sh create mode 100644 main.go create mode 100644 pkg/dirs/dirs.go create mode 100644 pkg/edit/edit.go create mode 100644 pkg/fzf/fzf.go create mode 100644 pkg/history/history.go create mode 100644 pkg/history/manager.go create mode 100644 pkg/kubeconfig/kubeconfig.go create mode 100644 pkg/kubeconfig/manager.go create mode 100644 pkg/kubectl/cmd.go create mode 100644 pkg/kubectl/kubectl.go create mode 100644 pkg/nodeshell/nodeshell.go create mode 100644 pkg/nodeshell/nodeshell.yaml create mode 100644 pkg/source/source.go create mode 100644 pkg/term/term.go diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..22dcd86 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,14 @@ +# Please see the documentation for all configuration options: +# https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: + - package-ecosystem: "gomod" + directory: "/" + schedule: + interval: "weekly" + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..cba3697 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,24 @@ +name: Build Go + +on: + push: + branches: + - "main" + pull_request: + branches: + - "main" + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up go + uses: actions/setup-go@v5 + with: + go-version: 'stable' + + - name: Build + run: make build diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml new file mode 100644 index 0000000..541ec35 --- /dev/null +++ b/.github/workflows/check.yml @@ -0,0 +1,27 @@ +name: Check Go + +on: + push: + branches: + - "main" + pull_request: + branches: + - "main" + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up go + uses: actions/setup-go@v5 + with: + go-version: 'stable' + + - name: Check Go + run: make check + + - name: Test Go + run: make test diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..0808333 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,32 @@ +name: Release + +on: + push: + tags: + - '*' + +permissions: + contents: write + +jobs: + release: + if: contains(github.ref, 'refs/tags/') + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up go + uses: actions/setup-go@v5 + with: + go-version: 'stable' + + - name: Build Go + run: make cross + + - name: Create release + uses: softprops/action-gh-release@v2 + with: + draft: true + files: | + bin/*.tar.gz diff --git a/.github/workflows/typos.yml b/.github/workflows/typos.yml new file mode 100644 index 0000000..0c3623a --- /dev/null +++ b/.github/workflows/typos.yml @@ -0,0 +1,17 @@ +name: Spell Check + +on: + push: + branches: + - "main" + pull_request: + branches: + - "main" + +jobs: + spell-check: + name: Spell Check with Typos + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: crate-ci/typos@master diff --git a/.gitignore b/.gitignore index 6f72f89..3a249dd 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,5 @@ go.work.sum # env file .env + +/bin diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..4e0adb8 --- /dev/null +++ b/Makefile @@ -0,0 +1,45 @@ +COMMIT_ID=$(shell git rev-parse HEAD) +COMMIT_ID_SHORT=$(shell git rev-parse --short HEAD) + +TAG=$(shell git describe --tags --abbrev=0 2>/dev/null) + +DATE=$(shell date '+%FT%TZ') + +# If current commit is tagged, use tag as version, else, use dev-${COMMIT_ID} as version +VERSION=$(shell git tag --points-at ${COMMIT_ID}) +VERSION:=$(if $(VERSION),$(VERSION),dev-${COMMIT_ID_SHORT}) + +.PHONY: build +build: + @bash build.sh + +.PHONY: install +install: + @bash build.sh "install" + +.PHONY: cross +cross: + @bash build.sh "linux" "amd64" + @tar -czf bin/kubewrap-linux-amd64.tar.gz -C bin kubewrap + @bash build.sh "linux" "arm64" + @tar -czf bin/kubewrap-linux-arm64.tar.gz -C bin kubewrap + @bash build.sh "darwin" "arm64" + @tar -czf bin/kubewrap-darwin-arm64.tar.gz -C bin kubewrap + @bash build.sh "darwin" "amd64" + @tar -czf bin/kubewrap-darwin-amd64.tar.gz -C bin kubewrap + +.PHONY: test +test: + @CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go test ./... + +.PHONY: fmt +fmt: + @find . -name \*.go -exec goimports -w {} \; + +.PHONY: check +check: + @CGO_ENABLED=0 go vet ./... + +.PHONY: typos +typos: + @typos ./ diff --git a/build.sh b/build.sh new file mode 100644 index 0000000..0793d43 --- /dev/null +++ b/build.sh @@ -0,0 +1,49 @@ +#!/bin/bash + +GIT_DESC=$(git describe --tags 2> /dev/null) +GIT_TAG=$(git describe --tags --abbrev=0 2> /dev/null) +GIT_COMMIT=$(git rev-parse HEAD) +GIT_COMMIT_SHORT=$(git rev-parse --short HEAD) + +if [[ "$GIT_DESC" == "$GIT_TAG" ]]; then + BUILD_TYPE="stable" + BUILD_VERSION="$GIT_TAG" +else + BUILD_TYPE="dev" + BUILD_VERSION="${GIT_TAG}-dev_${GIT_COMMIT_SHORT}" +fi + +if [[ -z "$BUILD_VERSION" ]]; then + BUILD_TYPE="dev" + BUILD_VERSION="dev_${GIT_COMMIT_SHORT}" +fi + +if git status --porcelain | grep -E '(M|A|D|R|\?)' > /dev/null; then + BUILD_TYPE="dev-uncommitted" + BUILD_VERSION="${BUILD_VERSION}-uncommitted" +fi + +cat << EOF +Build Args: +GIT_DESC=${GIT_DESC} +GIT_TAG=${GIT_TAG} +GIT_COMMIT=${GIT_COMMIT} +GIT_COMMIT_SHORT=${GIT_COMMIT_SHORT} +BUILD_TYPE=${BUILD_TYPE} +BUILD_VERSION=${BUILD_VERSION} +EOF + +BUILD_FLAGS="-X main.Version=${BUILD_VERSION} -X main.BuildType=${BUILD_TYPE} -X main.BuildCommit=${GIT_COMMIT} -X main.BuildTime=$(date +%F-%Z/%T)" + +echo "" +if [[ "$1" == "install" ]]; then + echo "Install kubewrap..." + CGO_ENABLED=0 go install -ldflags "${BUILD_FLAGS}" +else + echo "Build kubewrap..." + CGO_ENABLED=0 GOOS="$1" GOARCH="$2" go build -ldflags "${BUILD_FLAGS}" -o ./bin/kubewrap +fi +if [[ $? -ne 0 ]]; then + echo "Build kubewrap failed" + exit 1 +fi diff --git a/cmd/cmd.go b/cmd/cmd.go new file mode 100644 index 0000000..f2ef21e --- /dev/null +++ b/cmd/cmd.go @@ -0,0 +1,64 @@ +package cmd + +import ( + "fmt" + + "github.com/fioncat/kubewrap/config" + "github.com/fioncat/kubewrap/pkg/kubectl" + "github.com/fioncat/kubewrap/pkg/term" + "github.com/spf13/cobra" +) + +type Context struct { + Command *cobra.Command + Config *config.Config + Kubectl kubectl.Kubectl +} + +type Validator interface { + Validate(c *cobra.Command, args []string) error +} + +type Options interface { + Validator + Run(cmdctx *Context) error +} + +func Build(c *cobra.Command, opts Options) *cobra.Command { + var ( + printConfig bool + configPath string + useDefaultConfig bool + ) + + c.RunE = func(cmd *cobra.Command, args []string) error { + err := opts.Validate(cmd, args) + if err != nil { + return fmt.Errorf("validate command args: %w", err) + } + + cfg, err := config.Load(configPath, useDefaultConfig) + if err != nil { + return fmt.Errorf("load config: %w", err) + } + + if printConfig { + return term.PrintJson(cfg) + } + + kubectl := kubectl.NewCommand(cfg.Kubectl.Name, cfg.Kubectl.Args) + cmdctx := &Context{ + Command: cmd, + Config: cfg, + Kubectl: kubectl, + } + + return opts.Run(cmdctx) + } + + c.Flags().StringVarP(&configPath, "config", "", "", "config file path") + c.Flags().BoolVarP(&useDefaultConfig, "default-config", "", false, "force to use default config") + c.Flags().BoolVarP(&printConfig, "print-config", "", false, "print the config and exit (skip main process), useful for debug") + + return c +} diff --git a/cmd/completion.go b/cmd/completion.go new file mode 100644 index 0000000..e4968b4 --- /dev/null +++ b/cmd/completion.go @@ -0,0 +1,102 @@ +package cmd + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/fioncat/kubewrap/config" + "github.com/fioncat/kubewrap/pkg/kubeconfig" + "github.com/fioncat/kubewrap/pkg/kubectl" + "github.com/spf13/cobra" +) + +func GetCompleteConfig(c *cobra.Command) *config.Config { + configPath := c.Flags().Lookup("config").Value.String() + useDefaultConfig := c.Flags().Lookup("default-config").Value.String() == "true" + cfg, err := config.Load(configPath, useDefaultConfig) + if err != nil { + WriteCompleteLogs("Load config failed: %v", err) + return nil + } + return cfg +} + +func GetCompleteKubeconfigManager(c *cobra.Command) kubeconfig.Manager { + cfg := GetCompleteConfig(c) + if cfg == nil { + return nil + } + + mgr, err := kubeconfig.NewManager(cfg.KubeConfig.Root, cfg.KubeConfig.Alias) + if err != nil { + WriteCompleteLogs("Create kubeconfig manager failed: %v", err) + return nil + } + + return mgr +} + +func getCompleteKubectl(c *cobra.Command) kubectl.Kubectl { + printConfig := c.Flags().Lookup("print-config").Value.String() == "true" + if printConfig { + WriteCompleteLogs("In print config mode, skip completion") + return nil + } + + cfg := GetCompleteConfig(c) + if cfg == nil { + return nil + } + + return kubectl.NewCommand(cfg.Kubectl.Name, cfg.Kubectl.Args) +} + +func CompleteNodeItems(c *cobra.Command) ([]string, bool) { + nodes, ok := CompleteNodes(c) + if !ok { + return nil, false + } + items := make([]string, 0, len(nodes)) + for _, node := range nodes { + items = append(items, fmt.Sprintf("%s\t%s", node.Name, node.Description)) + } + return items, true +} + +func CompleteNodes(c *cobra.Command) ([]*kubectl.Node, bool) { + kubectl := getCompleteKubectl(c) + if kubectl == nil { + return nil, false + } + + nodes, err := kubectl.ListNodes() + if err != nil { + WriteCompleteLogs("List nodes failed: %v", err) + return nil, false + } + return nodes, true +} + +func SingleNodeCompletionFunc(c *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) != 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + items, ok := CompleteNodeItems(c) + if !ok { + return nil, cobra.ShellCompDirectiveError + } + + return items, cobra.ShellCompDirectiveNoFileComp +} + +func WriteCompleteLogs(format string, args ...any) { + logs := fmt.Sprintf(format+"\n", args...) + path := filepath.Join(os.TempDir(), "kubewrap_complete.log") + file, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) + if err != nil { + return + } + defer file.Close() + _, _ = file.WriteString(logs) +} diff --git a/cmd/config/completion.go b/cmd/config/completion.go new file mode 100644 index 0000000..50c0221 --- /dev/null +++ b/cmd/config/completion.go @@ -0,0 +1,38 @@ +package config + +import ( + "fmt" + + "github.com/fioncat/kubewrap/cmd" + "github.com/spf13/cobra" +) + +func CompletionFunc(c *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) != 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + mgr := cmd.GetCompleteKubeconfigManager(c) + if mgr == nil { + return nil, cobra.ShellCompDirectiveError + } + + var curName string + cur, ok := mgr.Current() + if ok { + curName = cur.Name + } + + kcs := mgr.List() + items := make([]string, 0, len(kcs)) + for _, kc := range kcs { + if curName != "" && kc.Name == curName { + continue + } + item := kc.Name + if kc.Alias != "" { + item = fmt.Sprintf("%s\talias to %s", kc.Name, kc.Alias) + } + items = append(items, item) + } + return items, cobra.ShellCompDirectiveNoFileComp +} diff --git a/cmd/config/config.go b/cmd/config/config.go new file mode 100644 index 0000000..282dc9b --- /dev/null +++ b/cmd/config/config.go @@ -0,0 +1,387 @@ +package config + +import ( + "errors" + "fmt" + "os" + + "github.com/fatih/color" + "github.com/fioncat/kubewrap/cmd" + "github.com/fioncat/kubewrap/pkg/edit" + "github.com/fioncat/kubewrap/pkg/fzf" + "github.com/fioncat/kubewrap/pkg/history" + "github.com/fioncat/kubewrap/pkg/kubeconfig" + "github.com/fioncat/kubewrap/pkg/source" + "github.com/fioncat/kubewrap/pkg/term" + "github.com/spf13/cobra" +) + +func New() *cobra.Command { + var opts Options + c := &cobra.Command{ + Use: "config [NAME]", + Short: "Manage kube config files", + Args: cobra.MaximumNArgs(1), + + ValidArgsFunction: CompletionFunc, + } + + c.Flags().BoolVarP(&opts.edit, "edit", "e", false, "edit kubeconfig file") + c.Flags().BoolVarP(&opts.delete, "delete", "d", false, "delete kubeconfig file") + c.Flags().BoolVarP(&opts.deleteAll, "delete-all", "D", false, "delete all kubeconfig files") + c.Flags().BoolVarP(&opts.list, "list", "l", false, "list kubeconfig files") + c.Flags().BoolVarP(&opts.listHistory, "list-history", "H", false, "show kubeconfig history") + c.Flags().BoolVarP(&opts.unuse, "unuse", "u", false, "unuse current kubeconfig") + + c.Flags().BoolVarP(&opts.skipConfirm, "noconfirm", "y", false, "skip confirm") + + return cmd.Build(c, &opts) +} + +type Options struct { + name string + + edit bool + delete bool + deleteAll bool + + list bool + listHistory bool + + unuse bool + + skipConfirm bool + + configMgr kubeconfig.Manager + historyMgr history.Manager + + cur *kubeconfig.KubeConfig + curName string +} + +func (o *Options) Validate(_ *cobra.Command, args []string) error { + if len(args) > 0 { + o.name = args[0] + } + + opts := []bool{ + o.edit, o.delete, o.deleteAll, o.list, o.listHistory, o.unuse, + } + var hasMode bool + for _, opt := range opts { + if opt { + hasMode = true + continue + } + if hasMode && opt { + return errors.New("mode cannot duplicate") + } + } + + return nil +} + +func (o *Options) Run(cmdctx *cmd.Context) error { + err := o.prepare(*cmdctx) + if err != nil { + return err + } + + switch { + case o.edit: + return o.handleEdit(cmdctx) + case o.delete: + return o.handleDelete() + case o.deleteAll: + return o.handleDeleteAll() + case o.list: + return o.handleList() + case o.listHistory: + return o.handleListHistory() + case o.unuse: + return o.handleUnuse(cmdctx) + default: + return o.handleUse(cmdctx) + } +} + +func (o *Options) prepare(cmdctx cmd.Context) error { + cfg := cmdctx.Config + configMgr, err := kubeconfig.NewManager(cfg.KubeConfig.Root, cfg.KubeConfig.Alias) + if err != nil { + return err + } + + histMgr, err := history.NewManager(cfg.History.Path, cfg.History.Max) + if err != nil { + return err + } + + cur, ok := configMgr.Current() + if ok { + o.cur = cur + o.curName = cur.Name + } + o.configMgr = configMgr + o.historyMgr = histMgr + + return nil +} + +func (o *Options) handleUse(cmdctx *cmd.Context) error { + kc, err := o.selectUse(cmdctx) + if err != nil { + return err + } + + return o.use(cmdctx, kc) +} + +func (o *Options) selectUse(cmdctx *cmd.Context) (*kubeconfig.KubeConfig, error) { + var curName string + cur, ok := o.configMgr.Current() + if ok { + curName = cur.Name + } + + if o.name == "-" { + lastNamePtr := o.historyMgr.GetLastName(curName) + if lastNamePtr == nil { + return nil, errors.New("no last kubeconfig selected") + } + + name := *lastNamePtr + kc, ok := o.configMgr.Get(name) + if !ok { + return nil, fmt.Errorf("cannot find last kubeconfig %q in history, you should remove history records", name) + } + return kc, nil + } + + if o.name != "" { + kc, ok := o.configMgr.Get(o.name) + if !ok { + err := term.Confirm(o.skipConfirm, "kubeconfig %q not found, do you want to create it", o.name) + if err != nil { + return nil, err + } + + data, err := edit.Edit(cmdctx.Config, nil) + if err != nil { + return nil, err + } + + return o.configMgr.Put(o.name, data) + } + return kc, nil + } + + return o.selectOne() +} + +func (o *Options) handleEdit(cmdctx *cmd.Context) error { + name, err := o.selectEdit() + if err != nil { + return err + } + + var initData []byte + kc, ok := o.configMgr.Get(name) + if !ok { + err = term.Confirm(o.skipConfirm, "try to edit a new kubeconfig %q, continue", name) + if err != nil { + return err + } + } else { + path := kc.Path() + initData, err = os.ReadFile(path) + if err != nil && !os.IsNotExist(err) { + return err + } + } + + data, err := edit.Edit(cmdctx.Config, initData) + if err != nil { + return err + } + + kc, err = o.configMgr.Put(name, data) + if err != nil { + return err + } + + cur, ok := o.configMgr.Current() + if ok && cur.Name == name { + return nil + } + + return o.use(cmdctx, kc) +} + +func (o *Options) selectEdit() (string, error) { + if o.name != "" { + return o.name, nil + } + + if o.curName != "" { + return o.curName, nil + } + + kc, err := o.selectOne() + if err != nil { + return "", err + } + + return kc.Name, nil +} + +func (o *Options) handleDelete() error { + name, err := o.selectDelete() + if err != nil { + return err + } + + o.historyMgr.DeleteByName(name) + err = o.historyMgr.Save() + if err != nil { + return err + } + + term.PrintHint("Delete kubeconfig %q", name) + return o.configMgr.Delete(name) +} + +func (o *Options) selectDelete() (string, error) { + if o.name != "" { + return o.name, nil + } + + kc, err := o.selectOne() + if err != nil { + return "", err + } + return kc.Name, nil +} + +func (o *Options) handleList() error { + kc, err := o.selectGet() + if err != nil { + return err + } + if kc != nil { + fmt.Println(kc.String()) + return nil + } + + kcs := o.configMgr.List() + for _, kc := range kcs { + line := kc.String() + if o.curName != "" && kc.Name == o.curName { + line = color.New(color.Bold).Sprintf("* %s", line) + } + fmt.Println(line) + } + return nil +} + +func (o *Options) handleListHistory() error { + records := o.historyMgr.List() + + kc, err := o.selectGet() + if err != nil { + return err + } + if kc != nil { + newRecords := make([]*history.Record, 0) + for _, record := range records { + if record.Name == kc.Name { + newRecords = append(newRecords, record) + } + } + records = newRecords + } + + for _, record := range records { + if record.Namespace != "" { + continue + } + fmt.Printf("[%s] %s\n", term.FormatTimestamp(record.Timestamp), record.Name) + } + + return nil +} + +func (o *Options) selectGet() (*kubeconfig.KubeConfig, error) { + if o.name == "" { + return o.cur, nil + } + kc, ok := o.configMgr.Get(o.name) + if !ok { + return nil, fmt.Errorf("cannot find kubeconfig %q to show", o.name) + } + return kc, nil +} + +func (o *Options) use(cmdctx *cmd.Context, kc *kubeconfig.KubeConfig) error { + term.PrintHint("Switch to kubeconfig %q", kc.Name) + src := kc.GenerateSource("") + err := source.Apply(cmdctx.Config, src) + if err != nil { + return err + } + + o.historyMgr.Add(kc.Name, "") + return o.historyMgr.Save() +} + +func (o *Options) selectOne() (*kubeconfig.KubeConfig, error) { + kcs := o.configMgr.List() + filtered := make([]*kubeconfig.KubeConfig, 0, len(kcs)) + for _, kc := range kcs { + if o.curName != "" && kc.Name == o.curName { + continue + } + filtered = append(filtered, kc) + } + + items := make([]string, 0, len(filtered)) + for _, kc := range filtered { + items = append(items, kc.Name) + } + + if len(items) == 0 { + return nil, errors.New("no kubeconfig to select") + } + + idx, err := fzf.Search(items) + if err != nil { + return nil, err + } + + return filtered[idx], nil +} + +func (o *Options) handleUnuse(cmdctx *cmd.Context) error { + if o.curName == "" { + return errors.New("no current kubeconfig used, cannot unuse") + } + + term.PrintHint("Unuse current kubeconfig %q", o.curName) + src := kubeconfig.UnsetSource() + return source.Apply(cmdctx.Config, src) +} + +func (o *Options) handleDeleteAll() error { + _, ok := o.configMgr.Current() + if ok { + return errors.New("you are now using a kubeconfig, please unuse it first") + } + + err := term.Confirm(o.skipConfirm, "Do you want to delete all kubeconfig files") + if err != nil { + return err + } + + term.PrintHint("Delete all kubeconfig files") + return o.configMgr.DeleteAll() +} diff --git a/cmd/cp/completion.go b/cmd/cp/completion.go new file mode 100644 index 0000000..cfd2880 --- /dev/null +++ b/cmd/cp/completion.go @@ -0,0 +1,38 @@ +package cp + +import ( + "fmt" + "strings" + + "github.com/fioncat/kubewrap/cmd" + "github.com/spf13/cobra" +) + +func CompletionFunc(c *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return nil, cobra.ShellCompDirectiveDefault + } + if len(args) != 1 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + src := args[0] + if strings.Contains(src, ":") { + return nil, cobra.ShellCompDirectiveDefault + } + + if strings.Contains(toComplete, ":") { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + nodes, ok := cmd.CompleteNodes(c) + if !ok { + return nil, cobra.ShellCompDirectiveError + } + + items := make([]string, 0, len(nodes)) + for _, node := range nodes { + items = append(items, fmt.Sprintf("%s:/\t%s", node.Name, node.Description)) + } + return items, cobra.ShellCompDirectiveNoSpace +} diff --git a/cmd/cp/cp.go b/cmd/cp/cp.go new file mode 100644 index 0000000..eb51ddb --- /dev/null +++ b/cmd/cp/cp.go @@ -0,0 +1,89 @@ +package cp + +import ( + "errors" + "strings" + + "github.com/fioncat/kubewrap/cmd" + "github.com/fioncat/kubewrap/pkg/nodeshell" + "github.com/fioncat/kubewrap/pkg/term" + "github.com/spf13/cobra" +) + +func New() *cobra.Command { + var opts Options + c := &cobra.Command{ + Use: "cp ", + Short: "Use nodeshell to copy files between local and remote nodes", + Args: cobra.ExactArgs(2), + + ValidArgsFunction: CompletionFunc, + } + return cmd.BuildNodeShell(c, &opts) +} + +type Options struct { + node string + src nodeshell.CopyPath + dest nodeshell.CopyPath +} + +func (o *Options) Validate(_ *cobra.Command, args []string) error { + src, ok := o.parseCopy(args[0]) + if !ok { + return errors.New("invalid source path") + } + + dest, ok := o.parseCopy(args[1]) + if !ok { + return errors.New("invalid destination path") + } + + if len(o.node) == 0 { + return errors.New("require at least one remote copy path") + } + + if src.Remote && dest.Remote { + return errors.New("cannot copy between two remote nodes") + } + + o.src = src + o.dest = dest + + return nil +} + +func (o *Options) Node() string { + return o.node +} + +func (o *Options) Run(cmdctx *cmd.Context, nodeshell *nodeshell.NodeShell) error { + term.PrintHint("Copying between host and %q", o.node) + return nodeshell.Copy(o.src, o.dest) +} + +func (o *Options) parseCopy(arg string) (nodeshell.CopyPath, bool) { + fields := strings.Split(arg, ":") + if len(fields) == 0 { + return nodeshell.CopyPath{}, false + } + if len(fields) == 1 { + return nodeshell.CopyPath{Path: arg}, true + } + + node := fields[0] + if len(node) == 0 { + return nodeshell.CopyPath{}, false + } + o.node = node + + path := strings.TrimPrefix(arg, node+":") + if len(path) == 0 { + return nodeshell.CopyPath{}, false + } + + return nodeshell.CopyPath{ + Path: path, + Remote: true, + }, true +} diff --git a/cmd/exec/exec.go b/cmd/exec/exec.go new file mode 100644 index 0000000..cab9157 --- /dev/null +++ b/cmd/exec/exec.go @@ -0,0 +1,53 @@ +package exec + +import ( + "errors" + + "github.com/fioncat/kubewrap/cmd" + "github.com/fioncat/kubewrap/pkg/nodeshell" + "github.com/fioncat/kubewrap/pkg/term" + "github.com/spf13/cobra" +) + +func New() *cobra.Command { + var opts Options + c := &cobra.Command{ + Use: "exec -- ", + Short: "Execute a command on a node", + + ValidArgsFunction: cmd.SingleNodeCompletionFunc, + } + return cmd.BuildNodeShell(c, &opts) +} + +type Options struct { + node string + + cmd []string +} + +func (o *Options) Validate(c *cobra.Command, args []string) error { + o.node = args[0] + if len(o.node) == 0 { + return errors.New("node is required") + } + + argsAtDash := c.ArgsLenAtDash() + if argsAtDash > -1 { + o.cmd = args[argsAtDash:] + } + if len(o.cmd) == 0 { + return errors.New("command is required") + } + + return nil +} + +func (o *Options) Node() string { + return o.node +} + +func (o *Options) Run(cmdctx *cmd.Context, nodeshell *nodeshell.NodeShell) error { + term.PrintHint("Running command on %q", o.node) + return nodeshell.Exec(o.cmd) +} diff --git a/cmd/init/init.go b/cmd/init/init.go new file mode 100644 index 0000000..265e9e7 --- /dev/null +++ b/cmd/init/init.go @@ -0,0 +1,59 @@ +package init + +import ( + "errors" + "fmt" + "os" + + "github.com/fioncat/kubewrap/cmd" + "github.com/fioncat/kubewrap/hack" + "github.com/spf13/cobra" +) + +func New() *cobra.Command { + var opts Options + c := &cobra.Command{ + Use: "init ", + Short: "Print init script, you should source this in the profile", + Args: cobra.ExactArgs(1), + } + return cmd.Build(c, &opts) +} + +type Options struct { + shell string + cmdName string +} + +func (o *Options) Validate(_ *cobra.Command, args []string) error { + o.shell = args[0] + if len(o.shell) == 0 { + return errors.New("shell is required") + } + return nil +} + +func (o *Options) Run(cmdctx *cmd.Context) error { + name := cmdctx.Config.Command + if len(o.cmdName) > 0 { + name = o.cmdName + } + + root := cmdctx.Command.Root() + root.Use = name + fmt.Println(hack.GetBash(name)) + + switch o.shell { + case "bash", "sh": + return root.GenBashCompletionV2(os.Stdout, true) + + case "zsh": + return root.GenZshCompletion(os.Stdout) + + case "fish": + return root.GenFishCompletion(os.Stdout, true) + + default: + return fmt.Errorf("unknown shell type: %s", o.shell) + } +} diff --git a/cmd/login/login.go b/cmd/login/login.go new file mode 100644 index 0000000..4efce58 --- /dev/null +++ b/cmd/login/login.go @@ -0,0 +1,44 @@ +package login + +import ( + "errors" + + "github.com/fioncat/kubewrap/cmd" + "github.com/fioncat/kubewrap/pkg/nodeshell" + "github.com/fioncat/kubewrap/pkg/term" + "github.com/spf13/cobra" +) + +func New() *cobra.Command { + var opts Options + c := &cobra.Command{ + Use: "login ", + Short: "Use nodeshell to login to a node", + Args: cobra.ExactArgs(1), + + ValidArgsFunction: cmd.SingleNodeCompletionFunc, + } + return cmd.BuildNodeShell(c, &opts) +} + +type Options struct { + node string +} + +func (o *Options) Validate(_ *cobra.Command, args []string) error { + o.node = args[0] + if len(o.node) == 0 { + return errors.New("node is required") + } + + return nil +} + +func (o *Options) Node() string { + return o.node +} + +func (o *Options) Run(cmdctx *cmd.Context, nodeshell *nodeshell.NodeShell) error { + term.PrintHint("Login to %q", o.Node()) + return nodeshell.Login() +} diff --git a/cmd/nodeshell.go b/cmd/nodeshell.go new file mode 100644 index 0000000..16bfbef --- /dev/null +++ b/cmd/nodeshell.go @@ -0,0 +1,67 @@ +package cmd + +import ( + "fmt" + "os" + "strings" + + "github.com/fioncat/kubewrap/pkg/nodeshell" + "github.com/fioncat/kubewrap/pkg/term" + "github.com/spf13/cobra" +) + +type NodeShellOptions interface { + Validator + Node() string + Run(cmdctx *Context, nodeshell *nodeshell.NodeShell) error +} + +func BuildNodeShell(c *cobra.Command, opts NodeShellOptions) *cobra.Command { + nsOpts := &nodeShellOptions{opts: opts} + c.Flags().StringVarP(&nsOpts.namespace, "namespace", "n", "", "namespace of the shell pod, default will use option from config file") + c.Flags().StringVarP(&nsOpts.image, "image", "i", "", "image of the shell pod, default will use option from config file") + c.Flags().StringVarP(&nsOpts.shell, "shell", "s", "", "shell command to run, default will use option from config file") + return Build(c, nsOpts) +} + +type nodeShellOptions struct { + opts NodeShellOptions + + namespace string + image string + shell string +} + +func (o *nodeShellOptions) Validate(cmd *cobra.Command, args []string) error { + return o.opts.Validate(cmd, args) +} + +func (o *nodeShellOptions) Run(cmdctx *Context) error { + if len(o.namespace) == 0 { + o.namespace = cmdctx.Config.NodeShell.Namespace + } + if len(o.image) == 0 { + o.image = cmdctx.Config.NodeShell.Image + } + var shell []string + if len(o.shell) == 0 { + shell = cmdctx.Config.NodeShell.Shell + } else { + shell = strings.Fields(o.shell) + } + + term.PrintHint("Spawning shell pod on %q", o.opts.Node()) + ns, err := nodeshell.New(cmdctx.Kubectl, o.opts.Node(), o.namespace, o.image, shell) + if err != nil { + return err + } + defer func() { + term.PrintHint("Deleting shell pod on %q", o.opts.Node()) + closeErr := ns.Close() + if closeErr != nil { + fmt.Fprintf(os.Stderr, "WARNING: failed to delete shell pod on %q, you may need to delete it manually\n", o.opts.Node()) + } + }() + + return o.opts.Run(cmdctx, ns) +} diff --git a/cmd/ns/completion.go b/cmd/ns/completion.go new file mode 100644 index 0000000..0bdf89a --- /dev/null +++ b/cmd/ns/completion.go @@ -0,0 +1,33 @@ +package ns + +import ( + "github.com/fioncat/kubewrap/cmd" + "github.com/fioncat/kubewrap/pkg/kubeconfig" + "github.com/fioncat/kubewrap/pkg/kubectl" + "github.com/spf13/cobra" +) + +func CompletionFunc(c *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + cfg := cmd.GetCompleteConfig(c) + + mgr, err := kubeconfig.NewManager(cfg.KubeConfig.Root, cfg.KubeConfig.Alias) + if err != nil { + cmd.WriteCompleteLogs("init kubeconfig manager failed: %v", err) + return nil, cobra.ShellCompDirectiveError + } + + cur, ok := mgr.Current() + if !ok { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + kubectl := kubectl.NewCommand(cfg.Kubectl.Name, cfg.Kubectl.Args) + + items, err := listNamespaces(cfg, kubectl, cur.Name) + if err != nil { + cmd.WriteCompleteLogs("list namespaces: %v", err) + return nil, cobra.ShellCompDirectiveError + } + + return items, cobra.ShellCompDirectiveNoFileComp +} diff --git a/cmd/ns/ns.go b/cmd/ns/ns.go new file mode 100644 index 0000000..9b6e48f --- /dev/null +++ b/cmd/ns/ns.go @@ -0,0 +1,186 @@ +package ns + +import ( + "errors" + "fmt" + "path/filepath" + + "github.com/fatih/color" + "github.com/fioncat/kubewrap/cmd" + "github.com/fioncat/kubewrap/config" + "github.com/fioncat/kubewrap/pkg/fzf" + "github.com/fioncat/kubewrap/pkg/history" + "github.com/fioncat/kubewrap/pkg/kubeconfig" + "github.com/fioncat/kubewrap/pkg/kubectl" + "github.com/fioncat/kubewrap/pkg/source" + "github.com/fioncat/kubewrap/pkg/term" + "github.com/spf13/cobra" +) + +func New() *cobra.Command { + var opts Options + c := &cobra.Command{ + Use: "ns [NAME]", + Short: "Switch to namespace", + Args: cobra.MaximumNArgs(1), + + ValidArgsFunction: CompletionFunc, + } + + c.Flags().BoolVarP(&opts.unuse, "unuse", "u", false, "unuse namespace") + c.Flags().BoolVarP(&opts.list, "list", "l", false, "list namespaces") + c.Flags().BoolVarP(&opts.listHistory, "list-history", "H", false, "show namespace history") + + return cmd.Build(c, &opts) +} + +type Options struct { + namespace string + + unuse bool + + list bool + listHistory bool +} + +func (o *Options) Validate(_ *cobra.Command, args []string) error { + if len(args) > 0 { + o.namespace = args[0] + } + return nil +} + +func (o *Options) Run(cmdctx *cmd.Context) error { + cfg := cmdctx.Config + configMgr, err := kubeconfig.NewManager(cfg.KubeConfig.Root, cfg.KubeConfig.Alias) + if err != nil { + return err + } + + histMgr, err := history.NewManager(cfg.History.Path, cfg.History.Max) + if err != nil { + return err + } + + cur, ok := configMgr.Current() + if !ok { + return errors.New("no kubeconfig selected, cannot perform ns operations, please select one first") + } + + if o.list { + curNs := kubeconfig.GetCurrentNamespace() + var nsList []string + nsList, err = listNamespacesRaw(cmdctx.Config, cmdctx.Kubectl, cur.Name) + if err != nil { + return err + } + for _, ns := range nsList { + if curNs != "" && curNs == ns { + ns = color.New(color.Bold).Sprintf("* %s", ns) + } + fmt.Println(ns) + } + return nil + } + + if o.listHistory { + records := histMgr.List() + for _, record := range records { + if record.Name != cur.Name { + continue + } + if record.Namespace == "" { + continue + } + fmt.Printf("[%s] %s\n", term.FormatTimestamp(record.Timestamp), record.Namespace) + } + return nil + } + + if o.unuse { + curNs := kubeconfig.GetCurrentNamespace() + if curNs == "" { + return errors.New("no current namespace used, cannot unuse") + } + term.PrintHint("Unuse current namespace %q", curNs) + return source.Apply(cfg, cur.GenerateSource("")) + } + + ns, err := o.selectNs(cmdctx, cur.Name, histMgr) + if err != nil { + return err + } + + term.PrintHint("Switch to namespace %q", ns) + err = source.Apply(cfg, cur.GenerateSource(ns)) + if err != nil { + return err + } + + histMgr.Add(cur.Name, ns) + return histMgr.Save() +} + +func (o *Options) selectNs(cmdctx *cmd.Context, curName string, histMgr history.Manager) (string, error) { + if o.namespace == "-" { + curNamespace := kubeconfig.GetCurrentNamespace() + lastNsPtr := histMgr.GetLastNamespace(curName, curNamespace) + if lastNsPtr == nil { + return "", errors.New("no last namespace selected") + } + return *lastNsPtr, nil + } + + if o.namespace != "" { + return o.namespace, nil + } + + items, err := listNamespaces(cmdctx.Config, cmdctx.Kubectl, curName) + if err != nil { + return "", err + } + idx, err := fzf.Search(items) + if err != nil { + return "", err + } + + return items[idx], nil +} + +func listNamespaces(cfg *config.Config, kubectl kubectl.Kubectl, curName string) ([]string, error) { + nsList, err := listNamespacesRaw(cfg, kubectl, curName) + if err != nil { + return nil, err + } + + newNsList := make([]string, 0, len(nsList)) + curNamespace := kubeconfig.GetCurrentNamespace() + for _, ns := range nsList { + if curNamespace != "" && ns == curNamespace { + continue + } + newNsList = append(newNsList, ns) + } + return newNsList, nil +} + +func listNamespacesRaw(cfg *config.Config, kubectl kubectl.Kubectl, curName string) ([]string, error) { + for _, nsAlias := range cfg.NamespaceAlias { + for _, pattern := range nsAlias.Pattern { + match, err := filepath.Match(pattern, curName) + if err != nil { + return nil, fmt.Errorf("invalid namespace alias pattern %q: %w", pattern, err) + } + if match { + return nsAlias.Namespaces, nil + } + } + for _, name := range nsAlias.Configs { + if name == curName { + return nsAlias.Namespaces, nil + } + } + } + + return kubectl.ListNamespaces() +} diff --git a/cmd/show/show.go b/cmd/show/show.go new file mode 100644 index 0000000..20a3acf --- /dev/null +++ b/cmd/show/show.go @@ -0,0 +1,47 @@ +package show + +import ( + "errors" + "fmt" + + "github.com/fioncat/kubewrap/cmd" + "github.com/fioncat/kubewrap/pkg/kubeconfig" + "github.com/spf13/cobra" +) + +func New() *cobra.Command { + var opts Options + c := &cobra.Command{ + Use: "show", + Short: "Print current selected kubeconfig and namespace", + Args: cobra.NoArgs, + } + return cmd.Build(c, &opts) +} + +type Options struct{} + +func (o *Options) Validate(_ *cobra.Command, _ []string) error { return nil } + +func (o *Options) Run(cmdctx *cmd.Context) error { + cfg := cmdctx.Config + mgr, err := kubeconfig.NewManager(cfg.KubeConfig.Root, cfg.KubeConfig.Alias) + if err != nil { + return err + } + + cur, ok := mgr.Current() + if !ok { + return errors.New("no current selected kubeconfig") + } + + ns := kubeconfig.GetCurrentNamespace() + + str := cur.String() + if ns != "" { + str = fmt.Sprintf("%s -> %s", str, ns) + } + + fmt.Println(str) + return nil +} diff --git a/cmd/source/source.go b/cmd/source/source.go new file mode 100644 index 0000000..9d64a97 --- /dev/null +++ b/cmd/source/source.go @@ -0,0 +1,37 @@ +package source + +import ( + "fmt" + + "github.com/fioncat/kubewrap/cmd" + "github.com/fioncat/kubewrap/pkg/source" + "github.com/spf13/cobra" +) + +func New() *cobra.Command { + var opts Options + c := &cobra.Command{ + Use: "source", + Short: "Print source content (please don't use directly)", + Args: cobra.NoArgs, + } + + c.Flags().BoolVarP(&opts.noDelete, "no-delete", "", false, "don't delete source file") + + return cmd.Build(c, &opts) +} + +type Options struct { + noDelete bool +} + +func (o *Options) Validate(_ *cobra.Command, _ []string) error { return nil } + +func (o *Options) Run(cmdctx *cmd.Context) error { + src, err := source.Get(cmdctx.Config, o.noDelete) + if err != nil { + return err + } + fmt.Println(src) + return nil +} diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..54ef9c3 --- /dev/null +++ b/config/config.go @@ -0,0 +1,179 @@ +package config + +import ( + _ "embed" + "errors" + "fmt" + "os" + "path/filepath" + + "github.com/BurntSushi/toml" +) + +const ( + maxConfigHistoryMax = 500 + minConfigHistoryMax = 5 +) + +type Config struct { + Command string `json:"cmd" toml:"cmd"` + + Editor string `json:"editor" toml:"editor"` + + SourceFilePath string `json:"source_file_path" toml:"source_file_path"` + + Kubectl Kubectl `json:"kubectl" toml:"kubectl"` + NodeShell NodeShell `json:"nodeshell" toml:"nodeshell"` + KubeConfig KubeConfig `json:"kubeconfig" toml:"kubeconfig"` + History History `json:"history" toml:"history"` + + NamespaceAlias []NamespaceAlias `json:"namespace_alias" toml:"namespace_alias"` +} + +type Kubectl struct { + Name string `json:"name" toml:"name"` + Args []string `json:"args" toml:"args"` +} + +type NodeShell struct { + Namespace string `json:"namespace" toml:"namespace"` + Image string `json:"image" toml:"image"` + Shell []string `json:"shell" toml:"shell"` +} + +type KubeConfig struct { + Root string `json:"root" toml:"root"` + Alias map[string]string `json:"alias" toml:"alias"` +} + +type History struct { + Path string `json:"path" toml:"path"` + Max int `json:"max" toml:"max"` +} + +type NamespaceAlias struct { + Configs []string `json:"configs" toml:"configs"` + Pattern []string `json:"pattern" toml:"pattern"` + Namespaces []string `json:"namespaces" toml:"namespaces"` +} + +//go:embed defaults.toml +var defaultsData []byte + +var defaults = func() *Config { + var cfg Config + err := toml.Unmarshal(defaultsData, &cfg) + if err != nil { + panic(err) + } + return &cfg +}() + +func Load(path string, useDefault bool) (*Config, error) { + cfg, err := load(path, useDefault) + if err != nil { + return nil, err + } + + err = cfg.normalize() + if err != nil { + return nil, fmt.Errorf("normalize: %w", err) + } + + return cfg, nil +} + +func load(path string, useDefault bool) (*Config, error) { + if useDefault { + return defaults, nil + } + + var cfg Config + if path != "" { + _, err := toml.DecodeFile(path, &cfg) + if err != nil { + return nil, err + } + } else { + homeDir, err := os.UserHomeDir() + if err != nil { + return nil, err + } + path = filepath.Join(homeDir, ".config", "kubewrap", "config.toml") + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return defaults, nil + } + return nil, err + } + + err = toml.Unmarshal(data, &cfg) + if err != nil { + return nil, err + } + } + + return &cfg, nil +} + +func (c *Config) normalize() error { + if len(c.Command) == 0 { + c.Command = defaults.Command + } + + if len(c.Editor) == 0 { + c.Editor = defaults.Editor + } + c.Editor = os.ExpandEnv(c.Editor) + if c.Editor == "" { + c.Editor = "vim" + } + + if len(c.SourceFilePath) == 0 { + c.SourceFilePath = defaults.SourceFilePath + } + c.SourceFilePath = os.ExpandEnv(c.SourceFilePath) + if !filepath.IsAbs(c.SourceFilePath) { + return errors.New("`source_file_path` is not absolute") + } + + if len(c.Kubectl.Name) == 0 { + c.Kubectl.Name = defaults.Kubectl.Name + } + + if len(c.NodeShell.Namespace) == 0 { + c.NodeShell.Namespace = defaults.NodeShell.Namespace + } + if len(c.NodeShell.Image) == 0 { + c.NodeShell.Image = defaults.NodeShell.Image + } + if len(c.NodeShell.Shell) == 0 { + c.NodeShell.Shell = defaults.NodeShell.Shell + } + + if len(c.KubeConfig.Root) == 0 { + c.KubeConfig.Root = defaults.KubeConfig.Root + } + c.KubeConfig.Root = os.ExpandEnv(c.KubeConfig.Root) + if !filepath.IsAbs(c.KubeConfig.Root) { + return errors.New("`kubeconfig.root` is not absolute") + } + + if len(c.History.Path) == 0 { + c.History.Path = defaults.History.Path + } + c.History.Path = os.ExpandEnv(c.History.Path) + + if c.History.Max <= 0 { + c.History.Max = defaults.History.Max + } + if c.History.Max < minConfigHistoryMax { + return fmt.Errorf("`history.max` is too small, should be >= %d", minConfigHistoryMax) + } + if c.History.Max > maxConfigHistoryMax { + return fmt.Errorf("`history.max` is too large, should be <= %d", maxConfigHistoryMax) + } + + return nil +} diff --git a/config/defaults.toml b/config/defaults.toml new file mode 100644 index 0000000..8a2d605 --- /dev/null +++ b/config/defaults.toml @@ -0,0 +1,21 @@ +cmd = "kw" + +editor = "$EDITOR" + +source_file_path = "$HOME/.kube/.source_file" + +[kubectl] +name = "kubectl" +args = [] + +[nodeshell] +namespace = "kube-system" +image = "alpine:latest" +shell = ["bash"] + +[kubeconfig] +root = "$HOME/.kube/config" + +[history] +path = "$HOME/.kube/.history" +max = 100 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..ba73971 --- /dev/null +++ b/go.mod @@ -0,0 +1,18 @@ +module github.com/fioncat/kubewrap + +go 1.23.4 + +require ( + github.com/BurntSushi/toml v1.4.0 + github.com/fatih/color v1.18.0 + github.com/icza/backscanner v0.0.0-20241124160932-dff01ac50250 + github.com/spf13/cobra v1.8.1 +) + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/spf13/pflag v1.0.5 // indirect + golang.org/x/sys v0.25.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..0d26387 --- /dev/null +++ b/go.sum @@ -0,0 +1,27 @@ +github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0= +github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/icza/backscanner v0.0.0-20241124160932-dff01ac50250 h1:BNmTcPx0VddsU1pIgq3GoXtO8ek6tygVtj+l37Dcqo0= +github.com/icza/backscanner v0.0.0-20241124160932-dff01ac50250/go.mod h1:GYeBD1CF7AqnKZK+UCytLcY3G+UKo0ByXX/3xfdNyqQ= +github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6 h1:8UsGZ2rr2ksmEru6lToqnXgA8Mz1DP11X4zSJ159C3k= +github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6/go.mod h1:xQig96I1VNBDIWGCdTt54nHt6EeI639SmHycLYL7FkA= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= +github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= +golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/hack/hack.go b/hack/hack.go new file mode 100644 index 0000000..de220c7 --- /dev/null +++ b/hack/hack.go @@ -0,0 +1,13 @@ +package hack + +import ( + _ "embed" + "strings" +) + +//go:embed kw.sh +var bash string + +func GetBash(name string) string { + return strings.ReplaceAll(bash, "{{name}}", name) +} diff --git a/hack/kw.sh b/hack/kw.sh new file mode 100644 index 0000000..40b3dfb --- /dev/null +++ b/hack/kw.sh @@ -0,0 +1,30 @@ +function {{name}}() { + local NEED_SOURCE_CODE=302 + local DEFAULT_EXECUTABLE_PATH="kubewrap" + declare -a opts + + while test $# -gt 0; do + opts+=( "$1" ) + shift + done + + local executable_path + if [[ -n "$KUBEWRAP_EXECUTABLE_PATH" ]]; then + executable_path="$KUBEWRAP_EXECUTABLE_PATH" + else + executable_path="$DEFAULT_EXECUTABLE_PATH" + fi + + $executable_path "${opts[@]}" + local exit_code=$? + if [[ $exit_code -ne 0 ]]; then + return $exit_code + fi + local source_content=$($executable_path source) + if [[ $? -ne 0 ]]; then + return 1 + fi + source <(echo "$source_content") + + return +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..0756c7b --- /dev/null +++ b/main.go @@ -0,0 +1,82 @@ +package main + +import ( + "errors" + "fmt" + "os" + + "github.com/fioncat/kubewrap/cmd/config" + "github.com/fioncat/kubewrap/cmd/cp" + "github.com/fioncat/kubewrap/cmd/exec" + initcmd "github.com/fioncat/kubewrap/cmd/init" + "github.com/fioncat/kubewrap/cmd/login" + "github.com/fioncat/kubewrap/cmd/ns" + "github.com/fioncat/kubewrap/cmd/show" + sourcecmd "github.com/fioncat/kubewrap/cmd/source" + "github.com/fioncat/kubewrap/pkg/fzf" + "github.com/spf13/cobra" +) + +var ( + Version string = "N/A" + BuildType string = "N/A" + BuildCommit string = "N/A" + BuildTime string = "N/A" +) + +func newCommand() *cobra.Command { + var printBuildInfo bool + + c := &cobra.Command{ + Use: "kubewrap", + Short: "A wrapper for kubectl, to add more tools", + + Version: Version, + + SilenceErrors: true, + SilenceUsage: true, + + // Completion is impletemented by `init` command, so disable this + CompletionOptions: cobra.CompletionOptions{ + DisableDefaultCmd: true, + }, + + RunE: func(cmd *cobra.Command, args []string) error { + if printBuildInfo { + fmt.Printf("version: %s\n", Version) + fmt.Printf("type: %s\n", BuildType) + fmt.Printf("commit: %s\n", BuildCommit) + fmt.Printf("time: %s\n", BuildTime) + return nil + + } + return cmd.Usage() + }, + } + + c.Flags().BoolVarP(&printBuildInfo, "build", "b", false, "print build information and exit") + + return c +} + +func main() { + c := newCommand() + + c.AddCommand(config.New()) + c.AddCommand(cp.New()) + c.AddCommand(exec.New()) + c.AddCommand(initcmd.New()) + c.AddCommand(login.New()) + c.AddCommand(ns.New()) + c.AddCommand(show.New()) + c.AddCommand(sourcecmd.New()) + + err := c.Execute() + if err != nil { + if errors.Is(err, fzf.ErrCanceled) { + os.Exit(fzf.ExitCodeCanceled) + } + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } +} diff --git a/pkg/dirs/dirs.go b/pkg/dirs/dirs.go new file mode 100644 index 0000000..6b5ceb4 --- /dev/null +++ b/pkg/dirs/dirs.go @@ -0,0 +1,26 @@ +package dirs + +import ( + "fmt" + "os" +) + +func EnsureCreate(path string) error { + stat, err := os.Stat(path) + if err != nil { + if os.IsNotExist(err) { + err = os.MkdirAll(path, os.ModePerm) + if err != nil { + return fmt.Errorf("mkdir dir: %w", err) + } + return nil + } + return fmt.Errorf("check dir stat: %w", err) + } + + if !stat.IsDir() { + return fmt.Errorf("%s is not a directory", path) + } + + return nil +} diff --git a/pkg/edit/edit.go b/pkg/edit/edit.go new file mode 100644 index 0000000..bf531fc --- /dev/null +++ b/pkg/edit/edit.go @@ -0,0 +1,54 @@ +package edit + +import ( + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "reflect" + + "github.com/fioncat/kubewrap/config" +) + +func Edit(cfg *config.Config, initData []byte) ([]byte, error) { + path, err := createEditFile(initData) + if err != nil { + return nil, err + } + + cmd := exec.Command(cfg.Editor, path) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Stdin = os.Stdin + + err = cmd.Run() + if err != nil { + return nil, fmt.Errorf("run editor: %w", err) + } + + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read edit file: %w", err) + } + + err = os.Remove(path) + if err != nil { + return nil, fmt.Errorf("remove edit file: %w", err) + } + + if reflect.DeepEqual(data, initData) { + return nil, errors.New("edit content not changed") + } + + return data, nil +} + +func createEditFile(initData []byte) (string, error) { + path := filepath.Join(os.TempDir(), "kubewrap_edit.yaml") + err := os.WriteFile(path, initData, 0644) + if err != nil { + return "", fmt.Errorf("write edit file: %w", err) + } + return path, nil +} diff --git a/pkg/fzf/fzf.go b/pkg/fzf/fzf.go new file mode 100644 index 0000000..26b1af6 --- /dev/null +++ b/pkg/fzf/fzf.go @@ -0,0 +1,53 @@ +package fzf + +import ( + "bytes" + "errors" + "fmt" + "os" + "os/exec" + "strings" +) + +var ErrCanceled = errors.New("fzf: canceled by user") + +const ExitCodeCanceled = 130 + +func Search(items []string) (int, error) { + var inputBuf bytes.Buffer + inputBuf.Grow(len(items)) + for _, item := range items { + inputBuf.WriteString(item + "\n") + } + + var outputBuf bytes.Buffer + cmd := exec.Command("fzf") + cmd.Stdin = &inputBuf + cmd.Stderr = os.Stderr + cmd.Stdout = &outputBuf + + err := cmd.Run() + if err != nil { + if exitError, ok := err.(*exec.ExitError); ok { + code := exitError.ExitCode() + switch code { + case ExitCodeCanceled: + return 0, ErrCanceled + + default: + return 0, fmt.Errorf("fzf exited with code %d", code) + } + } + return 0, fmt.Errorf("fzf exited with error: %w", err) + } + + result := outputBuf.String() + result = strings.TrimSpace(result) + for idx, item := range items { + if item == result { + return idx, nil + } + } + + return 0, fmt.Errorf("fzf: cannot find %q", result) +} diff --git a/pkg/history/history.go b/pkg/history/history.go new file mode 100644 index 0000000..42f7bde --- /dev/null +++ b/pkg/history/history.go @@ -0,0 +1,20 @@ +package history + +type Manager interface { + Add(name, namespace string) + GetLastName(current string) *string + GetLastNamespace(name, current string) *string + + DeleteByName(name string) + DeleteAll() + + List() []*Record + + Save() error +} + +type Record struct { + Timestamp int64 + Name string + Namespace string +} diff --git a/pkg/history/manager.go b/pkg/history/manager.go new file mode 100644 index 0000000..d108a17 --- /dev/null +++ b/pkg/history/manager.go @@ -0,0 +1,183 @@ +package history + +import ( + "fmt" + "io" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/fioncat/kubewrap/pkg/dirs" + "github.com/icza/backscanner" +) + +type manager struct { + path string + records []*Record +} + +func NewManager(path string, max int) (Manager, error) { + mgr := &manager{path: path} + err := mgr.scan(max) + if err != nil { + return nil, err + } + return mgr, nil +} + +func (m *manager) scan(max int) error { + file, err := os.Open(m.path) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return fmt.Errorf("open history file: %w", err) + } + defer file.Close() + + stat, err := file.Stat() + if err != nil { + return err + } + + scanner := backscanner.New(file, int(stat.Size())) + for { + line, _, err := scanner.Line() + if err != nil { + if err == io.EOF { + break + } + return fmt.Errorf("scan history file: %w", err) + } + + line = strings.TrimSpace(line) + if len(line) == 0 { + continue + } + + record, ok := m.parse(line) + if !ok { + continue + } + m.records = append(m.records, record) + if len(m.records) >= max { + break + } + } + + return nil +} + +func (m *manager) parse(line string) (*Record, bool) { + fields := strings.Fields(line) + + if len(fields) != 2 && len(fields) != 3 { + return nil, false + } + + timestamp, err := strconv.ParseInt(fields[0], 10, 64) + if err != nil { + return nil, false + } + if timestamp == 0 { + return nil, false + } + + name := fields[1] + if name == "" { + return nil, false + } + + var namespace string + if len(fields) == 3 { + namespace = fields[2] + } + + return &Record{ + Timestamp: timestamp, + Name: name, + Namespace: namespace, + }, true +} + +func (m *manager) Add(name, namespace string) { + m.records = append(m.records, &Record{ + Timestamp: time.Now().Unix(), + Name: name, + Namespace: namespace, + }) +} + +func (m *manager) GetLastName(current string) *string { + for i := len(m.records) - 1; i >= 0; i-- { + if m.records[i].Name != current { + return &m.records[i].Name + } + } + return nil +} + +func (m *manager) GetLastNamespace(name, current string) *string { + for i := len(m.records) - 1; i >= 0; i-- { + record := m.records[i] + if record.Namespace == "" { + continue + } + if record.Name == name && record.Namespace != current { + return &record.Namespace + } + } + return nil +} + +func (m *manager) DeleteByName(name string) { + newRecords := make([]*Record, 0) + for _, record := range m.records { + if record.Name == name { + continue + } + newRecords = append(newRecords, record) + } + m.records = newRecords +} + +func (m *manager) DeleteAll() { + m.records = nil +} + +func (m *manager) List() []*Record { + return m.records +} + +func (m *manager) Save() error { + err := dirs.EnsureCreate(filepath.Dir(m.path)) + if err != nil { + return fmt.Errorf("ensure history directory: %w", err) + } + + file, err := os.Create(m.path) + if err != nil { + return fmt.Errorf("create history file: %w", err) + } + defer file.Close() + + for _, record := range m.records { + sb := strings.Builder{} + sb.WriteString(fmt.Sprint(record.Timestamp)) + sb.WriteByte(' ') + sb.WriteString(record.Name) + if record.Namespace != "" { + sb.WriteByte(' ') + sb.WriteString(record.Namespace) + } + sb.WriteByte('\n') + _, err = file.WriteString(sb.String()) + if err != nil { + return fmt.Errorf("write history file: %w", err) + } + } + + return nil +} diff --git a/pkg/kubeconfig/kubeconfig.go b/pkg/kubeconfig/kubeconfig.go new file mode 100644 index 0000000..26751e8 --- /dev/null +++ b/pkg/kubeconfig/kubeconfig.go @@ -0,0 +1,81 @@ +package kubeconfig + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +const ( + envName = "KUBECONFIG_NAME" + envPath = "KUBECONFIG" // this is used by `kubectl` + envNamespace = "KUBECONFIG_NAMESPACE" +) + +const sourceTemplate = ` +export %s="%s" +export %s="%s" +export %s="%s" +alias k='kubectl%s' +alias kk='k9s%s' +` + +const unsetTemplate = ` +export %s="" +export %s="" +export %s="" +alias k='kubectl' +alias kk='k9s' +` + +type Manager interface { + Put(name string, data []byte) (*KubeConfig, error) + Delete(name string) error + DeleteAll() error + + Current() (*KubeConfig, bool) + Get(name string) (*KubeConfig, bool) + List() []*KubeConfig +} + +type KubeConfig struct { + root string + + Name string + Alias string +} + +func (c *KubeConfig) Path() string { + name := c.Name + if c.Alias != "" { + name = c.Alias + } + return filepath.Join(c.root, name) +} + +func (c *KubeConfig) GenerateSource(ns string) string { + var nsSet string + if len(ns) > 0 { + nsSet = fmt.Sprintf(" -n %s", ns) + } + source := fmt.Sprintf(sourceTemplate, envName, c.Name, envPath, c.Path(), envNamespace, ns, nsSet, nsSet) + return strings.TrimSpace(source) +} + +func (c *KubeConfig) String() string { + s := c.Name + if c.Alias != "" { + s = fmt.Sprintf("%s (alias to %s)", s, c.Alias) + } + return s +} + +func UnsetSource() string { + source := fmt.Sprintf(unsetTemplate, envName, envPath, envNamespace) + return strings.TrimSpace(source) +} + +func GetCurrentNamespace() string { + return os.Getenv(envNamespace) +} diff --git a/pkg/kubeconfig/manager.go b/pkg/kubeconfig/manager.go new file mode 100644 index 0000000..e7ad803 --- /dev/null +++ b/pkg/kubeconfig/manager.go @@ -0,0 +1,199 @@ +package kubeconfig + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "sort" + + "github.com/fioncat/kubewrap/pkg/dirs" +) + +type manager struct { + root string + + current *KubeConfig + configs map[string]*KubeConfig +} + +func NewManager(root string, alias map[string]string) (Manager, error) { + mgr := &manager{ + root: root, + current: nil, + configs: make(map[string]*KubeConfig), + } + err := mgr.init(alias) + if err != nil { + return nil, err + } + return mgr, nil +} + +func (m *manager) init(alias map[string]string) error { + err := filepath.Walk(m.root, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + return nil + } + + name, err := filepath.Rel(m.root, path) + if err != nil { + // This error should not happen in normal case. So we add a prefix to mark it. + // If this happens, it means there is a bug in the code. + return fmt.Errorf("[Internal] bad kubeconfig path %q, not in expected position", path) + } + + m.configs[name] = &KubeConfig{ + root: m.root, + + Name: name, + Alias: "", + } + return nil + }) + if err != nil && !os.IsNotExist(err) { + return fmt.Errorf("read kubeconfig root: %w", err) + } + + for alias, target := range alias { + _, ok := m.configs[alias] + if ok { + return fmt.Errorf("alias %q is already used by a kubeconfig", alias) + } + _, ok = m.configs[target] + if !ok { + return fmt.Errorf("alias %q target %q not found", alias, target) + } + m.configs[alias] = &KubeConfig{ + root: m.root, + + Name: alias, + Alias: target, + } + } + + currentName := os.Getenv(envName) + if currentName != "" { + currentConfig, ok := m.configs[currentName] + if !ok { + return fmt.Errorf("current kubeconfig %q not found, please unuse it", currentName) + } + m.current = currentConfig + } + + return nil +} + +func (m *manager) Put(name string, data []byte) (*KubeConfig, error) { + config, ok := m.configs[name] + if !ok { + config = &KubeConfig{ + root: m.root, + Name: name, + } + m.configs[name] = config + } + path := config.Path() + err := dirs.EnsureCreate(filepath.Dir(path)) + if err != nil { + return nil, fmt.Errorf("ensure kubeconfig dir: %w", err) + } + + err = os.WriteFile(path, data, 0644) + if err != nil { + return nil, fmt.Errorf("write kubeconfig file: %w", err) + } + return config, nil +} + +func (m *manager) Delete(name string) error { + if m.current != nil && m.current.Name == name { + return errors.New("cannot delete current kubeconfig, please unuse it first") + } + for _, kubeconfig := range m.configs { + if kubeconfig.Alias == name { + return errors.New("this kubeconfig is used by an alias, please delete the alias in config file first") + } + } + + config, ok := m.configs[name] + if !ok { + return fmt.Errorf("kubeconfig %q not found", name) + } + + if config.Alias != "" { + return errors.New("cannot delete an alias kubeconfig, please delete it from config file") + } + + path := config.Path() + err := os.Remove(path) + if err != nil && !os.IsNotExist(err) { + return fmt.Errorf("delete kubeconfig file: %w", err) + } + + var dir string + for { + dir = filepath.Dir(path) + if dir == m.root { + break + } + + ents, err := os.ReadDir(dir) + if err != nil { + return err + } + if len(ents) > 0 { + break + } + + err = os.Remove(dir) + if err != nil { + return err + } + } + + delete(m.configs, name) + return nil +} + +func (m *manager) DeleteAll() error { + if m.current != nil { + return errors.New("cannot delete all kubeconfigs, please unuse the current kubeconfig first") + } + var hasAlias bool + for _, kubeconfig := range m.configs { + if kubeconfig.Alias != "" { + hasAlias = true + break + } + } + if hasAlias { + return errors.New("cannot delete all kubeconfigs, please delete the alias kubeconfigs first") + } + + m.configs = nil + return os.RemoveAll(m.root) +} + +func (m *manager) Current() (*KubeConfig, bool) { + return m.current, m.current != nil +} + +func (m *manager) Get(name string) (*KubeConfig, bool) { + config, ok := m.configs[name] + return config, ok +} + +func (m *manager) List() []*KubeConfig { + var list []*KubeConfig + for _, config := range m.configs { + list = append(list, config) + } + sort.Slice(list, func(i, j int) bool { + return list[i].Name < list[j].Name + }) + return list +} diff --git a/pkg/kubectl/cmd.go b/pkg/kubectl/cmd.go new file mode 100644 index 0000000..e28fe38 --- /dev/null +++ b/pkg/kubectl/cmd.go @@ -0,0 +1,164 @@ +package kubectl + +import ( + "bytes" + "errors" + "fmt" + "io" + "os" + "os/exec" + "strings" +) + +type cmdKubectl struct { + name string + args []string +} + +func NewCommand(name string, args []string) Kubectl { + return &cmdKubectl{name: name, args: args} +} + +func (k *cmdKubectl) CheckNode(name string) error { + nodes, err := k.ListNodes() + if err != nil { + return err + } + + for _, node := range nodes { + if node.Name == name { + return nil + } + } + + return newNotFoundError("node", name) +} + +func (k *cmdKubectl) ListNodes() ([]*Node, error) { + lines, err := k.lines("get", "nodes", "--no-headers") + if err != nil { + return nil, err + } + + nodes := make([]*Node, 0, len(lines)) + for _, line := range lines { + fields := strings.Fields(line) + if len(fields) == 0 { + continue + } + name := fields[0] + desc := strings.TrimPrefix(line, name) + nodes = append(nodes, &Node{ + Name: name, + Description: strings.TrimSpace(desc), + }) + } + return nodes, nil +} + +func (k *cmdKubectl) CheckNamespace(name string) error { + namespaces, err := k.ListNamespaces() + if err != nil { + return err + } + for _, ns := range namespaces { + if ns == name { + return nil + } + } + return newNotFoundError("namespace", name) +} + +func (k *cmdKubectl) ListNamespaces() ([]string, error) { + output, err := k.output(nil, "get", "namespaces", "-o", "jsonpath={.items[*].metadata.name}") + if err != nil { + return nil, err + } + return strings.Fields(output), nil +} + +func (k *cmdKubectl) Apply(data []byte) error { + buf := bytes.NewBuffer(data) + _, err := k.output(buf, "apply", "-f", "-") + if err != nil { + return err + } + return nil +} + +func (k *cmdKubectl) DeletePod(namespace, name string) error { + _, err := k.output(nil, "delete", "-n", namespace, "pod", name) + return err +} + +func (k *cmdKubectl) GetPodStatus(namespace, name string) (string, error) { + status, err := k.output(nil, "get", "-n", namespace, "pod", name, "-o", "jsonpath={.status.phase}") + if err != nil { + return "", err + } + if len(status) == 0 { + return "", errors.New("status returned by kubectl is empty, this is not expected") + } + return status, nil +} + +func (k *cmdKubectl) Exec(namespace, name string, cmd []string) error { + args := []string{"exec", "-it", "-n", namespace, name, "--"} + args = append(args, cmd...) + return k.exec(args, true, nil, nil) +} + +func (k *cmdKubectl) Copy(namespace, src, dest string) error { + args := []string{"cp", "-n", namespace, src, dest} + _, err := k.output(nil, args...) + return err +} + +func (k *cmdKubectl) lines(args ...string) ([]string, error) { + output, err := k.output(nil, args...) + if err != nil { + return nil, err + } + + lines := make([]string, 0) + for _, line := range strings.Split(output, "\n") { + line = strings.TrimSpace(line) + if len(line) == 0 { + continue + } + lines = append(lines, line) + } + return lines, nil +} + +func (k *cmdKubectl) output(in io.Reader, args ...string) (string, error) { + buf := bytes.NewBuffer(nil) + err := k.exec(args, false, in, buf) + if err != nil { + return "", err + } + output := buf.String() + return strings.TrimSpace(output), nil +} + +func (k *cmdKubectl) exec(args []string, tty bool, in io.Reader, out io.Writer) error { + if len(k.args) > 0 { + args = append(k.args, args...) + } + cmd := exec.Command(k.name, args...) + cmd.Stderr = os.Stderr + if tty { + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + } else { + cmd.Stdin = in + cmd.Stdout = out + } + + err := cmd.Run() + if err != nil { + return fmt.Errorf("kubectl command exited with bad status: %s %s", k.name, strings.Join(args, " ")) + } + + return nil +} diff --git a/pkg/kubectl/kubectl.go b/pkg/kubectl/kubectl.go new file mode 100644 index 0000000..4e1d080 --- /dev/null +++ b/pkg/kubectl/kubectl.go @@ -0,0 +1,44 @@ +package kubectl + +import "fmt" + +type Kubectl interface { + CheckNode(name string) error + ListNodes() ([]*Node, error) + + CheckNamespace(name string) error + ListNamespaces() ([]string, error) + + Apply(data []byte) error + DeletePod(namespace, name string) error + + GetPodStatus(namespace, name string) (string, error) + + Exec(namespace, name string, cmd []string) error + Copy(namespace, src, dest string) error +} + +type Node struct { + Name string + Description string +} + +type NotFoundError struct { + resourceType string + name string +} + +func newNotFoundError(resourceType, name string) error { + return &NotFoundError{resourceType: resourceType, name: name} +} + +func (e *NotFoundError) Error() string { + return fmt.Sprintf("cannot find %s %q", e.resourceType, e.name) +} + +func IsNotFound(err error) bool { + if _, ok := err.(*NotFoundError); ok { + return true + } + return false +} diff --git a/pkg/nodeshell/nodeshell.go b/pkg/nodeshell/nodeshell.go new file mode 100644 index 0000000..8eb27b1 --- /dev/null +++ b/pkg/nodeshell/nodeshell.go @@ -0,0 +1,155 @@ +package nodeshell + +import ( + "bytes" + _ "embed" + "errors" + "fmt" + "math/rand" + "strings" + "time" + + "github.com/fioncat/kubewrap/pkg/kubectl" +) + +//go:embed nodeshell.yaml +var yamlData []byte + +const ( + checkPodReadyInterval = time.Millisecond * 300 + checkPodReadyTimeout = time.Second * 10 +) + +func generateYAML(namespace, name, node, image string) []byte { + data := bytes.ReplaceAll(yamlData, []byte("{{name}}"), []byte(name)) + data = bytes.ReplaceAll(data, []byte("{{namespace}}"), []byte(namespace)) + data = bytes.ReplaceAll(data, []byte("{{node}}"), []byte(node)) + data = bytes.ReplaceAll(data, []byte("{{image}}"), []byte(image)) + return data +} + +type NodeShell struct { + node string + + podName string + podNamespace string + + image string + + shell []string + + kubectl kubectl.Kubectl +} + +type CopyPath struct { + Path string + Remote bool +} + +func New(kubectl kubectl.Kubectl, node, namespace, image string, shell []string) (*NodeShell, error) { + err := kubectl.CheckNode(node) + if err != nil { + return nil, err + } + + err = kubectl.CheckNamespace(namespace) + if err != nil { + return nil, err + } + + safeNode := strings.ReplaceAll(node, ".", "-") + podName := fmt.Sprintf("nodeshell-%s-%s", safeNode, genRandomName(5)) + + ns := &NodeShell{ + node: node, + podName: podName, + podNamespace: namespace, + image: image, + shell: shell, + kubectl: kubectl, + } + err = ns.start() + if err != nil { + return nil, err + } + + return ns, nil +} + +func (n *NodeShell) start() error { + yaml := generateYAML(n.podNamespace, n.podName, n.node, n.image) + err := n.kubectl.Apply(yaml) + if err != nil { + return fmt.Errorf("nodeshell: create pod: %w", err) + } + + checkInterval := time.NewTicker(checkPodReadyInterval) + checkTimeout := time.NewTimer(checkPodReadyTimeout) + + var status string = "Unknown" + for { + select { + case <-checkInterval.C: + status, err = n.kubectl.GetPodStatus(n.podNamespace, n.podName) + if err != nil { + return fmt.Errorf("nodeshell check ready: get pod status: %w", err) + } + + if status == "Running" { + return nil + } + + case <-checkTimeout.C: + err = n.Close() + if err != nil { + return fmt.Errorf("delete pod after wait nodeshell pod ready timeout: %w", err) + } + + return fmt.Errorf("wait nodeshell pod ready timeout after %v, please check its status (the last status is %q)", checkPodReadyTimeout, status) + } + } +} + +func (n *NodeShell) Login() error { + return n.kubectl.Exec(n.podNamespace, n.podName, n.shell) +} + +func (n *NodeShell) Exec(cmd []string) error { + return n.kubectl.Exec(n.podNamespace, n.podName, cmd) +} + +func (n *NodeShell) Copy(src, dest CopyPath) error { + if src.Remote && dest.Remote { + return errors.New("copy: both src and dest are remote") + } + if !src.Remote && !dest.Remote { + return errors.New("copy: both src and dest are local") + } + + srcPath := src.Path + if src.Remote { + srcPath = fmt.Sprintf("%s:%s", n.podName, src.Path) + } + + destPath := dest.Path + if dest.Remote { + destPath = fmt.Sprintf("%s:%s", n.podName, dest.Path) + } + + return n.kubectl.Copy(n.podNamespace, srcPath, destPath) +} + +func (n *NodeShell) Close() error { + return n.kubectl.DeletePod(n.podNamespace, n.podName) +} + +const randomCharset = "abcdefghijklmnopqrstuvwxyz0123456789" + +func genRandomName(length int) string { + s := make([]byte, length) + for i := 0; i < length; i++ { + s[i] = randomCharset[rand.Intn(len(randomCharset))] + } + + return string(s) +} diff --git a/pkg/nodeshell/nodeshell.yaml b/pkg/nodeshell/nodeshell.yaml new file mode 100644 index 0000000..7555ba9 --- /dev/null +++ b/pkg/nodeshell/nodeshell.yaml @@ -0,0 +1,20 @@ +apiVersion: v1 +kind: Pod +metadata: + name: "{{name}}" + namespace: "{{namespace}}" + labels: + app: nodeshell +spec: + nodeName: "{{node}}" + hostNetwork: true + hostPID: true + hostIPC: true + containers: + - name: nodeshell + image: "{{image}}" + command: ["nsenter"] + args: ["-t", "1", "-m", "-u", "-i", "-n", "sleep", "infinity"] + workingDir: "/root" + securityContext: + privileged: true diff --git a/pkg/source/source.go b/pkg/source/source.go new file mode 100644 index 0000000..6a331fe --- /dev/null +++ b/pkg/source/source.go @@ -0,0 +1,46 @@ +package source + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/fioncat/kubewrap/config" + "github.com/fioncat/kubewrap/pkg/dirs" +) + +func Apply(cfg *config.Config, src string) error { + path := cfg.SourceFilePath + err := dirs.EnsureCreate(filepath.Dir(path)) + if err != nil { + return err + } + + err = os.WriteFile(path, []byte(src), 0644) + if err != nil { + return fmt.Errorf("write source file: %w", err) + } + + return nil +} + +func Get(cfg *config.Config, noDelete bool) (string, error) { + path := cfg.SourceFilePath + + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return "", nil + } + return "", fmt.Errorf("read source file: %w", err) + } + + if !noDelete { + err = os.Remove(path) + if err != nil { + return "", fmt.Errorf("delete source file: %w", err) + } + } + + return string(data), nil +} diff --git a/pkg/term/term.go b/pkg/term/term.go new file mode 100644 index 0000000..7c2d183 --- /dev/null +++ b/pkg/term/term.go @@ -0,0 +1,49 @@ +package term + +import ( + "encoding/json" + "fmt" + "time" + + "github.com/fatih/color" + "github.com/fioncat/kubewrap/pkg/fzf" +) + +func PrintJson(v any) error { + data, err := json.MarshalIndent(v, "", " ") + if err != nil { + return err + } + + fmt.Println(string(data)) + return nil +} + +func PrintHint(format string, args ...any) { + s := fmt.Sprintf(format, args...) + hint := color.New(color.Bold).Sprint(s) + prefix := color.New(color.Bold, color.FgGreen).Sprint("==>") + fmt.Println(prefix, hint) +} + +func Confirm(skip bool, format string, args ...any) error { + if skip { + return nil + } + hint := fmt.Sprintf(format, args...) + fmt.Printf("%s? (y/n) ", hint) + + var resp string + fmt.Scanf("%s", &resp) + + if resp == "y" { + return nil + } + + return fzf.ErrCanceled +} + +func FormatTimestamp(ts int64) string { + t := time.Unix(ts, 0) + return t.Format("2006-01-02 15:04:05") +}