From fe0f4e07c454ac58727c522f8d794663987b3dd9 Mon Sep 17 00:00:00 2001 From: Denis Tarabrin Date: Mon, 2 Feb 2026 16:50:27 +0400 Subject: [PATCH 1/2] 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 | 157 ++++++++++++ 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 | 233 +++++++++++++++++ 10 files changed, 1089 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..9e1cb067 --- /dev/null +++ b/cmd/commands/cni.go @@ -0,0 +1,157 @@ +/* +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/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 DKP. + +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.Fatalf("❌ Error running switch command: %v", err) + } + + fmt.Println() + if err := cni.RunWatch(); err != nil { + if errors.Is(err, cni.ErrMigrationFailed) { + os.Exit(1) + } + log.Fatalf("❌ Error monitoring switch progress: %v", err) + } + }, + } + 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(_ *cobra.Command, _ []string) { + if err := cni.RunWatch(); err != nil { + if errors.Is(err, cni.ErrMigrationFailed) { + os.Exit(1) + } + log.Fatalf("❌ Error running watch command: %v", err) + } + }, + } + return cmd +} + +func NewCmdCniCleanup() *cobra.Command { + cmd := &cobra.Command{ + Use: "cleanup", + Short: "Cleans up resources created during CNI switching", + Example: cniCleanupExample, + Run: func(_ *cobra.Command, _ []string) { + if err := cni.RunCleanup(); err != nil { + log.Fatalf("❌ Error running cleanup command: %v", err) + } + }, + } + return cmd +} diff --git a/cmd/d8/root.go b/cmd/d8/root.go index 0325c1a3..38858f83 100644 --- a/cmd/d8/root.go +++ b/cmd/d8/root.go @@ -109,6 +109,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)) if os.Getenv("DECKHOUSE_PLUGINS_ENABLED") != "true" { 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..125a65f6 --- /dev/null +++ b/internal/cni/watch.go @@ -0,0 +1,233 @@ +/* +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" + "os" + "sort" + "strings" + "time" + + "github.com/mitchellh/go-wordwrap" + "golang.org/x/term" + 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") + + 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 visual lines in the dynamic footer + ) + + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + activeMigration, err := FindActiveMigration(ctx, rtClient) + if err != nil { + // Clear footer before warning + clearFooter(footerLines) + fmt.Printf("âš ī¸ Error finding active migration: %v\n", err) + footerLines = 1 + continue + } + + if activeMigration == nil { + clearFooter(footerLines) + // Migration resource disappeared + if migrationName != "" { + fmt.Println("🔎 Migration resource is gone") + } else { + fmt.Println("🔎 No active migration found") + } + return nil + } + + // Clear footer to print new logs + clearFooter(footerLines) + footerLines = 0 + + // Print migration info once + if migrationName == "" { + migrationName = activeMigration.Name + fmt.Printf("[%s] 🔎 Monitoring migration resource: %s\n", + activeMigration.CreationTimestamp.Format("15:04:05"), + migrationName) + } + + // Sort conditions by time + conditions := activeMigration.Status.Conditions + sort.Slice(conditions, func(i, j int) bool { + return conditions[i].LastTransitionTime.Before(&conditions[j].LastTransitionTime) + }) + + // Track last step completion time + lastStepTime := activeMigration.CreationTimestamp.Time + + // Process conditions + for _, c := range conditions { + // Deduplicate events + 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 + isProgress := false + + switch { + case c.Status == metav1.ConditionTrue: + icon = "✅" + shouldPrint = true + case c.Status == metav1.ConditionFalse && c.Reason == "Error": + icon = "❌" + shouldPrint = true + case c.Reason == "InProgress": + icon = " " + shouldPrint = true + isProgress = true + } + + if shouldPrint { + if !printedEvents[eventKey] { + if isProgress { + fmt.Printf("[%s] %s %s: %s\n", + c.LastTransitionTime.Format("15:04:05"), + icon, + c.Type, + c.Message) + } else { + stepDuration := c.LastTransitionTime.Time.Sub(lastStepTime) + + 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 reference time for completed steps + if !isProgress { + lastStepTime = c.LastTransitionTime.Time + } + } + + if c.Status == metav1.ConditionFalse && c.Reason == "Error" { + return ErrMigrationFailed + } + } + + // Print Failed Nodes + for _, f := range activeMigration.Status.FailedSummary { + failKey := fmt.Sprintf("fail|%s|%s", f.Node, f.Reason) + if !printedEvents[failKey] { + fmt.Printf("âš ī¸ Node %s failed: %s\n", f.Node, f.Reason) + printedEvents[failKey] = true + } + } + + // Update status footer + phaseMsg := "" + if activeMigration.Status.Phase != "" { + phaseMsg = fmt.Sprintf(" Phase: %s", activeMigration.Status.Phase) + + if count := len(activeMigration.Status.FailedSummary); count > 0 { + phaseMsg += fmt.Sprintf(" (Failed Nodes: %d)", count) + } + } + + if phaseMsg != "" { + termWidth := getTermWidth() + if termWidth <= 0 { + termWidth = 80 + } + lines := printFooter(phaseMsg, termWidth) + footerLines += lines + } + + // Check 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 + } +} + +func getTermWidth() int { + width, _, err := term.GetSize(int(os.Stdout.Fd())) + if err != nil { + return 80 + } + return width +} + +// printFooter prints text wrapped to fit within width and returns the number of lines printed. +func printFooter(text string, width int) int { + // If the text is short enough, just print it + if len(text) <= width { + fmt.Println(text) + return 1 + } + + // Use wordwrap to split into lines + wrapped := wordwrap.WrapString(text, uint(width)) + fmt.Println(wrapped) + + return strings.Count(wrapped, "\n") + 1 +} From 9f6760428281dff651559ee1ae55742bbb4ac270 Mon Sep 17 00:00:00 2001 From: Denis Tarabrin Date: Fri, 20 Feb 2026 21:11:37 +0400 Subject: [PATCH 2/2] Increase watch ticker to 1 second Update error handling in the watch loop to correctly format and print errors using the existing footer mechanism. Also, display the current time instead of the last transition time for events, and update the success message to clear the footer. Signed-off-by: Denis Tarabrin --- internal/cni/watch.go | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/internal/cni/watch.go b/internal/cni/watch.go index 125a65f6..d68240fd 100644 --- a/internal/cni/watch.go +++ b/internal/cni/watch.go @@ -51,7 +51,7 @@ func RunWatch() error { return fmt.Errorf("creating runtime client: %w", err) } - ticker := time.NewTicker(500 * time.Millisecond) + ticker := time.NewTicker(1000 * time.Millisecond) defer ticker.Stop() var ( @@ -69,8 +69,14 @@ func RunWatch() error { if err != nil { // Clear footer before warning clearFooter(footerLines) - fmt.Printf("âš ī¸ Error finding active migration: %v\n", err) - footerLines = 1 + msg := fmt.Sprintf("âš ī¸ Error finding active migration: %v", err) + + termWidth := getTermWidth() + if termWidth <= 0 { + termWidth = 80 + } + footerLines = printFooter(msg, termWidth) + continue } @@ -133,7 +139,7 @@ func RunWatch() error { if !printedEvents[eventKey] { if isProgress { fmt.Printf("[%s] %s %s: %s\n", - c.LastTransitionTime.Format("15:04:05"), + time.Now().Format("15:04:05"), icon, c.Type, c.Message) @@ -141,7 +147,7 @@ func RunWatch() error { stepDuration := c.LastTransitionTime.Time.Sub(lastStepTime) fmt.Printf("[%s] %s %s: %s (+%s)\n", - c.LastTransitionTime.Format("15:04:05"), + time.Now().Format("15:04:05"), icon, c.Type, c.Message, @@ -155,10 +161,6 @@ func RunWatch() error { lastStepTime = c.LastTransitionTime.Time } } - - if c.Status == metav1.ConditionFalse && c.Reason == "Error" { - return ErrMigrationFailed - } } // Print Failed Nodes @@ -192,6 +194,7 @@ func RunWatch() error { // Check completion for _, cond := range activeMigration.Status.Conditions { if cond.Type == v1alpha1.ConditionSucceeded && cond.Status == metav1.ConditionTrue { + clearFooter(footerLines) totalDuration := cond.LastTransitionTime.Time.Sub(activeMigration.CreationTimestamp.Time) fmt.Printf("🎉 CNI switch to '%s' completed successfully! (Total time: %s)\n", activeMigration.Spec.TargetCNI,