diff --git a/cmd/completion.go b/cmd/completion.go index e4968b4..b4ccaa3 100644 --- a/cmd/completion.go +++ b/cmd/completion.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "github.com/fioncat/kubewrap/config" "github.com/fioncat/kubewrap/pkg/kubeconfig" @@ -78,6 +79,88 @@ func CompleteNodes(c *cobra.Command) ([]*kubectl.Node, bool) { return nodes, true } +var resourceTypeCompletionList = []string{ + "deploy/", "sts/", "ds/", "job/", "cronjob/", +} + +func CompleteResource(c *cobra.Command, toComplete string) ([]string, cobra.ShellCompDirective) { + return completeResource(c, toComplete, false) +} + +func completeResource(c *cobra.Command, toComplete string, fromContainer bool) ([]string, cobra.ShellCompDirective) { + fields := strings.Split(toComplete, "/") + switch len(fields) { + case 0, 1: + return resourceTypeCompletionList, cobra.ShellCompDirectiveNoSpace + + case 2: + resourceType := fields[0] + namespace := getCurrentNamespace() + k := getCompleteKubectl(c) + if k == nil { + return nil, cobra.ShellCompDirectiveError + } + + rs, err := k.ListResources(resourceType, namespace) + if err != nil { + WriteCompleteLogs("List resources failed: %v", err) + return nil, cobra.ShellCompDirectiveError + } + + items := make([]string, 0, len(rs)) + for _, r := range rs { + item := fmt.Sprintf("%s/%s", resourceType, r.Name) + if fromContainer { + item = item + "/" + } + items = append(items, item) + } + + flag := cobra.ShellCompDirectiveNoFileComp + if fromContainer { + flag = cobra.ShellCompDirectiveNoSpace + } + + return items, flag + } + + return nil, cobra.ShellCompDirectiveNoFileComp +} + +func CompleteContainer(c *cobra.Command, toComplete string) ([]string, cobra.ShellCompDirective) { + fields := strings.Split(toComplete, "/") + switch len(fields) { + case 0, 1, 2: + return completeResource(c, toComplete, true) + + case 3: + r := &kubectl.Resource{ + Type: fields[0], + Namespace: getCurrentNamespace(), + Name: fields[1], + } + k := getCompleteKubectl(c) + if k == nil { + return nil, cobra.ShellCompDirectiveError + } + + cs, err := k.ListContainers(r) + if err != nil { + WriteCompleteLogs("List containers failed: %v", err) + return nil, cobra.ShellCompDirectiveError + } + + items := make([]string, 0, len(cs)) + for _, container := range cs { + items = append(items, fmt.Sprintf("%s/%s/%s", r.Type, r.Name, container.ContainerName)) + } + + return items, cobra.ShellCompDirectiveNoFileComp + } + + return nil, cobra.ShellCompDirectiveNoFileComp +} + func SingleNodeCompletionFunc(c *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { if len(args) != 0 { return nil, cobra.ShellCompDirectiveNoFileComp diff --git a/cmd/restart/completion.go b/cmd/restart/completion.go new file mode 100644 index 0000000..31a2277 --- /dev/null +++ b/cmd/restart/completion.go @@ -0,0 +1,15 @@ +package restart + +import ( + "github.com/fioncat/kubewrap/cmd" + "github.com/spf13/cobra" +) + +func CompletionFunc(c *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + switch len(args) { + case 0: + return cmd.CompleteResource(c, toComplete) + } + + return nil, cobra.ShellCompDirectiveNoFileComp +} diff --git a/cmd/restart/restart.go b/cmd/restart/restart.go new file mode 100644 index 0000000..7bc3b74 --- /dev/null +++ b/cmd/restart/restart.go @@ -0,0 +1,42 @@ +package restart + +import ( + "github.com/fioncat/kubewrap/cmd" + "github.com/fioncat/kubewrap/pkg/term" + "github.com/spf13/cobra" +) + +func New() *cobra.Command { + var opts Options + c := &cobra.Command{ + Use: "restart ", + Short: "Restart a resource", + Args: cobra.ExactArgs(1), + + ValidArgsFunction: CompletionFunc, + } + return cmd.Build(c, &opts) +} + +type Options struct { + query string +} + +func (o *Options) Validate(_ *cobra.Command, args []string) error { + o.query = args[0] + return nil +} + +func (o *Options) Run(cmdctx *cmd.Context) error { + r, err := cmd.SelectResource(cmdctx.Kubectl, o.query) + if err != nil { + return err + } + err = cmdctx.Kubectl.RolloutRestart(r) + if err != nil { + return err + } + + term.PrintHint("Restarted %v", r) + return nil +} diff --git a/cmd/scale/completion.go b/cmd/scale/completion.go new file mode 100644 index 0000000..1449867 --- /dev/null +++ b/cmd/scale/completion.go @@ -0,0 +1,15 @@ +package scale + +import ( + "github.com/fioncat/kubewrap/cmd" + "github.com/spf13/cobra" +) + +func CompletionFunc(c *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + switch len(args) { + case 0: + return cmd.CompleteResource(c, toComplete) + } + + return nil, cobra.ShellCompDirectiveNoFileComp +} diff --git a/cmd/scale/scale.go b/cmd/scale/scale.go new file mode 100644 index 0000000..b1af071 --- /dev/null +++ b/cmd/scale/scale.go @@ -0,0 +1,57 @@ +package scale + +import ( + "errors" + "fmt" + "strconv" + + "github.com/fioncat/kubewrap/cmd" + "github.com/fioncat/kubewrap/pkg/term" + "github.com/spf13/cobra" +) + +func New() *cobra.Command { + var opts Options + c := &cobra.Command{ + Use: "scale ", + Short: "Scale the replicas of a resource", + Args: cobra.ExactArgs(2), + + ValidArgsFunction: CompletionFunc, + } + return cmd.Build(c, &opts) +} + +type Options struct { + query string + replicas int +} + +func (o *Options) Validate(_ *cobra.Command, args []string) error { + o.query = args[0] + + var err error + o.replicas, err = strconv.Atoi(args[1]) + if err != nil { + return fmt.Errorf("replicas must be an integer: %w", err) + } + if o.replicas < 0 { + return errors.New("replicas must be greater than or equal to 0") + } + + return nil +} + +func (o *Options) Run(cmdctx *cmd.Context) error { + r, err := cmd.SelectResource(cmdctx.Kubectl, o.query) + if err != nil { + return err + } + err = cmdctx.Kubectl.Scale(r, o.replicas) + if err != nil { + return err + } + + term.PrintHint("Scaled %v to %d", r, o.replicas) + return nil +} diff --git a/cmd/select.go b/cmd/select.go new file mode 100644 index 0000000..2b44368 --- /dev/null +++ b/cmd/select.go @@ -0,0 +1,170 @@ +package cmd + +import ( + "fmt" + "strings" + + "github.com/fioncat/kubewrap/pkg/fzf" + "github.com/fioncat/kubewrap/pkg/kubeconfig" + "github.com/fioncat/kubewrap/pkg/kubectl" +) + +func SelectResource(k kubectl.Kubectl, query string) (*kubectl.Resource, error) { + fields := strings.Split(query, "/") + if len(fields) != 1 && len(fields) != 2 { + return nil, fmt.Errorf("invalid resource query %q, should be '[/name]'", query) + } + + resourceType := fields[0] + if resourceType == "" { + return nil, fmt.Errorf("invalid resource query %q, type is required", query) + } + + namespace := getCurrentNamespace() + + var name string + if len(fields) == 2 { + name = fields[1] + } + if name == "" { + rs, err := k.ListResources(resourceType, namespace) + if err != nil { + return nil, err + } + + items := make([]string, 0, len(rs)) + for _, r := range rs { + items = append(items, r.Name) + } + var idx int + idx, err = fzf.Search(items) + if err != nil { + return nil, err + } + + name = items[idx] + } + + return &kubectl.Resource{ + Type: resourceType, + Namespace: namespace, + Name: name, + }, nil +} + +type selectContainerItem struct { + key string + + container *kubectl.Container +} + +func SelectContainer(k kubectl.Kubectl, query string) (*kubectl.Container, error) { + fields := strings.Split(query, "/") + if len(fields) != 1 && len(fields) != 2 && len(fields) != 3 { + return nil, fmt.Errorf("invalid container query %q, should be '[//]'", query) + } + + resourceType := fields[0] + + var name string + if len(fields) > 1 { + name = fields[1] + } + + namespace := getCurrentNamespace() + + if name == "" { + return selectContainerByResourceType(k, resourceType, namespace) + } + + var containerName string + if len(fields) == 3 { + containerName = fields[2] + } + if containerName != "" { + return &kubectl.Container{ + Resource: kubectl.Resource{ + Type: resourceType, + Namespace: namespace, + Name: name, + }, + ContainerName: containerName, + }, nil + } + + r := &kubectl.Resource{ + Type: resourceType, + Namespace: namespace, + Name: name, + } + cs, err := k.ListContainers(r) + if err != nil { + return nil, err + } + if len(cs) == 0 { + return nil, fmt.Errorf("no containers for %s %s/%s", r.Type, namespace, r.Name) + } + if len(cs) == 1 { + return cs[0], nil + } + + items := make([]string, 0, len(cs)) + for _, c := range cs { + items = append(items, c.ContainerName) + } + idx, err := fzf.Search(items) + if err != nil { + return nil, err + } + return cs[idx], nil +} + +func selectContainerByResourceType(k kubectl.Kubectl, resourceType, namespace string) (*kubectl.Container, error) { + rs, err := k.ListResources(resourceType, namespace) + if err != nil { + return nil, err + } + citems := make([]*selectContainerItem, 0, len(rs)) + for _, r := range rs { + var cs []*kubectl.Container + cs, err = k.ListContainers(r) + if err != nil { + return nil, err + } + if len(cs) == 0 { + continue + } + if len(cs) == 1 { + citems = append(citems, &selectContainerItem{ + key: r.Name, + container: cs[0], + }) + continue + } + for _, c := range cs { + citems = append(citems, &selectContainerItem{ + key: fmt.Sprintf("%s/%s", r.Name, c.ContainerName), + container: c, + }) + } + } + + items := make([]string, 0, len(citems)) + for _, citem := range citems { + items = append(items, citem.key) + } + idx, err := fzf.Search(items) + if err != nil { + return nil, err + } + + return citems[idx].container, nil +} + +func getCurrentNamespace() string { + namespace := kubeconfig.GetCurrentNamespace() + if namespace == "" { + return "default" + } + return namespace +} diff --git a/cmd/setimage/completion.go b/cmd/setimage/completion.go new file mode 100644 index 0000000..6525590 --- /dev/null +++ b/cmd/setimage/completion.go @@ -0,0 +1,15 @@ +package setimage + +import ( + "github.com/fioncat/kubewrap/cmd" + "github.com/spf13/cobra" +) + +func CompletionFunc(c *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + switch len(args) { + case 0: + return cmd.CompleteContainer(c, toComplete) + } + + return nil, cobra.ShellCompDirectiveNoFileComp +} diff --git a/cmd/setimage/setimage.go b/cmd/setimage/setimage.go new file mode 100644 index 0000000..0e77fbc --- /dev/null +++ b/cmd/setimage/setimage.go @@ -0,0 +1,51 @@ +package setimage + +import ( + "errors" + + "github.com/fioncat/kubewrap/cmd" + "github.com/fioncat/kubewrap/pkg/term" + "github.com/spf13/cobra" +) + +func New() *cobra.Command { + var opts Options + c := &cobra.Command{ + Use: "set-image ", + Short: "Set the image of a container", + Args: cobra.ExactArgs(2), + + ValidArgsFunction: CompletionFunc, + } + return cmd.Build(c, &opts) +} + +type Options struct { + query string + image string +} + +func (o *Options) Validate(_ *cobra.Command, args []string) error { + o.query = args[0] + + o.image = args[1] + if len(o.image) == 0 { + return errors.New("image is required") + } + + return nil +} + +func (o *Options) Run(cmdctx *cmd.Context) error { + c, err := cmd.SelectContainer(cmdctx.Kubectl, o.query) + if err != nil { + return err + } + err = cmdctx.Kubectl.SetImage(c, o.image) + if err != nil { + return err + } + + term.PrintHint("Set image of %v to %q", c, o.image) + return nil +} diff --git a/config/config.go b/config/config.go index 54ef9c3..2474b78 100644 --- a/config/config.go +++ b/config/config.go @@ -164,6 +164,9 @@ func (c *Config) normalize() error { c.History.Path = defaults.History.Path } c.History.Path = os.ExpandEnv(c.History.Path) + if !filepath.IsAbs(c.History.Path) { + return errors.New("`history.path` is not absolute") + } if c.History.Max <= 0 { c.History.Max = defaults.History.Max diff --git a/main.go b/main.go index 0756c7b..4afe222 100644 --- a/main.go +++ b/main.go @@ -11,6 +11,9 @@ import ( initcmd "github.com/fioncat/kubewrap/cmd/init" "github.com/fioncat/kubewrap/cmd/login" "github.com/fioncat/kubewrap/cmd/ns" + "github.com/fioncat/kubewrap/cmd/restart" + "github.com/fioncat/kubewrap/cmd/scale" + "github.com/fioncat/kubewrap/cmd/setimage" "github.com/fioncat/kubewrap/cmd/show" sourcecmd "github.com/fioncat/kubewrap/cmd/source" "github.com/fioncat/kubewrap/pkg/fzf" @@ -68,6 +71,9 @@ func main() { c.AddCommand(initcmd.New()) c.AddCommand(login.New()) c.AddCommand(ns.New()) + c.AddCommand(restart.New()) + c.AddCommand(scale.New()) + c.AddCommand(setimage.New()) c.AddCommand(show.New()) c.AddCommand(sourcecmd.New()) diff --git a/pkg/kubectl/cmd.go b/pkg/kubectl/cmd.go index e28fe38..00f5335 100644 --- a/pkg/kubectl/cmd.go +++ b/pkg/kubectl/cmd.go @@ -114,6 +114,87 @@ func (k *cmdKubectl) Copy(namespace, src, dest string) error { return err } +func (k *cmdKubectl) ListResources(resourceType, namespace string) ([]*Resource, error) { + args := []string{ + "get", "-n", namespace, + resourceType, + "-o", "jsonpath={.items[*].metadata.name}", + } + output, err := k.output(nil, args...) + if err != nil { + return nil, err + } + names := strings.Fields(output) + rs := make([]*Resource, 0, len(names)) + for _, name := range names { + name = strings.TrimSpace(name) + if name == "" { + continue + } + rs = append(rs, &Resource{ + Type: resourceType, + Namespace: namespace, + Name: name, + }) + } + return rs, nil +} + +func (k *cmdKubectl) ListContainers(r *Resource) ([]*Container, error) { + args := []string{ + "get", "-n", r.Namespace, + r.Type, r.Name, + "-o", "jsonpath={.spec.template.spec.containers[*].name}", + } + output, err := k.output(nil, args...) + if err != nil { + return nil, err + } + names := strings.Fields(output) + cs := make([]*Container, 0, len(names)) + for _, name := range names { + name = strings.TrimSpace(name) + if name == "" { + continue + } + cs = append(cs, &Container{ + Resource: *r, + + ContainerName: name, + }) + } + return cs, nil +} + +func (k *cmdKubectl) SetImage(c *Container, image string) error { + args := []string{ + "set", "image", "-n", c.Namespace, + fmt.Sprintf("%s/%s", c.Type, c.Name), + fmt.Sprintf("%s=%s", c.ContainerName, image), + } + _, err := k.output(nil, args...) + return err +} + +func (k *cmdKubectl) Scale(r *Resource, replicas int) error { + args := []string{ + "scale", "-n", r.Namespace, + fmt.Sprintf("%s/%s", r.Type, r.Name), + fmt.Sprintf("--replicas=%d", replicas), + } + _, err := k.output(nil, args...) + return err +} + +func (k *cmdKubectl) RolloutRestart(r *Resource) error { + args := []string{ + "rollout", "restart", "-n", r.Namespace, + fmt.Sprintf("%s/%s", r.Type, r.Name), + } + _, err := k.output(nil, args...) + return err +} + func (k *cmdKubectl) lines(args ...string) ([]string, error) { output, err := k.output(nil, args...) if err != nil { diff --git a/pkg/kubectl/kubectl.go b/pkg/kubectl/kubectl.go index 4e1d080..b8b1254 100644 --- a/pkg/kubectl/kubectl.go +++ b/pkg/kubectl/kubectl.go @@ -16,6 +16,13 @@ type Kubectl interface { Exec(namespace, name string, cmd []string) error Copy(namespace, src, dest string) error + + ListResources(resourceType, namespace string) ([]*Resource, error) + ListContainers(r *Resource) ([]*Container, error) + + SetImage(c *Container, image string) error + Scale(r *Resource, replicas int) error + RolloutRestart(r *Resource) error } type Node struct { @@ -23,6 +30,25 @@ type Node struct { Description string } +type Resource struct { + Type string + Namespace string + Name string +} + +func (r *Resource) String() string { + return fmt.Sprintf("%s %s/%s", r.Type, r.Namespace, r.Name) +} + +type Container struct { + Resource + ContainerName string +} + +func (c *Container) String() string { + return fmt.Sprintf("%s %s/%s/%s", c.Type, c.Namespace, c.Name, c.ContainerName) +} + type NotFoundError struct { resourceType string name string