From c63c770b5723042cbc63687b0c85e6c5a98e13a4 Mon Sep 17 00:00:00 2001 From: Denis Tarabrin Date: Mon, 2 Feb 2026 16:50:27 +0400 Subject: [PATCH] feat: Add CNI migration commands Introduces commands for managing CNI provider migrations in the Deckhouse cluster. This includes: - `d8 cni-migration switch`: Initiates a CNI migration. - `d8 cni-migration watch`: Monitors the progress of an ongoing migration. - `d8 cni-migration cleanup`: Removes migration-related resources. This feature leverages custom Kubernetes resources (CNIMigration and CNINodeMigration) to orchestrate and track the migration process. Signed-off-by: Denis Tarabrin --- cmd/commands/cni.go | 158 ++++++++++++ cmd/d8/root.go | 1 + .../cni/api/v1alpha1/cni_migration_types.go | 84 +++++++ .../api/v1alpha1/cni_node_migration_types.go | 59 +++++ internal/cni/api/v1alpha1/register.go | 50 ++++ .../cni/api/v1alpha1/zz_generated.deepcopy.go | 238 ++++++++++++++++++ internal/cni/cleanup.go | 71 ++++++ internal/cni/common.go | 102 ++++++++ internal/cni/switch.go | 94 +++++++ internal/cni/watch.go | 217 ++++++++++++++++ 10 files changed, 1074 insertions(+) create mode 100644 cmd/commands/cni.go create mode 100644 internal/cni/api/v1alpha1/cni_migration_types.go create mode 100644 internal/cni/api/v1alpha1/cni_node_migration_types.go create mode 100644 internal/cni/api/v1alpha1/register.go create mode 100644 internal/cni/api/v1alpha1/zz_generated.deepcopy.go create mode 100644 internal/cni/cleanup.go create mode 100644 internal/cni/common.go create mode 100644 internal/cni/switch.go create mode 100644 internal/cni/watch.go diff --git a/cmd/commands/cni.go b/cmd/commands/cni.go new file mode 100644 index 00000000..a900c98a --- /dev/null +++ b/cmd/commands/cni.go @@ -0,0 +1,158 @@ +/* +Copyright 2025 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package commands + +import ( + "errors" + "fmt" + "log" + "os" + "strings" + + "github.com/go-logr/logr" + "github.com/gosuri/uitable/util/wordwrap" + "github.com/spf13/cobra" + "k8s.io/kubectl/pkg/util/templates" + ctrllog "sigs.k8s.io/controller-runtime/pkg/log" + + "github.com/deckhouse/deckhouse-cli/internal/cni" +) + +var ( + cniSwitchLong = `A group of commands to switch the CNI (Container Network Interface) provider in the Deckhouse cluster. + +The migration process is handled automatically by an in-cluster controller. +This CLI tool is used to trigger the migration and monitor its status. + +Workflow: + + 1. 'd8 cni-migration switch --to-cni ' - Initiates the migration. + This creates a CNIMigration resource, which triggers the deployment of the migration agent. + The agent then performs all necessary steps (validation, node checks, CNI switching). + + 2. 'd8 cni-migration watch' - (Optional) Monitors the progress of the migration. + Since the process is automated, this command simply watches the status. + + 3. 'd8 cni-migration cleanup' - Cleans up the migration resources after completion.` + + cniSwitchExample = templates.Examples(` + # Start the migration to Cilium CNI + d8 cni-migration switch --to-cni cilium`) + + cniWatchExample = templates.Examples(` + # Monitor the ongoing migration + d8 cni-migration watch`) + + cniCleanupExample = templates.Examples(` + # Cleanup resources created by the 'switch' command + d8 cni-migration cleanup`) + + supportedCNIs = []string{"cilium", "flannel", "simple-bridge"} +) + +func NewCniSwitchCommand() *cobra.Command { + log.SetFlags(0) + ctrllog.SetLogger(logr.Discard()) + + cmd := &cobra.Command{ + Use: "cni-migration", + Short: "A group of commands to switch CNI in the cluster", + Long: cniSwitchLong, + } + cmd.AddCommand(NewCmdCniSwitch()) + cmd.AddCommand(NewCmdCniWatch()) + cmd.AddCommand(NewCmdCniCleanup()) + return cmd +} + +func NewCmdCniSwitch() *cobra.Command { + cmd := &cobra.Command{ + Use: "switch", + Short: "Initiates the CNI switching", + Example: cniSwitchExample, + PreRunE: func(cmd *cobra.Command, _ []string) error { + targetCNI, _ := cmd.Flags().GetString("to-cni") + for _, supported := range supportedCNIs { + if strings.ToLower(targetCNI) == supported { + return nil + } + } + return fmt.Errorf( + "invalid --to-cni value %q. Supported values are: %s", + targetCNI, + strings.Join(supportedCNIs, ", "), + ) + }, + + Run: func(cmd *cobra.Command, _ []string) { + targetCNI, _ := cmd.Flags().GetString("to-cni") + + if err := cni.RunSwitch(targetCNI); err != nil { + if errors.Is(err, cni.ErrCancelled) { + return + } + log.Fatal(wordwrap.WrapString(fmt.Sprintf("❌ Error running switch command: %v", err), 100)) + } + + fmt.Println() + if err := cni.RunWatch(); err != nil { + if errors.Is(err, cni.ErrMigrationFailed) { + os.Exit(1) + } + log.Fatal(wordwrap.WrapString(fmt.Sprintf("❌ Error monitoring switch progress: %v", err), 100)) + } + }, + } + cmd.Flags().String("to-cni", "", fmt.Sprintf( + "Target CNI provider to switch to. Supported values: %s", + strings.Join(supportedCNIs, ", "), + )) + _ = cmd.MarkFlagRequired("to-cni") + + return cmd +} + +func NewCmdCniWatch() *cobra.Command { + cmd := &cobra.Command{ + Use: "watch", + Short: "Monitors the CNI switching progress", + Example: cniWatchExample, + Run: func(cmd *cobra.Command, _ []string) { + if err := cni.RunWatch(); err != nil { + if errors.Is(err, cni.ErrMigrationFailed) { + os.Exit(1) + } + log.Fatal(wordwrap.WrapString(fmt.Sprintf("❌ Error running watch command: %v", err), 100)) + } + }, + } + return cmd +} + +func NewCmdCniCleanup() *cobra.Command { + cmd := &cobra.Command{ + Use: "cleanup", + Short: "Cleans up resources created during CNI switching", + Example: cniCleanupExample, + Run: func(cmd *cobra.Command, _ []string) { + if err := cni.RunCleanup(); err != nil { + log.Fatal(wordwrap.WrapString(fmt.Sprintf("❌ Error running cleanup command: %v", err), 100)) + } + }, + } + return cmd +} diff --git a/cmd/d8/root.go b/cmd/d8/root.go index 46ccce7d..946c7936 100644 --- a/cmd/d8/root.go +++ b/cmd/d8/root.go @@ -98,6 +98,7 @@ func (r *RootCommand) registerCommands() { r.cmd.AddCommand(commands.NewKubectlCommand()) r.cmd.AddCommand(commands.NewLoginCommand()) r.cmd.AddCommand(commands.NewStrongholdCommand()) + r.cmd.AddCommand(commands.NewCniSwitchCommand()) r.cmd.AddCommand(commands.NewHelpJSONCommand(r.cmd)) r.cmd.AddCommand(plugins.NewPluginsCommand(r.logger.Named("plugins-command"))) diff --git a/internal/cni/api/v1alpha1/cni_migration_types.go b/internal/cni/api/v1alpha1/cni_migration_types.go new file mode 100644 index 00000000..2f42219d --- /dev/null +++ b/internal/cni/api/v1alpha1/cni_migration_types.go @@ -0,0 +1,84 @@ +/* +Copyright 2025 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// +genclient +// +genclient:nonNamespaced +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +// +k8s:openapi-gen=true + +// CNIMigration is the schema for the CNIMigration API. +// It is a cluster-level resource that serves as the "single source of truth" +// for the entire migration process. It defines the goal (targetCNI) +// and tracks the overall progress across all nodes. +type CNIMigration struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata"` + + // Spec defines the desired state of CNIMigration. + Spec CNIMigrationSpec `json:"spec"` + // Status defines the observed state of CNIMigration. + Status CNIMigrationStatus `json:"status"` +} + +type CNIMigrationSpec struct { + // TargetCNI is the CNI to switch to. + TargetCNI string `json:"targetCNI"` +} + +const ( + ConditionSucceeded = "Succeeded" +) + +// CNIMigrationStatus defines the observed state of CNIMigration. +// +k8s:deepcopy-gen=true +type CNIMigrationStatus struct { + // CurrentCNI is the detected CNI from which the switch is being made. + CurrentCNI string `json:"currentCNI,omitempty"` + // NodesTotal is the total number of nodes involved in the migration. + NodesTotal int `json:"nodesTotal,omitempty"` + // NodesSucceeded is the number of nodes that have successfully completed the migration. + NodesSucceeded int `json:"nodesSucceeded,omitempty"` + // NodesFailed is the number of nodes where an error occurred. + NodesFailed int `json:"nodesFailed,omitempty"` + // FailedSummary contains details about nodes that failed the migration. + FailedSummary []FailedNodeSummary `json:"failedSummary,omitempty"` + // Phase reflects the current high-level stage of the migration. + Phase string `json:"phase,omitempty"` + // Conditions reflect the state of the migration as a whole. + // The d8 cli aggregates statuses from all CNINodeMigrations here. + Conditions []metav1.Condition `json:"conditions,omitempty"` +} + +// FailedNodeSummary captures the error state of a specific node. +// +k8s:deepcopy-gen=true +type FailedNodeSummary struct { + Node string `json:"node"` + Reason string `json:"reason"` +} + +// CNIMigrationList contains a list of CNIMigration. +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type CNIMigrationList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata"` + Items []CNIMigration `json:"items"` +} diff --git a/internal/cni/api/v1alpha1/cni_node_migration_types.go b/internal/cni/api/v1alpha1/cni_node_migration_types.go new file mode 100644 index 00000000..e8be2550 --- /dev/null +++ b/internal/cni/api/v1alpha1/cni_node_migration_types.go @@ -0,0 +1,59 @@ +/* +Copyright 2025 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// +genclient +// +genclient:nonNamespaced +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +// +k8s:openapi-gen=true + +// CNINodeMigration is the schema for the CNINodeMigration API. +// This resource is created for each node in the cluster. The Helper +// agent running on the node updates this resource to report its local progress. +// The d8 cli reads these resources to display detailed status. +type CNINodeMigration struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata"` + + // Spec can be empty, as all configuration is taken from the parent CNIMigration resource. + Spec CNINodeMigrationSpec `json:"spec"` + // Status defines the observed state of CNINodeMigration. + Status CNINodeMigrationStatus `json:"status"` +} + +// CNINodeMigrationSpec defines the desired state of CNINodeMigration. +// +k8s:deepcopy-gen=true +type CNINodeMigrationSpec struct { + // The spec can be empty, as all configuration is taken from the parent CNIMigration resource. +} + +type CNINodeMigrationStatus struct { + // Conditions are the detailed conditions reflecting the steps performed on the node. + Conditions []metav1.Condition `json:"conditions,omitempty"` +} + +// CNINodeMigrationList contains a list of CNINodeMigration. +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type CNINodeMigrationList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata"` + Items []CNINodeMigration `json:"items"` +} diff --git a/internal/cni/api/v1alpha1/register.go b/internal/cni/api/v1alpha1/register.go new file mode 100644 index 00000000..958d272f --- /dev/null +++ b/internal/cni/api/v1alpha1/register.go @@ -0,0 +1,50 @@ +/* +Copyright 2025 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +const ( + APIGroup = "network.deckhouse.io" + APIVersion = "v1alpha1" +) + +// SchemeGroupVersion is group version used to register these objects +var ( + SchemeGroupVersion = schema.GroupVersion{ + Group: APIGroup, + Version: APIVersion, + } + SchemeBuilder = runtime.NewSchemeBuilder(addKnownTypes) + AddToScheme = SchemeBuilder.AddToScheme +) + +// Adds the list of known types to Scheme. +func addKnownTypes(scheme *runtime.Scheme) error { + scheme.AddKnownTypes(SchemeGroupVersion, + &CNIMigration{}, + &CNIMigrationList{}, + &CNINodeMigration{}, + &CNINodeMigrationList{}, + ) + metav1.AddToGroupVersion(scheme, SchemeGroupVersion) + return nil +} diff --git a/internal/cni/api/v1alpha1/zz_generated.deepcopy.go b/internal/cni/api/v1alpha1/zz_generated.deepcopy.go new file mode 100644 index 00000000..cec3b4d6 --- /dev/null +++ b/internal/cni/api/v1alpha1/zz_generated.deepcopy.go @@ -0,0 +1,238 @@ +//go:build !ignore_autogenerated + +/* +Copyright 2025 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by controller-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" +) + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *CNIMigration) DeepCopyInto(out *CNIMigration) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + out.Spec = in.Spec + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CNIMigration. +func (in *CNIMigration) DeepCopy() *CNIMigration { + if in == nil { + return nil + } + out := new(CNIMigration) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *CNIMigration) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *CNIMigrationList) DeepCopyInto(out *CNIMigrationList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]CNIMigration, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CNIMigrationList. +func (in *CNIMigrationList) DeepCopy() *CNIMigrationList { + if in == nil { + return nil + } + out := new(CNIMigrationList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *CNIMigrationList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *CNIMigrationSpec) DeepCopyInto(out *CNIMigrationSpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CNIMigrationSpec. +func (in *CNIMigrationSpec) DeepCopy() *CNIMigrationSpec { + if in == nil { + return nil + } + out := new(CNIMigrationSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *CNIMigrationStatus) DeepCopyInto(out *CNIMigrationStatus) { + *out = *in + if in.FailedSummary != nil { + in, out := &in.FailedSummary, &out.FailedSummary + *out = make([]FailedNodeSummary, len(*in)) + copy(*out, *in) + } + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CNIMigrationStatus. +func (in *CNIMigrationStatus) DeepCopy() *CNIMigrationStatus { + if in == nil { + return nil + } + out := new(CNIMigrationStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FailedNodeSummary) DeepCopyInto(out *FailedNodeSummary) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FailedNodeSummary. +func (in *FailedNodeSummary) DeepCopy() *FailedNodeSummary { + if in == nil { + return nil + } + out := new(FailedNodeSummary) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *CNINodeMigration) DeepCopyInto(out *CNINodeMigration) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + out.Spec = in.Spec + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CNINodeMigration. +func (in *CNINodeMigration) DeepCopy() *CNINodeMigration { + if in == nil { + return nil + } + out := new(CNINodeMigration) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *CNINodeMigration) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *CNINodeMigrationList) DeepCopyInto(out *CNINodeMigrationList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]CNINodeMigration, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CNINodeMigrationList. +func (in *CNINodeMigrationList) DeepCopy() *CNINodeMigrationList { + if in == nil { + return nil + } + out := new(CNINodeMigrationList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *CNINodeMigrationList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *CNINodeMigrationSpec) DeepCopyInto(out *CNINodeMigrationSpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CNINodeMigrationSpec. +func (in *CNINodeMigrationSpec) DeepCopy() *CNINodeMigrationSpec { + if in == nil { + return nil + } + out := new(CNINodeMigrationSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *CNINodeMigrationStatus) DeepCopyInto(out *CNINodeMigrationStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CNINodeMigrationStatus. +func (in *CNINodeMigrationStatus) DeepCopy() *CNINodeMigrationStatus { + if in == nil { + return nil + } + out := new(CNINodeMigrationStatus) + in.DeepCopyInto(out) + return out +} diff --git a/internal/cni/cleanup.go b/internal/cni/cleanup.go new file mode 100644 index 00000000..5305e9d1 --- /dev/null +++ b/internal/cni/cleanup.go @@ -0,0 +1,71 @@ +/* +Copyright 2025 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cni + +import ( + "context" + "fmt" + + "k8s.io/apimachinery/pkg/api/errors" + + "github.com/deckhouse/deckhouse-cli/internal/cni/api/v1alpha1" + saferequest "github.com/deckhouse/deckhouse-cli/pkg/libsaferequest/client" +) + +// RunCleanup executes the logic for the 'cni-migration cleanup' command. +func RunCleanup() error { + ctx := context.Background() + + fmt.Println("🚀 Starting CNI switch cleanup") + + // Create a Kubernetes client + safeClient, err := saferequest.NewSafeClient() + if err != nil { + return fmt.Errorf("creating safe client: %w", err) + } + + rtClient, err := safeClient.NewRTClient(v1alpha1.AddToScheme) + if err != nil { + return fmt.Errorf("creating runtime client: %w", err) + } + + // Find and delete all CNIMigration resources + migrations := &v1alpha1.CNIMigrationList{} + if err := rtClient.List(ctx, migrations); err != nil { + return fmt.Errorf("listing CNIMigrations: %w", err) + } + + if len(migrations.Items) == 0 { + fmt.Println("✅ No active migrations found") + return nil + } + + for _, m := range migrations.Items { + fmt.Printf("Deleting CNIMigration '%s'...", m.Name) + if err := rtClient.Delete(ctx, &m); err != nil { + if !errors.IsNotFound(err) { + return fmt.Errorf("deleting CNIMigration %s: %w", m.Name, err) + } + fmt.Println(" already deleted") + } else { + fmt.Println(" done") + } + } + + fmt.Println("🎉 Cleanup triggered. The cluster-internal controllers will handle the rest") + return nil +} diff --git a/internal/cni/common.go b/internal/cni/common.go new file mode 100644 index 00000000..7342c52f --- /dev/null +++ b/internal/cni/common.go @@ -0,0 +1,102 @@ +/* +Copyright 2025 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cni + +import ( + "bufio" + "context" + "fmt" + "os" + "strings" + + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/deckhouse/deckhouse-cli/internal/cni/api/v1alpha1" +) + +// AskForConfirmation displays a warning and prompts the user for confirmation. +func AskForConfirmation(commandName string) (bool, error) { + reader := bufio.NewReader(os.Stdin) + + fmt.Println("--------------------------------------------------------------------------------") + fmt.Println("âš ī¸ IMPORTANT: PLEASE READ CAREFULLY") + fmt.Println("--------------------------------------------------------------------------------") + fmt.Println() + fmt.Printf("You are about to run the '%s' step of the CNI switch process. Please ensure that:\n\n", commandName) + fmt.Println("1. External cluster management systems (CI/CD, GitOps like ArgoCD, Flux)") + fmt.Println(" are temporarily disabled. They might interfere with the CNI switch process") + fmt.Println(" by reverting changes made by this tool.") + fmt.Println() + fmt.Println("2. This tool is NOT intended for switching to any (third-party) CNI.") + fmt.Println() + fmt.Println("3. The utility does not configure CNI modules in the cluster; it only enables/disables") + fmt.Println(" them via ModuleConfig during operation. The user must independently prepare the") + fmt.Println(" ModuleConfig configuration for the target CNI.") + fmt.Println() + fmt.Println("4. During the migration, all pods in the cluster using the network (PodNetwork)") + fmt.Println(" created by the current CNI will be restarted. This will cause service interruption.") + fmt.Println(" To minimize the risk of critical data loss, it is highly recommended to manually") + fmt.Println(" stop the most critical services before performing the work.") + fmt.Println() + fmt.Println("5. It is recommended to perform the work during an agreed maintenance window.") + fmt.Println() + fmt.Println("Once the process starts, no active intervention is required from you.") + fmt.Println() + fmt.Print("Do you want to continue? (y/n): ") + + for { + response, err := reader.ReadString('\n') + if err != nil { + return false, err + } + + response = strings.ToLower(strings.TrimSpace(response)) + + switch response { + case "y", "yes": + fmt.Println() + return true, nil + case "n", "no": + fmt.Println() + return false, nil + default: + fmt.Print("Invalid input. Please enter 'y/yes' or 'n/no'): ") + } + } +} + +// FindActiveMigration searches for an existing CNIMigration resource. +func FindActiveMigration(ctx context.Context, rtClient client.Client) (*v1alpha1.CNIMigration, error) { + migrationList := &v1alpha1.CNIMigrationList{} + if err := rtClient.List(ctx, migrationList); err != nil { + return nil, fmt.Errorf("listing CNIMigration objects: %w", err) + } + + if len(migrationList.Items) == 0 { + return nil, nil // No migration found + } + + if len(migrationList.Items) > 1 { + return nil, fmt.Errorf( + "found %d CNI migration objects, which is an inconsistent state. "+ + "Please run 'd8 cni-migration cleanup' to resolve this", + len(migrationList.Items), + ) + } + + return &migrationList.Items[0], nil +} diff --git a/internal/cni/switch.go b/internal/cni/switch.go new file mode 100644 index 00000000..35cd6c7a --- /dev/null +++ b/internal/cni/switch.go @@ -0,0 +1,94 @@ +/* +Copyright 2025 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cni + +import ( + "context" + "errors" + "fmt" + "time" + + k8serrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/deckhouse/deckhouse-cli/internal/cni/api/v1alpha1" + saferequest "github.com/deckhouse/deckhouse-cli/pkg/libsaferequest/client" +) + +var ErrCancelled = errors.New("cancelled") + +// RunSwitch executes the logic for the 'cni-migration switch' command. +func RunSwitch(targetCNI string) error { + // Ask for user confirmation + confirmed, err := AskForConfirmation("switch") + if err != nil { + return fmt.Errorf("asking for confirmation: %w", err) + } + if !confirmed { + fmt.Println("Operation cancelled by user") + return ErrCancelled + } + + fmt.Printf("🚀 Starting CNI switch for target '%s'\n", targetCNI) + + // Create a Kubernetes client + safeClient, err := saferequest.NewSafeClient() + if err != nil { + return fmt.Errorf("creating safe client: %w", err) + } + + rtClient, err := safeClient.NewRTClient(v1alpha1.AddToScheme) + if err != nil { + return fmt.Errorf("creating runtime client: %w", err) + } + + // Check for existing migration + existingMigration, err := FindActiveMigration(context.Background(), rtClient) + if err != nil { + return fmt.Errorf("checking for existing migration: %w", err) + } + if existingMigration != nil { + return fmt.Errorf("a CNI migration (%s) is already in progress. "+ + "Please use 'd8 cni-migration watch' to monitor it or 'd8 cni-migration cleanup' to abort it", + existingMigration.Name) + } + + // Create the CNIMigration resource + migrationName := fmt.Sprintf("cni-migration-%s", time.Now().Format("20060102-150405")) + newMigration := &v1alpha1.CNIMigration{ + ObjectMeta: metav1.ObjectMeta{ + Name: migrationName, + }, + Spec: v1alpha1.CNIMigrationSpec{ + TargetCNI: targetCNI, + }, + } + + if err := rtClient.Create(context.Background(), newMigration); err != nil { + if k8serrors.IsAlreadyExists(err) { + fmt.Printf("â„šī¸ Migration '%s' already exists\n", migrationName) + } else { + return fmt.Errorf("creating CNIMigration: %w", err) + } + } else { + fmt.Printf("✅ CNIMigration '%s' created\n", migrationName) + } + + fmt.Println("The migration is now being handled automatically by the cluster") + + return nil +} diff --git a/internal/cni/watch.go b/internal/cni/watch.go new file mode 100644 index 00000000..d3a2c887 --- /dev/null +++ b/internal/cni/watch.go @@ -0,0 +1,217 @@ +/* +Copyright 2025 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cni + +import ( + "context" + "errors" + "fmt" + "sort" + "strings" + "time" + + "github.com/mitchellh/go-wordwrap" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/deckhouse/deckhouse-cli/internal/cni/api/v1alpha1" + saferequest "github.com/deckhouse/deckhouse-cli/pkg/libsaferequest/client" +) + +var ErrMigrationFailed = errors.New("migration failed") + +// RunWatch executes the logic for the 'cni-migration watch' command. +func RunWatch() error { + ctx := context.Background() + + fmt.Println("🚀 Monitoring CNI switch progress") + + // Create a Kubernetes client + safeClient, err := saferequest.NewSafeClient() + if err != nil { + return fmt.Errorf("creating safe client: %w", err) + } + + rtClient, err := safeClient.NewRTClient(v1alpha1.AddToScheme) + if err != nil { + return fmt.Errorf("creating runtime client: %w", err) + } + + ticker := time.NewTicker(500 * time.Millisecond) + defer ticker.Stop() + + var ( + migrationName string + printedEvents = make(map[string]bool) + footerLines int // Number of lines in the dynamic footer (progress + errors) + ) + + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + activeMigration, err := FindActiveMigration(ctx, rtClient) + if err != nil { + // Clean previous footer before printing warning + clearFooter(footerLines) + fmt.Printf("âš ī¸ Error finding active migration: %v\n", err) + footerLines = 1 // We printed one line + continue + } + + if activeMigration == nil { + clearFooter(footerLines) + // If we were watching a migration and it disappeared, it's done or deleted. + if migrationName != "" { + fmt.Println("â„šī¸ Migration resource is gone") + } else { + fmt.Println("â„šī¸ No active migration found") + } + return nil + } + + // Clear previous footer to verify if we need to print new logs + clearFooter(footerLines) + footerLines = 0 + + // Print migration name once + if migrationName == "" { + migrationName = activeMigration.Name + fmt.Printf("[%s] â„šī¸ Monitoring migration resource: %s\n", + activeMigration.CreationTimestamp.Format("15:04:05"), + migrationName) + } + + // Sort conditions by transition time + conditions := activeMigration.Status.Conditions + sort.Slice(conditions, func(i, j int) bool { + return conditions[i].LastTransitionTime.Before(&conditions[j].LastTransitionTime) + }) + + // Track time of the last completed step to calculate deltas + lastStepTime := activeMigration.CreationTimestamp.Time + + // Print new conditions + var currentProgressMsg string + for _, c := range conditions { + // Track InProgress message (latest wins due to sort) + if c.Reason == "InProgress" { + currentProgressMsg = c.Message + continue // Don't print InProgress to log + } + + // Create unique key for the event + eventKey := fmt.Sprintf("%s|%s|%s|%s|%s", + c.Type, c.Status, c.Reason, c.Message, c.LastTransitionTime.Time.String()) + + var icon string + shouldPrint := false + + switch { + case c.Status == metav1.ConditionTrue: + icon = "✅" + shouldPrint = true + case c.Status == metav1.ConditionFalse && c.Reason == "Error": + icon = "❌" + shouldPrint = true + } + + if shouldPrint { + // Calculate duration since the last completed step + stepDuration := c.LastTransitionTime.Time.Sub(lastStepTime) + + // Only print if not already printed + if !printedEvents[eventKey] { + fmt.Printf("[%s] %s %s: %s (+%s)\n", + c.LastTransitionTime.Format("15:04:05"), + icon, + c.Type, + c.Message, + stepDuration.Round(time.Second)) + printedEvents[eventKey] = true + } + + // Update the reference time for the next step + lastStepTime = c.LastTransitionTime.Time + } + + if c.Status == metav1.ConditionFalse && c.Reason == "Error" { + return ErrMigrationFailed + } + } + + // Draw Footer + // 0. Phase + if activeMigration.Status.Phase != "" { + fmt.Printf(" Phase: %s\n", activeMigration.Status.Phase) + footerLines++ + } + + // 0.5 Current Progress (Dynamic) + if currentProgressMsg != "" { + // Use wordwrap for long progress messages too + wrapped := wordwrap.WrapString(currentProgressMsg, 100) + lines := strings.Split(wrapped, "\n") + fmt.Printf(" âŗ %s\n", lines[0]) + footerLines++ + for _, line := range lines[1:] { + fmt.Printf(" %s\n", line) + footerLines++ + } + } + + // 1. Failed Nodes Details (Critical info only) + if len(activeMigration.Status.FailedSummary) > 0 { + fmt.Printf(" âš ī¸ Failed Nodes (%d):\n", len(activeMigration.Status.FailedSummary)) + footerLines++ + for _, f := range activeMigration.Status.FailedSummary { + // Wrap error message at 100 chars + wrapped := wordwrap.WrapString(f.Reason, 100) + lines := strings.Split(wrapped, "\n") + + // Print first line with node name + fmt.Printf(" - %s: %s\n", f.Node, lines[0]) + footerLines++ + + // Print subsequent lines with indentation + indent := strings.Repeat(" ", 6+len(f.Node)+2) // " - " + node + ": " + for _, line := range lines[1:] { + fmt.Printf("%s%s\n", indent, line) + footerLines++ + } + } + } + + // Check for completion + for _, cond := range activeMigration.Status.Conditions { + if cond.Type == v1alpha1.ConditionSucceeded && cond.Status == metav1.ConditionTrue { + totalDuration := cond.LastTransitionTime.Time.Sub(activeMigration.CreationTimestamp.Time) + fmt.Printf("🎉 CNI switch to '%s' completed successfully! (Total time: %s)\n", + activeMigration.Spec.TargetCNI, + totalDuration.Round(time.Second)) + return nil + } + } + } + } +} + +func clearFooter(lines int) { + for range lines { + fmt.Print("\033[1A\033[K") // Move up and clear line + } +}