diff --git a/.gitignore b/.gitignore index 30a43a92..8680e16b 100644 --- a/.gitignore +++ b/.gitignore @@ -37,4 +37,4 @@ dist/ build/ # Entrypoint for the application -!/cmd/d8 \ No newline at end of file +!/cmd/d8 diff --git a/cmd/commands/cni.go b/cmd/commands/cni.go new file mode 100644 index 00000000..b78b34d6 --- /dev/null +++ b/cmd/commands/cni.go @@ -0,0 +1,166 @@ +/* +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 ( + "fmt" + "log" + "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 = templates.LongDesc(` +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-switch 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-switch watch' - (Optional) Monitors the progress of the migration. + Since the process is automated, this command simply watches the status. + + 3. 'd8 cni-switch cleanup' - Cleans up the migration resources after completion. +`) + + cniSwitchExample = templates.Examples(` + # Start the migration to Cilium CNI + d8 cni-switch switch --to-cni cilium`) + + cniWatchExample = templates.Examples(` + # Monitor the ongoing migration + d8 cni-switch watch`) + + cniCleanupExample = templates.Examples(` + # Cleanup resources created by the 'switch' command + d8 cni-switch cleanup`) + + cniRollbackExample = templates.Examples(` + # Rollback changes, restore previous CNI, cleanup resources created by the 'switch' command + d8 cni-switch rollback`) // TODO(tden): Remove functionality, delete internal/cni/rollback.go? + + supportedCNIs = []string{"cilium", "flannel", "simple-bridge"} +) + +func NewCniSwitchCommand() *cobra.Command { + log.SetFlags(0) + ctrllog.SetLogger(logr.Discard()) + + cmd := &cobra.Command{ + Use: "cni-switch", + Short: "A group of commands to switch CNI in the cluster", + Long: cniSwitchLong, + } + cmd.AddCommand(NewCmdCniSwitch()) + cmd.AddCommand(NewCmdCniWatch()) + cmd.AddCommand(NewCmdCniCleanup()) + cmd.AddCommand(NewCmdCniRollback()) + 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 { + log.Fatalf("❌ Error running switch command: %v", err) + } + + fmt.Println() + if err := cni.RunWatch(); err != nil { + 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(cmd *cobra.Command, _ []string) { + if err := cni.RunWatch(); err != nil { + 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(cmd *cobra.Command, _ []string) { + if err := cni.RunCleanup(); err != nil { + log.Fatalf("❌ Error running cleanup command: %v", err) + } + }, + } + return cmd +} + +func NewCmdCniRollback() *cobra.Command { // TODO(tden): It needs to be done! + cmd := &cobra.Command{ + Use: "rollback", + Short: "Rollback all changes and restore previous CNI", + Example: cniRollbackExample, + Run: func(cmd *cobra.Command, _ []string) { + if err := cni.RunRollback(); err != nil { + log.Fatalf("❌ Error running rollback command: %v", err) + } + }, + } + 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..2869e0c8 --- /dev/null +++ b/internal/cni/api/v1alpha1/cni_migration_types.go @@ -0,0 +1,78 @@ +/* +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"` +} + +// 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"` + // 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..cbc9d72f --- /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-switch 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..6b43bc89 --- /dev/null +++ b/internal/cni/common.go @@ -0,0 +1,97 @@ +/* +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. You have sufficient administrative privileges for this cluster to perform") + fmt.Println(" the required actions (modifying ModuleConfigs, deleting pods, etc.).") + 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("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. +// It returns an error if more than one migration is found. +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-switch cleanup' to resolve this", + len(migrationList.Items), + ) + } + + return &migrationList.Items[0], nil +} diff --git a/internal/cni/rollback.go b/internal/cni/rollback.go new file mode 100644 index 00000000..92428296 --- /dev/null +++ b/internal/cni/rollback.go @@ -0,0 +1,27 @@ +/* +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 ( + "fmt" +) + +// RunRollback executes the logic for the 'cni-switch rollback' command. +func RunRollback() error { + fmt.Println("Logic for rollback is not implemented yet.") + return nil +} diff --git a/internal/cni/switch.go b/internal/cni/switch.go new file mode 100644 index 00000000..a07ddd71 --- /dev/null +++ b/internal/cni/switch.go @@ -0,0 +1,81 @@ +/* +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" + "time" + + "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" +) + +// RunSwitch executes the logic for the 'cni-switch 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 nil + } + + 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) + } + + // 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 errors.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("🎉 Switch triggered.") + 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..df1929d6 --- /dev/null +++ b/internal/cni/watch.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 cni + +import ( + "context" + "fmt" + "sort" + "time" + + 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" +) + +// RunWatch executes the logic for the 'cni-switch 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(2 * time.Second) + defer ticker.Stop() + + var ( + migrationName string + lastPrintedTime time.Time + 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("ℹ️ Monitoring migration resource: %s\n", migrationName) + } + + // Collect and sort valid conditions + var validConditions []metav1.Condition + for _, c := range activeMigration.Status.Conditions { + if c.Status == metav1.ConditionTrue { + validConditions = append(validConditions, c) + } + } + + sort.Slice(validConditions, func(i, j int) bool { + return validConditions[i].LastTransitionTime.Before(&validConditions[j].LastTransitionTime) + }) + + // Print new conditions + for _, c := range validConditions { + if c.LastTransitionTime.Time.After(lastPrintedTime) { + fmt.Printf("[%s] %s: %s\n", + c.LastTransitionTime.Format("15:04:05"), + c.Type, + c.Message) + lastPrintedTime = c.LastTransitionTime.Time + } + } + + // Draw Footer + // 1. Node Progress + if activeMigration.Status.NodesTotal > 0 { + fmt.Printf(" Nodes: %d/%d succeeded", + activeMigration.Status.NodesSucceeded, + activeMigration.Status.NodesTotal) + if activeMigration.Status.NodesFailed > 0 { + fmt.Printf(", %d failed", activeMigration.Status.NodesFailed) + } + fmt.Println() // End of progress line + footerLines++ + + // 2. Failed Nodes Details + if len(activeMigration.Status.FailedSummary) > 0 { + fmt.Println(" ⚠️ Failed Nodes:") + footerLines++ + for _, f := range activeMigration.Status.FailedSummary { + fmt.Printf(" - %s: %s\n", f.Node, f.Reason) + footerLines++ + } + } + } else { + // Placeholder if no nodes stats yet + fmt.Println(" Waiting for node statistics...") + footerLines++ + } + + // Check for completion + for _, cond := range activeMigration.Status.Conditions { + if cond.Type == "Succeeded" && cond.Status == metav1.ConditionTrue { + fmt.Printf("\n🎉 CNI switch to '%s' completed successfully!\n", + activeMigration.Spec.TargetCNI) + return nil + } + } + } + } +} + +func clearFooter(lines int) { + for range lines { + fmt.Print("\033[1A\033[K") // Move up and clear line + } +}