From fcc7191ab3d16360438e8ce737ce3d40ff5bd52e Mon Sep 17 00:00:00 2001 From: Hussein Galal Date: Tue, 20 Jan 2026 14:00:24 +0200 Subject: [PATCH] CLI cluster update (#595) * CLI cluster update Signed-off-by: galal-hussein --- cli/cmds/cluster.go | 1 + cli/cmds/cluster_create.go | 16 ++- cli/cmds/cluster_create_test.go | 2 +- cli/cmds/cluster_update.go | 198 ++++++++++++++++++++++++++++++ cli/cmds/diff_printer.go | 53 ++++++++ docs/cli/k3kcli.adoc | 34 +++++ docs/cli/k3kcli_cluster.md | 1 + docs/cli/k3kcli_cluster_update.md | 38 ++++++ go.mod | 2 +- scripts/generate-cli-docs | 1 + tests/cli_test.go | 178 +++++++++++++++++++++++++++ 11 files changed, 518 insertions(+), 6 deletions(-) create mode 100644 cli/cmds/cluster_update.go create mode 100644 cli/cmds/diff_printer.go create mode 100644 docs/cli/k3kcli_cluster_update.md diff --git a/cli/cmds/cluster.go b/cli/cmds/cluster.go index f7cd711..c26cd32 100644 --- a/cli/cmds/cluster.go +++ b/cli/cmds/cluster.go @@ -12,6 +12,7 @@ func NewClusterCmd(appCtx *AppContext) *cobra.Command { cmd.AddCommand( NewClusterCreateCmd(appCtx), + NewClusterUpdateCmd(appCtx), NewClusterDeleteCmd(appCtx), NewClusterListCmd(appCtx), ) diff --git a/cli/cmds/cluster_create.go b/cli/cmds/cluster_create.go index 57a5c56..204eb35 100644 --- a/cli/cmds/cluster_create.go +++ b/cli/cmds/cluster_create.go @@ -148,9 +148,9 @@ func createAction(appCtx *AppContext, config *CreateConfig) func(cmd *cobra.Comm return fmt.Errorf("failed to wait for cluster to be reconciled: %w", err) } - clusterDetails, err := printClusterDetails(cluster) + clusterDetails, err := getClusterDetails(cluster) if err != nil { - return fmt.Errorf("failed to print cluster details: %w", err) + return fmt.Errorf("failed to get cluster details: %w", err) } logrus.Info(clusterDetails) @@ -399,9 +399,13 @@ const clusterDetailsTemplate = `Cluster details: Persistence: Type: {{.Persistence.Type}}{{ if .Persistence.StorageClassName }} StorageClass: {{ .Persistence.StorageClassName }}{{ end }}{{ if .Persistence.StorageRequestSize }} - Size: {{ .Persistence.StorageRequestSize }}{{ end }}` + Size: {{ .Persistence.StorageRequestSize }}{{ end }}{{ if .Labels }} + Labels: {{ range $key, $value := .Labels }} + {{$key}}: {{$value}}{{ end }}{{ end }}{{ if .Annotations }} + Annotations: {{ range $key, $value := .Annotations }} + {{$key}}: {{$value}}{{ end }}{{ end }}` -func printClusterDetails(cluster *v1beta1.Cluster) (string, error) { +func getClusterDetails(cluster *v1beta1.Cluster) (string, error) { type templateData struct { Mode v1beta1.ClusterMode Servers int32 @@ -413,6 +417,8 @@ func printClusterDetails(cluster *v1beta1.Cluster) (string, error) { StorageClassName string StorageRequestSize string } + Labels map[string]string + Annotations map[string]string } data := templateData{ @@ -421,6 +427,8 @@ func printClusterDetails(cluster *v1beta1.Cluster) (string, error) { Agents: ptr.Deref(cluster.Spec.Agents, 0), Version: cluster.Spec.Version, HostVersion: cluster.Status.HostVersion, + Annotations: cluster.Annotations, + Labels: cluster.Labels, } data.Persistence.Type = cluster.Spec.Persistence.Type diff --git a/cli/cmds/cluster_create_test.go b/cli/cmds/cluster_create_test.go index 21407f3..64b91f9 100644 --- a/cli/cmds/cluster_create_test.go +++ b/cli/cmds/cluster_create_test.go @@ -87,7 +87,7 @@ func Test_printClusterDetails(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - clusterDetails, err := printClusterDetails(tt.cluster) + clusterDetails, err := getClusterDetails(tt.cluster) assert.NoError(t, err) assert.Equal(t, tt.want, clusterDetails) }) diff --git a/cli/cmds/cluster_update.go b/cli/cmds/cluster_update.go new file mode 100644 index 0000000..66c308b --- /dev/null +++ b/cli/cmds/cluster_update.go @@ -0,0 +1,198 @@ +package cmds + +import ( + "bufio" + "errors" + "fmt" + "os" + "strings" + + "github.com/blang/semver/v4" + "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/types" + "k8s.io/utils/ptr" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + + "github.com/rancher/k3k/pkg/apis/k3k.io/v1beta1" + k3kcluster "github.com/rancher/k3k/pkg/controller/cluster" +) + +type UpdateConfig struct { + servers int32 + agents int32 + labels []string + annotations []string + version string + noConfirm bool +} + +func NewClusterUpdateCmd(appCtx *AppContext) *cobra.Command { + updateConfig := &UpdateConfig{} + + cmd := &cobra.Command{ + Use: "update", + Short: "Update existing cluster", + Example: "k3kcli cluster update [command options] NAME", + RunE: updateAction(appCtx, updateConfig), + Args: cobra.ExactArgs(1), + } + + CobraFlagNamespace(appCtx, cmd.Flags()) + updateFlags(cmd, updateConfig) + + return cmd +} + +func updateFlags(cmd *cobra.Command, cfg *UpdateConfig) { + cmd.Flags().Int32Var(&cfg.servers, "servers", 1, "number of servers") + cmd.Flags().Int32Var(&cfg.agents, "agents", 0, "number of agents") + cmd.Flags().StringArrayVar(&cfg.labels, "labels", []string{}, "Labels to add to the cluster object (e.g. key=value)") + cmd.Flags().StringArrayVar(&cfg.annotations, "annotations", []string{}, "Annotations to add to the cluster object (e.g. key=value)") + cmd.Flags().StringVar(&cfg.version, "version", "", "k3s version") + cmd.Flags().BoolVarP(&cfg.noConfirm, "no-confirm", "y", false, "Skip interactive approval before applying update") +} + +func updateAction(appCtx *AppContext, config *UpdateConfig) func(cmd *cobra.Command, args []string) error { + return func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + client := appCtx.Client + name := args[0] + + if name == k3kcluster.ClusterInvalidName { + return errors.New("invalid cluster name") + } + + namespace := appCtx.Namespace(name) + + var virtualCluster v1beta1.Cluster + + clusterKey := types.NamespacedName{Name: name, Namespace: appCtx.namespace} + if err := appCtx.Client.Get(ctx, clusterKey, &virtualCluster); err != nil { + if apierrors.IsNotFound(err) { + return fmt.Errorf("cluster %s not found in namespace %s", name, appCtx.namespace) + } + + return fmt.Errorf("failed to fetch cluster: %w", err) + } + + var changes []change + + if cmd.Flags().Changed("version") && config.version != virtualCluster.Spec.Version { + currentVersion := virtualCluster.Spec.Version + if currentVersion == "" { + currentVersion = virtualCluster.Status.HostVersion + } + + currentVersionSemver, err := semver.ParseTolerant(currentVersion) + if err != nil { + return fmt.Errorf("failed to parse current cluster version %w", err) + } + + newVersionSemver, err := semver.ParseTolerant(config.version) + if err != nil { + return fmt.Errorf("failed to parse new cluster version %w", err) + } + + if newVersionSemver.LT(currentVersionSemver) { + return fmt.Errorf("downgrading cluster version is not supported") + } + + changes = append(changes, change{"Version", currentVersion, config.version}) + virtualCluster.Spec.Version = config.version + } + + if cmd.Flags().Changed("servers") { + var oldServers int32 + if virtualCluster.Spec.Agents != nil { + oldServers = *virtualCluster.Spec.Servers + } + + if oldServers != config.servers { + changes = append(changes, change{"Servers", fmt.Sprintf("%d", oldServers), fmt.Sprintf("%d", config.servers)}) + virtualCluster.Spec.Servers = ptr.To(config.servers) + } + } + + if cmd.Flags().Changed("agents") { + var oldAgents int32 + if virtualCluster.Spec.Agents != nil { + oldAgents = *virtualCluster.Spec.Agents + } + + if oldAgents != config.agents { + changes = append(changes, change{"Agents", fmt.Sprintf("%d", oldAgents), fmt.Sprintf("%d", config.agents)}) + virtualCluster.Spec.Agents = ptr.To(config.agents) + } + } + + var labelChanges []change + + if cmd.Flags().Changed("labels") { + oldLabels := labels.Merge(nil, virtualCluster.Labels) + virtualCluster.Labels = labels.Merge(virtualCluster.Labels, parseKeyValuePairs(config.labels, "label")) + labelChanges = diffMaps(oldLabels, virtualCluster.Labels) + } + + var annotationChanges []change + + if cmd.Flags().Changed("annotations") { + oldAnnotations := labels.Merge(nil, virtualCluster.Annotations) + virtualCluster.Annotations = labels.Merge(virtualCluster.Annotations, parseKeyValuePairs(config.annotations, "annotation")) + annotationChanges = diffMaps(oldAnnotations, virtualCluster.Annotations) + } + + if len(changes) == 0 && len(labelChanges) == 0 && len(annotationChanges) == 0 { + logrus.Info("No changes detected, skipping update") + return nil + } + + logrus.Infof("Updating cluster '%s' in namespace '%s'", name, namespace) + + printDiff(changes) + printMapDiff("Labels", labelChanges) + printMapDiff("Annotations", annotationChanges) + + if !config.noConfirm { + if !confirmClusterUpdate(&virtualCluster) { + return nil + } + } + + if err := client.Update(ctx, &virtualCluster); err != nil { + return err + } + + logrus.Info("Cluster updated successfully") + + return nil + } +} + +func confirmClusterUpdate(cluster *v1beta1.Cluster) bool { + clusterDetails, err := getClusterDetails(cluster) + if err != nil { + logrus.Fatalf("unable to get cluster details: %v", err) + } + + fmt.Printf("\nNew %s\n", clusterDetails) + + fmt.Printf("\nDo you want to update the cluster? [y/N]: ") + + scanner := bufio.NewScanner(os.Stdin) + + if !scanner.Scan() { + if err := scanner.Err(); err != nil { + logrus.Errorf("Error reading input: %v", err) + } + + return false + } + + fmt.Printf("\n") + + return strings.ToLower(strings.TrimSpace(scanner.Text())) == "y" +} diff --git a/cli/cmds/diff_printer.go b/cli/cmds/diff_printer.go new file mode 100644 index 0000000..79008a7 --- /dev/null +++ b/cli/cmds/diff_printer.go @@ -0,0 +1,53 @@ +package cmds + +import "fmt" + +type change struct { + field string + oldValue string + newValue string +} + +func printDiff(changes []change) { + for _, c := range changes { + if c.oldValue == c.newValue { + continue + } + + fmt.Printf("%s: %s -> %s\n", c.field, c.oldValue, c.newValue) + } +} + +func printMapDiff(title string, changes []change) { + if len(changes) == 0 { + return + } + + fmt.Printf("%s:\n", title) + + for _, c := range changes { + switch c.oldValue { + case "": + fmt.Printf(" %s=%s (new)\n", c.field, c.newValue) + default: + fmt.Printf(" %s=%s -> %s=%s\n", c.field, c.oldValue, c.field, c.newValue) + } + } +} + +func diffMaps(oldMap, newMap map[string]string) []change { + var changes []change + + // Check for new and changed keys + for k, newVal := range newMap { + if oldVal, exists := oldMap[k]; exists { + if oldVal != newVal { + changes = append(changes, change{k, oldVal, newVal}) + } + } else { + changes = append(changes, change{k, "", newVal}) + } + } + + return changes +} diff --git a/docs/cli/k3kcli.adoc b/docs/cli/k3kcli.adoc index edcff88..b6fbebf 100644 --- a/docs/cli/k3kcli.adoc +++ b/docs/cli/k3kcli.adoc @@ -133,6 +133,40 @@ k3kcli cluster list [command options] --kubeconfig string kubeconfig path ($HOME/.kube/config or $KUBECONFIG if set) ---- +== k3kcli cluster update + +Update existing cluster + +---- +k3kcli cluster update [flags] +---- + +=== Examples + +---- +k3kcli cluster update [command options] NAME +---- + +=== Options + +---- + --agents int32 number of agents + --annotations stringArray Annotations to add to the cluster object (e.g. key=value) + -h, --help help for update + --labels stringArray Labels to add to the cluster object (e.g. key=value) + -n, --namespace string namespace of the k3k cluster + -y, --no-confirm Skip interactive approval before applying update + --servers int32 number of servers (default 1) + --version string k3s version +---- + +=== Options inherited from parent commands + +---- + --debug Turn on debug logs + --kubeconfig string kubeconfig path ($HOME/.kube/config or $KUBECONFIG if set) +---- + == k3kcli kubeconfig Manage kubeconfig for clusters. diff --git a/docs/cli/k3kcli_cluster.md b/docs/cli/k3kcli_cluster.md index cdf1d93..99ad19f 100644 --- a/docs/cli/k3kcli_cluster.md +++ b/docs/cli/k3kcli_cluster.md @@ -21,4 +21,5 @@ K3k cluster command. * [k3kcli cluster create](k3kcli_cluster_create.md) - Create a new cluster. * [k3kcli cluster delete](k3kcli_cluster_delete.md) - Delete an existing cluster. * [k3kcli cluster list](k3kcli_cluster_list.md) - List all existing clusters. +* [k3kcli cluster update](k3kcli_cluster_update.md) - Update existing cluster diff --git a/docs/cli/k3kcli_cluster_update.md b/docs/cli/k3kcli_cluster_update.md new file mode 100644 index 0000000..55b749e --- /dev/null +++ b/docs/cli/k3kcli_cluster_update.md @@ -0,0 +1,38 @@ +## k3kcli cluster update + +Update existing cluster + +``` +k3kcli cluster update [flags] +``` + +### Examples + +``` +k3kcli cluster update [command options] NAME +``` + +### Options + +``` + --agents int32 number of agents + --annotations stringArray Annotations to add to the cluster object (e.g. key=value) + -h, --help help for update + --labels stringArray Labels to add to the cluster object (e.g. key=value) + -n, --namespace string namespace of the k3k cluster + -y, --no-confirm Skip interactive approval before applying update + --servers int32 number of servers (default 1) + --version string k3s version +``` + +### Options inherited from parent commands + +``` + --debug Turn on debug logs + --kubeconfig string kubeconfig path ($HOME/.kube/config or $KUBECONFIG if set) +``` + +### SEE ALSO + +* [k3kcli cluster](k3kcli_cluster.md) - K3k cluster command. + diff --git a/go.mod b/go.mod index 0896d74..7d40842 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ replace ( ) require ( + github.com/blang/semver/v4 v4.0.0 github.com/go-logr/logr v1.4.2 github.com/go-logr/zapr v1.3.0 github.com/google/go-cmp v0.7.0 @@ -60,7 +61,6 @@ require ( github.com/antlr4-go/antlr/v4 v4.13.0 // indirect github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect github.com/beorn7/perks v1.0.1 // indirect - github.com/blang/semver/v4 v4.0.0 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/chai2010/gettext-go v1.0.2 // indirect diff --git a/scripts/generate-cli-docs b/scripts/generate-cli-docs index b2baa72..84cdc60 100755 --- a/scripts/generate-cli-docs +++ b/scripts/generate-cli-docs @@ -18,6 +18,7 @@ SUBCOMMAND_FILES=( "$DOCS_DIR/k3kcli_cluster_create.md" "$DOCS_DIR/k3kcli_cluster_delete.md" "$DOCS_DIR/k3kcli_cluster_list.md" + "$DOCS_DIR/k3kcli_cluster_update.md" "$DOCS_DIR/k3kcli_kubeconfig.md" "$DOCS_DIR/k3kcli_kubeconfig_generate.md" "$DOCS_DIR/k3kcli_policy.md" diff --git a/tests/cli_test.go b/tests/cli_test.go index 25e50e5..9466148 100644 --- a/tests/cli_test.go +++ b/tests/cli_test.go @@ -11,6 +11,7 @@ import ( v1 "k8s.io/api/core/v1" + "github.com/rancher/k3k/pkg/apis/k3k.io/v1beta1" "github.com/rancher/k3k/pkg/controller/policy" . "github.com/onsi/ginkgo/v2" @@ -201,6 +202,183 @@ var _ = When("using the k3kcli", Label("cli"), func() { }) }) + When("trying the cluster update commands", func() { + It("can update a cluster's server count", func() { + var ( + stderr string + err error + ) + + clusterName := "cluster-" + rand.String(5) + + namespace := NewNamespace() + clusterNamespace := namespace.Name + + DeferCleanup(func() { + DeleteNamespaces(clusterNamespace) + }) + + // Create the cluster first + _, stderr, err = K3kcli("cluster", "create", "--namespace", clusterNamespace, clusterName) + Expect(err).To(Not(HaveOccurred()), string(stderr)) + Expect(stderr).To(ContainSubstring("You can start using the cluster")) + + // Update the cluster server count + _, stderr, err = K3kcli("cluster", "update", "-y", "--servers", "2", "--namespace", clusterNamespace, clusterName) + Expect(err).To(Not(HaveOccurred()), string(stderr)) + Expect(stderr).To(ContainSubstring("Updating cluster")) + + // Verify the cluster state was actually updated + var cluster v1beta1.Cluster + err = k8sClient.Get(context.Background(), types.NamespacedName{Name: clusterName, Namespace: clusterNamespace}, &cluster) + Expect(err).To(Not(HaveOccurred())) + Expect(cluster.Spec.Servers).To(Not(BeNil())) + Expect(*cluster.Spec.Servers).To(Equal(int32(2))) + }) + + It("can update a cluster's version", func() { + var ( + stderr string + err error + ) + + clusterName := "cluster-" + rand.String(5) + + namespace := NewNamespace() + clusterNamespace := namespace.Name + + DeferCleanup(func() { + DeleteNamespaces(clusterNamespace) + }) + + // Create the cluster with initial version + _, stderr, err = K3kcli("cluster", "create", "--version", "v1.31.13-k3s1", "--namespace", clusterNamespace, clusterName) + Expect(err).To(Not(HaveOccurred()), string(stderr)) + Expect(stderr).To(ContainSubstring("You can start using the cluster")) + + // Update the cluster version + _, stderr, err = K3kcli("cluster", "update", "-y", "--version", "v1.32.8-k3s1", "--namespace", clusterNamespace, clusterName) + Expect(err).To(Not(HaveOccurred()), string(stderr)) + Expect(stderr).To(ContainSubstring("Updating cluster")) + + // Verify the cluster state was actually updated + var cluster v1beta1.Cluster + err = k8sClient.Get(context.Background(), types.NamespacedName{Name: clusterName, Namespace: clusterNamespace}, &cluster) + Expect(err).To(Not(HaveOccurred())) + Expect(cluster.Spec.Version).To(Equal("v1.32.8-k3s1")) + }) + + It("fails to downgrade cluster version", func() { + var ( + stderr string + err error + ) + + clusterName := "cluster-" + rand.String(5) + + namespace := NewNamespace() + clusterNamespace := namespace.Name + + DeferCleanup(func() { + DeleteNamespaces(clusterNamespace) + }) + + // Create the cluster with a version + _, stderr, err = K3kcli("cluster", "create", "--version", "v1.32.8-k3s1", "--namespace", clusterNamespace, clusterName) + Expect(err).To(Not(HaveOccurred()), string(stderr)) + Expect(stderr).To(ContainSubstring("You can start using the cluster")) + + // Attempt to downgrade should fail + _, stderr, err = K3kcli("cluster", "update", "-y", "--version", "v1.31.13-k3s1", "--namespace", clusterNamespace, clusterName) + Expect(err).To(HaveOccurred()) + Expect(stderr).To(ContainSubstring("downgrading cluster version is not supported")) + + // Verify the cluster version was NOT changed + var cluster v1beta1.Cluster + err = k8sClient.Get(context.Background(), types.NamespacedName{Name: clusterName, Namespace: clusterNamespace}, &cluster) + Expect(err).To(Not(HaveOccurred())) + Expect(cluster.Spec.Version).To(Equal("v1.32.8-k3s1")) + }) + + It("fails to update a non-existent cluster", func() { + var ( + stderr string + err error + ) + + // Attempt to update a cluster that doesn't exist + _, stderr, err = K3kcli("cluster", "update", "-y", "--servers", "2", "non-existent-cluster") + Expect(err).To(HaveOccurred()) + Expect(stderr).To(ContainSubstring("failed to fetch cluster")) + }) + + It("can update a cluster's labels", func() { + var ( + stderr string + err error + ) + + clusterName := "cluster-" + rand.String(5) + + namespace := NewNamespace() + clusterNamespace := namespace.Name + + DeferCleanup(func() { + DeleteNamespaces(clusterNamespace) + }) + + // Create the cluster first + _, stderr, err = K3kcli("cluster", "create", "--namespace", clusterNamespace, clusterName) + Expect(err).To(Not(HaveOccurred()), string(stderr)) + Expect(stderr).To(ContainSubstring("You can start using the cluster")) + + // Update the cluster with labels + _, stderr, err = K3kcli("cluster", "update", "-y", "--labels", "env=test", "--labels", "team=dev", "--namespace", clusterNamespace, clusterName) + Expect(err).To(Not(HaveOccurred()), string(stderr)) + Expect(stderr).To(ContainSubstring("Updating cluster")) + + // Verify the cluster labels were actually updated + var cluster v1beta1.Cluster + err = k8sClient.Get(context.Background(), types.NamespacedName{Name: clusterName, Namespace: clusterNamespace}, &cluster) + Expect(err).To(Not(HaveOccurred())) + Expect(cluster.Labels).To(HaveKeyWithValue("env", "test")) + Expect(cluster.Labels).To(HaveKeyWithValue("team", "dev")) + }) + + It("can update a cluster's annotations", func() { + var ( + stderr string + err error + ) + + clusterName := "cluster-" + rand.String(5) + + namespace := NewNamespace() + clusterNamespace := namespace.Name + + DeferCleanup(func() { + DeleteNamespaces(clusterNamespace) + }) + + // Create the cluster first + _, stderr, err = K3kcli("cluster", "create", "--namespace", clusterNamespace, clusterName) + Expect(err).To(Not(HaveOccurred()), string(stderr)) + Expect(stderr).To(ContainSubstring("You can start using the cluster")) + + // Update the cluster with annotations + _, stderr, err = K3kcli("cluster", "update", "-y", "--annotations", "description=test-cluster", "--annotations", "owner=qa-team", "--namespace", clusterNamespace, clusterName) + Expect(err).To(Not(HaveOccurred()), string(stderr)) + Expect(stderr).To(ContainSubstring("Updating cluster")) + + // Verify the cluster annotations were actually updated + var cluster v1beta1.Cluster + err = k8sClient.Get(context.Background(), types.NamespacedName{Name: clusterName, Namespace: clusterNamespace}, &cluster) + Expect(err).To(Not(HaveOccurred())) + Expect(cluster.Annotations).To(HaveKeyWithValue("description", "test-cluster")) + Expect(cluster.Annotations).To(HaveKeyWithValue("owner", "qa-team")) + }) + }) + When("trying the kubeconfig command", func() { It("can generate a kubeconfig", func() { var (