diff --git a/cmd/bink/main.go b/cmd/bink/main.go index 172227d..1a77405 100644 --- a/cmd/bink/main.go +++ b/cmd/bink/main.go @@ -7,6 +7,7 @@ import ( "fmt" "os" + "github.com/bootc-dev/bink/internal/cli" "github.com/bootc-dev/bink/internal/cli/api" "github.com/bootc-dev/bink/internal/cli/cluster" "github.com/bootc-dev/bink/internal/cli/node" @@ -84,6 +85,8 @@ func init() { viper.BindPFlag("logging.verbose", rootCmd.PersistentFlags().Lookup("verbose")) viper.BindPFlag("logging.debug", rootCmd.PersistentFlags().Lookup("debug")) + rootCmd.RegisterFlagCompletionFunc("cluster-name", cli.CompleteClusterNames) + rootCmd.AddCommand(cluster.NewClusterCmd()) rootCmd.AddCommand(node.NewNodeCmd()) rootCmd.AddCommand(api.NewAPICmd()) diff --git a/internal/cli/api/expose.go b/internal/cli/api/expose.go index c096183..149866d 100644 --- a/internal/cli/api/expose.go +++ b/internal/cli/api/expose.go @@ -15,6 +15,7 @@ import ( "github.com/spf13/cobra" "github.com/spf13/viper" + "github.com/bootc-dev/bink/internal/cli" "github.com/bootc-dev/bink/internal/config" "github.com/bootc-dev/bink/internal/haproxy" "github.com/bootc-dev/bink/internal/podman" @@ -44,6 +45,8 @@ This command: cmd.Flags().StringVarP(&nodeName, "node", "n", "", "Node name to fetch kubeconfig from (auto-detected if not set)") cmd.Flags().StringVarP(&kubeconfigPath, "kubeconfig", "k", filepath.Join(config.DefaultKubeconfigDir, "kubeconfig"), "Path to save kubeconfig") + cmd.RegisterFlagCompletionFunc("node", cli.CompleteNodeNames) + return cmd } diff --git a/internal/cli/completion.go b/internal/cli/completion.go new file mode 100644 index 0000000..b123ffa --- /dev/null +++ b/internal/cli/completion.go @@ -0,0 +1,105 @@ +// SPDX-FileCopyrightText: 2026 The bink Authors +// SPDX-License-Identifier: Apache-2.0 + +package cli + +import ( + "strings" + + "github.com/spf13/cobra" + "github.com/spf13/viper" + + "github.com/bootc-dev/bink/internal/config" + "github.com/bootc-dev/bink/internal/podman" +) + +func CompleteClusterNames(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + client, err := podman.NewClient() + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + ctx := cmd.Context() + containers, err := client.ContainerList(ctx, "name="+config.ContainerNamePrefix) + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + seen := make(map[string]bool) + var names []string + for _, ctr := range containers { + clusterName, err := client.ContainerInspect(ctx, ctr, `{{index .Config.Labels "bink.cluster-name"}}`) + if err != nil || clusterName == "" { + continue + } + if !seen[clusterName] && strings.HasPrefix(clusterName, toComplete) { + seen[clusterName] = true + names = append(names, clusterName) + } + } + return names, cobra.ShellCompDirectiveNoFileComp +} + +func CompleteNodeNames(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + client, err := podman.NewClient() + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + ctx := cmd.Context() + clusterName := viper.GetString("cluster.name") + containers, err := client.ContainerList(ctx, "label=bink.cluster-name="+clusterName) + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + var names []string + for _, ctr := range containers { + component, _ := client.ContainerInspect(ctx, ctr, `{{index .Config.Labels "bink.component"}}`) + if component != "" { + continue + } + nodeName, err := client.ContainerInspect(ctx, ctr, `{{index .Config.Labels "bink.node-name"}}`) + if err != nil || nodeName == "" { + continue + } + if strings.HasPrefix(nodeName, toComplete) { + names = append(names, nodeName) + } + } + return names, cobra.ShellCompDirectiveNoFileComp +} + +func CompleteControlPlaneNodes(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + client, err := podman.NewClient() + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + ctx := cmd.Context() + clusterName := viper.GetString("cluster.name") + containers, err := client.ContainerList(ctx, "label=bink.cluster-name="+clusterName) + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + var names []string + for _, ctr := range containers { + component, _ := client.ContainerInspect(ctx, ctr, `{{index .Config.Labels "bink.component"}}`) + if component != "" { + continue + } + role, _ := client.ContainerInspect(ctx, ctr, `{{index .Config.Labels "bink.node-role"}}`) + if role != "control-plane" { + continue + } + nodeName, err := client.ContainerInspect(ctx, ctr, `{{index .Config.Labels "bink.node-name"}}`) + if err != nil || nodeName == "" { + continue + } + if strings.HasPrefix(nodeName, toComplete) { + names = append(names, nodeName) + } + } + return names, cobra.ShellCompDirectiveNoFileComp +} diff --git a/internal/cli/node/add.go b/internal/cli/node/add.go index d449f88..c3bf587 100644 --- a/internal/cli/node/add.go +++ b/internal/cli/node/add.go @@ -12,6 +12,7 @@ import ( "github.com/spf13/cobra" "github.com/spf13/viper" + "github.com/bootc-dev/bink/internal/cli" "github.com/bootc-dev/bink/internal/cluster" "github.com/bootc-dev/bink/internal/config" "github.com/bootc-dev/bink/internal/dns" @@ -60,6 +61,11 @@ func newAddCmd() *cobra.Command { cmd.Flags().BoolVar(&hostNetworkPopulator, "host-network-populator", false, "Use host networking for the image populator container (fixes DNS in nested podman)") cmd.Flags().StringArrayVarP(&labelFlags, "label", "l", nil, "Node label in key=value format (can be specified multiple times)") + cmd.RegisterFlagCompletionFunc("role", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return []string{"worker", "control-plane"}, cobra.ShellCompDirectiveNoFileComp + }) + cmd.RegisterFlagCompletionFunc("control-plane", cli.CompleteControlPlaneNodes) + return cmd } diff --git a/internal/cli/node/remove.go b/internal/cli/node/remove.go index 0177c55..d67cbd0 100644 --- a/internal/cli/node/remove.go +++ b/internal/cli/node/remove.go @@ -11,6 +11,7 @@ import ( "github.com/spf13/cobra" "github.com/spf13/viper" + "github.com/bootc-dev/bink/internal/cli" "github.com/bootc-dev/bink/internal/config" "github.com/bootc-dev/bink/internal/dns" "github.com/bootc-dev/bink/internal/haproxy" @@ -25,7 +26,8 @@ func newRemoveCmd() *cobra.Command { Use: "remove ", Short: "Remove a node from the cluster", Long: "Drain and remove a node from the Kubernetes cluster, then stop and remove its container", - Args: cobra.ExactArgs(1), + Args: cobra.ExactArgs(1), + ValidArgsFunction: cli.CompleteNodeNames, RunE: func(cmd *cobra.Command, args []string) error { logger := logrus.New() return runRemove(cmd.Context(), args[0], force, logger) diff --git a/internal/cli/node/ssh.go b/internal/cli/node/ssh.go index 9656a02..3481e5d 100644 --- a/internal/cli/node/ssh.go +++ b/internal/cli/node/ssh.go @@ -11,6 +11,7 @@ import ( "github.com/spf13/cobra" "github.com/spf13/viper" + "github.com/bootc-dev/bink/internal/cli" "github.com/bootc-dev/bink/internal/node" "github.com/bootc-dev/bink/internal/ssh" ) @@ -25,8 +26,9 @@ func newSSHCmd() *cobra.Command { # SSH into a worker node in a named cluster bink node ssh node2 --cluster-name dev`, - Args: cobra.ExactArgs(1), - RunE: runSSH, + Args: cobra.ExactArgs(1), + ValidArgsFunction: cli.CompleteNodeNames, + RunE: runSSH, } return cmd