From 771399b9ca57a8aaf1dda806289b8c522f3a00b4 Mon Sep 17 00:00:00 2001 From: SzymonSAP Date: Mon, 31 Mar 2025 14:29:09 +0200 Subject: [PATCH 1/3] Add bootctl move command --- cmd/bootctl/app/app.go | 32 ++++++ cmd/bootctl/app/move.go | 79 ++++++++++++++ cmd/bootctl/main.go | 19 ++++ cmdutils/clients.go | 11 ++ cmdutils/move.go | 235 ++++++++++++++++++++++++++++++++++++++++ cmdutils/move_test.go | 101 +++++++++++++++++ cmdutils/suite_test.go | 105 ++++++++++++++++++ cmdutils/utils.go | 24 ++++ docs/usage/bootctl.md | 56 ++++++++++ go.mod | 2 + go.sum | 7 ++ mkdocs.yml | 2 + 12 files changed, 673 insertions(+) create mode 100644 cmd/bootctl/app/app.go create mode 100644 cmd/bootctl/app/move.go create mode 100644 cmd/bootctl/main.go create mode 100644 cmdutils/clients.go create mode 100644 cmdutils/move.go create mode 100644 cmdutils/move_test.go create mode 100644 cmdutils/suite_test.go create mode 100644 cmdutils/utils.go create mode 100644 docs/usage/bootctl.md diff --git a/cmd/bootctl/app/app.go b/cmd/bootctl/app/app.go new file mode 100644 index 00000000..a6852301 --- /dev/null +++ b/cmd/bootctl/app/app.go @@ -0,0 +1,32 @@ +// SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package app + +import ( + "github.com/spf13/cobra" + "k8s.io/apimachinery/pkg/runtime" + + bootv1alphav1 "github.com/ironcore-dev/boot-operator/api/v1alpha1" + metalv1alpha1 "github.com/ironcore-dev/metal-operator/api/v1alpha1" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" +) + +const Name string = "bootctl" + +var scheme = runtime.NewScheme() + +func init() { + utilruntime.Must(bootv1alphav1.AddToScheme(scheme)) + utilruntime.Must(metalv1alpha1.AddToScheme(scheme)) +} + +func NewCommand() *cobra.Command { + root := &cobra.Command{ + Use: Name, + Short: "CLI client for boot-operator", + Args: cobra.NoArgs, + } + root.AddCommand(NewMoveCommand()) + return root +} diff --git a/cmd/bootctl/app/move.go b/cmd/bootctl/app/move.go new file mode 100644 index 00000000..41ed50ec --- /dev/null +++ b/cmd/bootctl/app/move.go @@ -0,0 +1,79 @@ +// SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package app + +import ( + "fmt" + "log/slog" + + "github.com/spf13/cobra" + "k8s.io/client-go/tools/clientcmd" + "sigs.k8s.io/controller-runtime/pkg/client" + + utils "github.com/ironcore-dev/boot-operator/cmdutils" +) + +var ( + sourceKubeconfig string + targetKubeconfig string + namespace string + requireOwners bool + dryRun bool + verbose bool +) + +func NewMoveCommand() *cobra.Command { + move := &cobra.Command{ + Use: "move", + Short: "Move boot-operator CRs from one cluster to another", + RunE: runMove, + } + move.Flags().StringVar(&sourceKubeconfig, "source-kubeconfig", "", "Kubeconfig pointing to the source cluster") + move.Flags().StringVar(&targetKubeconfig, "target-kubeconfig", "", "Kubeconfig pointing to the target cluster") + move.Flags().StringVar(&namespace, "namespace", "", + "namespace to filter CRs to migrate. Defaults to all namespaces if not specified") + move.Flags().BoolVar(&requireOwners, "require-owners", false, "if set to true, an error will be returned if for any custom resource an owner ServerBootConfiguration is not present in the target cluster") + move.Flags().BoolVar(&dryRun, "dry-run", false, "show what would be moved without executing the migration") + move.Flags().BoolVar(&verbose, "verbose", false, "enable verbose logging for detailed output during migration") + _ = move.MarkFlagRequired("source-kubeconfig") + _ = move.MarkFlagRequired("target-kubeconfig") + + if verbose { + slog.SetLogLoggerLevel(slog.LevelDebug) + } + return move +} + +func makeClient(kubeconfig string) (client.Client, error) { + cfg, err := clientcmd.BuildConfigFromFlags("", kubeconfig) + if err != nil { + return nil, fmt.Errorf("failed to load cluster kubeconfig: %w", err) + } + return client.New(cfg, client.Options{Scheme: scheme}) +} + +func makeClients() (utils.Clients, error) { + var clients utils.Clients + var err error + + clients.Source, err = makeClient(sourceKubeconfig) + if err != nil { + return clients, fmt.Errorf("failed to construct a source cluster client: %w", err) + } + clients.Target, err = makeClient(targetKubeconfig) + if err != nil { + return clients, fmt.Errorf("failed to construct a target cluster client: %w", err) + } + return clients, nil +} + +func runMove(cmd *cobra.Command, args []string) error { + clients, err := makeClients() + if err != nil { + return err + } + ctx := cmd.Context() + + return utils.Move(ctx, clients, scheme, namespace, requireOwners, dryRun) +} diff --git a/cmd/bootctl/main.go b/cmd/bootctl/main.go new file mode 100644 index 00000000..90e4c4ed --- /dev/null +++ b/cmd/bootctl/main.go @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "fmt" + "os" + + "github.com/ironcore-dev/boot-operator/cmd/bootctl/app" + "sigs.k8s.io/controller-runtime/pkg/manager/signals" +) + +func main() { + if err := app.NewCommand().ExecuteContext(signals.SetupSignalHandler()); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/cmdutils/clients.go b/cmdutils/clients.go new file mode 100644 index 00000000..040bd72e --- /dev/null +++ b/cmdutils/clients.go @@ -0,0 +1,11 @@ +// SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package cmdutils + +import "sigs.k8s.io/controller-runtime/pkg/client" + +type Clients struct { + Source client.Client + Target client.Client +} diff --git a/cmdutils/move.go b/cmdutils/move.go new file mode 100644 index 00000000..e09709bc --- /dev/null +++ b/cmdutils/move.go @@ -0,0 +1,235 @@ +// SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package cmdutils + +import ( + "context" + "errors" + "fmt" + "log/slog" + "reflect" + "slices" + + bootv1alphav1 "github.com/ironcore-dev/boot-operator/api/v1alpha1" + metalv1alpha1 "github.com/ironcore-dev/metal-operator/api/v1alpha1" + v1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" +) + +func Move( + ctx context.Context, + clients Clients, + scheme *runtime.Scheme, + namespace string, + requireOwners bool, + dryRun bool, +) error { + httpConfigs, ipxeConfigs, err := getCrs(ctx, clients.Source, namespace) + if err != nil { + return err + } + slog.Debug(fmt.Sprintf("found %s CRs in the source cluster", bootv1alphav1.GroupVersion.Group), + slog.Any("http boot configs", transform(httpConfigs, HTTPBootConfigName)), + slog.Any("ipxe boot configs", transform(ipxeConfigs, IPXEBootConfigName))) + + objsToMove, err := getObjsToBeMoved(ctx, clients, httpConfigs, ipxeConfigs) + if err != nil { + return err + } + slog.Debug("moving", slog.Any("objects", transform(objsToMove, objName))) + + if !dryRun { + var movedObjs []client.Object + if movedObjs, err = moveObjs(ctx, clients.Target, scheme, objsToMove, requireOwners); err != nil { + cleanupErr := cleanup(ctx, clients.Target, movedObjs) + err = errors.Join(err, + fmt.Errorf("clean up of already moved objects was performed to restore a target cluster's state with error result: %w", + cleanupErr)) + } else { + slog.Debug(fmt.Sprintf("all %s CRs with theirs secrets from the source cluster were moved to the target cluster", + bootv1alphav1.GroupVersion.Group)) + } + } + + return err +} + +func getCrs( + ctx context.Context, + cl client.Client, + namespace string, +) ([]bootv1alphav1.HTTPBootConfig, []bootv1alphav1.IPXEBootConfig, error) { + httpBootConfigList := &bootv1alphav1.HTTPBootConfigList{} + if err := cl.List(ctx, httpBootConfigList, &client.ListOptions{Namespace: namespace}); err != nil { + return nil, nil, fmt.Errorf("couldn't list HTTPBootConfigs: %w", err) + } + IPXEBootConfigList := &bootv1alphav1.IPXEBootConfigList{} + if err := cl.List(ctx, IPXEBootConfigList, &client.ListOptions{Namespace: namespace}); err != nil { + return nil, nil, fmt.Errorf("couldn't list IPXEBootConfigs: %w", err) + } + return httpBootConfigList.Items, IPXEBootConfigList.Items, nil +} + +func getObjsToBeMoved( + ctx context.Context, + clients Clients, + sourceHttpCrs []bootv1alphav1.HTTPBootConfig, + sourceIpxeCrs []bootv1alphav1.IPXEBootConfig, +) ([]client.Object, error) { + objsToMove := make([]client.Object, 0, 2*(len(sourceHttpCrs)+len(sourceIpxeCrs))) + uidToSecretNameMap := getUidToSecretNameMap(sourceHttpCrs, sourceIpxeCrs) + httpObjs := transform(sourceHttpCrs, func(c bootv1alphav1.HTTPBootConfig) client.Object { return &c }) + ipxeObjs := transform(sourceIpxeCrs, func(c bootv1alphav1.IPXEBootConfig) client.Object { return &c }) + + for _, sourceObj := range slices.Concat(httpObjs, ipxeObjs) { + sourceObjNN := client.ObjectKeyFromObject(sourceObj) + targetObj := sourceObj.DeepCopyObject().(client.Object) + + err := clients.Target.Get(ctx, sourceObjNN, targetObj) + if apierrors.IsNotFound(err) { + objsToMove = append(objsToMove, sourceObj) + + secret, err := getSecret(ctx, clients, uidToSecretNameMap[sourceObj.GetUID()], sourceObj.GetNamespace()) + if err != nil { + return nil, err + } else if secret != nil { + objsToMove = append(objsToMove, secret) + } + continue + } + + if err != nil { + return nil, fmt.Errorf("failed to check an object existence in the target cluster: %w", err) + } + + if reflect.DeepEqual(clearFields(sourceObj), clearFields(targetObj)) { + slog.Debug("source and target objects are the same", slog.String("object", sourceObjNN.String())) + continue + } + return nil, fmt.Errorf( + "a %q object already exists in the target cluster and is different then in the source cluster", + sourceObjNN.String()) + } + return objsToMove, nil +} + +func getUidToSecretNameMap( + sourceHttpCrs []bootv1alphav1.HTTPBootConfig, + sourceIpxeCrs []bootv1alphav1.IPXEBootConfig, +) map[types.UID]string { + uidToSecretNameMap := map[types.UID]string{} + for _, config := range sourceHttpCrs { + if config.Spec.IgnitionSecretRef != nil { + uidToSecretNameMap[config.UID] = config.Spec.IgnitionSecretRef.Name + } + } + for _, config := range sourceIpxeCrs { + if config.Spec.IgnitionSecretRef != nil { + uidToSecretNameMap[config.UID] = config.Spec.IgnitionSecretRef.Name + } + } + return uidToSecretNameMap +} + +func getSecret( + ctx context.Context, + clients Clients, + name string, + namespace string, +) (*v1.Secret, error) { + if name == "" { + return nil, nil + } + + nn := types.NamespacedName{ + Name: name, + Namespace: namespace, + } + sourceSecret := &v1.Secret{} + err := clients.Source.Get(ctx, nn, sourceSecret) + if err != nil { + return nil, fmt.Errorf("can't get a %q secret in the source cluster: %w", nn.String(), err) + } + + targetSecret := &v1.Secret{} + err = clients.Target.Get(ctx, nn, targetSecret) + if apierrors.IsNotFound(err) { + return sourceSecret, nil + } else if err != nil { + return nil, fmt.Errorf("can't get a %q secret in the target cluster: %w", nn.String(), err) + } + + if reflect.DeepEqual(clearFields(sourceSecret), clearFields(targetSecret)) { + slog.Debug("source and target secrets are the same", slog.String("secret", nn.String())) + return nil, nil + } + return nil, fmt.Errorf( + "a %q secret already exists in the target cluster and is different then in the source cluster", + nn.String()) +} + +func clearFields(obj client.Object) client.Object { + obj.SetResourceVersion("") + obj.SetUID("") + obj.SetGeneration(0) + obj.SetCreationTimestamp(metav1.Time{}) + obj.SetManagedFields(nil) + obj.GetObjectKind().SetGroupVersionKind(schema.GroupVersionKind{}) + return obj +} + +func moveObjs( + ctx context.Context, + cl client.Client, + scheme *runtime.Scheme, + objs []client.Object, + requireOwners bool, +) ([]client.Object, error) { + movedObjs := make([]client.Object, 0, len(objs)) + + for _, obj := range objs { + objKey := client.ObjectKeyFromObject(obj) + if obj.GetObjectKind().GroupVersionKind().Group == bootv1alphav1.GroupVersion.Group && len(obj.GetOwnerReferences()) == 1 { + obj.SetOwnerReferences([]metav1.OwnerReference{}) + serverBootConfiguration := &metalv1alpha1.ServerBootConfiguration{} + + err := cl.Get(ctx, objKey, serverBootConfiguration) + if err == nil { + err = controllerutil.SetControllerReference(serverBootConfiguration, obj, scheme) + if err != nil { + return movedObjs, fmt.Errorf("error when setting owner reference: %w", err) + } + } else if apierrors.IsNotFound(err) && requireOwners { + return movedObjs, fmt.Errorf("an owner ServerBootConfiguration %q wasn't found in the target cluster: %w", objKey.String(), err) + } else if !apierrors.IsNotFound(err) { + return movedObjs, fmt.Errorf("error when getting a server boot configuration: %w", err) + } + } + + obj.SetResourceVersion("") + if err := cl.Create(ctx, obj); err != nil { + err = fmt.Errorf("object %q couldn't be created in the target cluster: %w", objKey.String(), err) + return movedObjs, err + } + movedObjs = append(movedObjs, obj) + } + + return movedObjs, nil +} + +func cleanup(ctx context.Context, cl client.Client, objs []client.Object) error { + cleanupErrs := make([]error, 0) + for _, obj := range objs { + if err := cl.Delete(ctx, obj); err != nil { + cleanupErrs = append(cleanupErrs, err) + } + } + return errors.Join(cleanupErrs...) +} diff --git a/cmdutils/move_test.go b/cmdutils/move_test.go new file mode 100644 index 00000000..80c6e249 --- /dev/null +++ b/cmdutils/move_test.go @@ -0,0 +1,101 @@ +// SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package cmdutils + +import ( + "context" + "log/slog" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + v1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + k8sSchema "k8s.io/client-go/kubernetes/scheme" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + . "sigs.k8s.io/controller-runtime/pkg/envtest/komega" + + bootv1alphav1 "github.com/ironcore-dev/boot-operator/api/v1alpha1" + metalv1alpha1 "github.com/ironcore-dev/metal-operator/api/v1alpha1" +) + +const ( + ns = "test-namespace" +) + +func namedObj[T client.Object](obj T, name string) T { + obj.SetName(name) + obj.SetNamespace(ns) + return obj +} + +func create[T client.Object](ctx SpecContext, cl client.Client, obj T) T { + Expect(cl.Create(ctx, obj)).To(Succeed()) + Eventually(func(g Gomega) error { + return clients.Source.Get(ctx, client.ObjectKeyFromObject(obj), obj) + }).Should(Succeed()) + return obj +} + +var _ = Describe("bootctl move", func() { + It("Should successfully move boot CRs with secrets from a source cluster on a target cluster", func(ctx SpecContext) { + slog.SetLogLoggerLevel(slog.LevelDebug) + + // source cluster setup + create(ctx, clients.Source, &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: ns}}) + + sourceServerBootConfiguration := create(ctx, clients.Source, namedObj(&metalv1alpha1.ServerBootConfiguration{}, "server-boot-configuration")) + sourceHttpSecret := create(ctx, clients.Source, namedObj(&v1.Secret{}, "http-secret")) + sourceHTTPBootConfig := namedObj(&bootv1alphav1.HTTPBootConfig{}, sourceServerBootConfiguration.Name) + sourceHTTPBootConfig.Spec.IgnitionSecretRef = &corev1.LocalObjectReference{Name: sourceHttpSecret.Name} + Expect(controllerutil.SetControllerReference(sourceServerBootConfiguration, sourceHTTPBootConfig, k8sSchema.Scheme)).To(Succeed()) + sourceHTTPBootConfig = create(ctx, clients.Source, sourceHTTPBootConfig) + + sourceCommonServerBootConfiguration := create(ctx, clients.Source, namedObj(&metalv1alpha1.ServerBootConfiguration{}, "common-server-boot-configuration")) + sourceHttpCommonSecret := create(ctx, clients.Source, namedObj(&v1.Secret{}, "http-common-secret")) + sourceCommonHTTPBootConfig := namedObj(&bootv1alphav1.HTTPBootConfig{}, sourceCommonServerBootConfiguration.Name) + sourceCommonHTTPBootConfig.Spec.IgnitionSecretRef = &corev1.LocalObjectReference{Name: sourceHttpCommonSecret.Name} + Expect(controllerutil.SetControllerReference(sourceCommonServerBootConfiguration, sourceCommonHTTPBootConfig, k8sSchema.Scheme)).To(Succeed()) + create(ctx, clients.Source, sourceCommonHTTPBootConfig) + + sourceIPXESecret := create(ctx, clients.Source, namedObj(&v1.Secret{}, "ipxe-secret")) + sourceIPXEBootConfig := namedObj(&bootv1alphav1.IPXEBootConfig{}, "test-ipxe-boot-config") + sourceIPXEBootConfig.Spec.IgnitionSecretRef = &corev1.LocalObjectReference{Name: sourceIPXESecret.Name} + sourceIPXEBootConfig = create(ctx, clients.Source, sourceIPXEBootConfig) + + // target cluster setup + create(ctx, clients.Target, &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: ns}}) + + targetHttpSecret := create(ctx, clients.Target, namedObj(&v1.Secret{}, sourceHttpSecret.Name)) + targetHTTPBootConfig := namedObj(&bootv1alphav1.HTTPBootConfig{}, sourceHTTPBootConfig.Name) + + targetCommonServerBootConfiguration := create(ctx, clients.Target, namedObj(&metalv1alpha1.ServerBootConfiguration{}, sourceCommonServerBootConfiguration.Name)) + targetHttpCommonSecret := create(ctx, clients.Target, namedObj(&v1.Secret{}, sourceHttpCommonSecret.Name)) + targetCommonHTTPBootConfig := namedObj(&bootv1alphav1.HTTPBootConfig{}, targetCommonServerBootConfiguration.Name) + targetCommonHTTPBootConfig.Spec.IgnitionSecretRef = &corev1.LocalObjectReference{Name: targetHttpCommonSecret.Name} + Expect(controllerutil.SetControllerReference(targetCommonServerBootConfiguration, targetCommonHTTPBootConfig, k8sSchema.Scheme)).To(Succeed()) + create(ctx, clients.Target, targetCommonHTTPBootConfig) + + targetIPXESecret := namedObj(&v1.Secret{}, sourceIPXESecret.Name) + targetIPXEBootConfig := namedObj(&bootv1alphav1.IPXEBootConfig{}, sourceIPXEBootConfig.Name) + + // TEST + err := Move(context.TODO(), clients, k8sSchema.Scheme, ns, false, false) + Expect(err).ToNot(HaveOccurred()) + + SetClient(clients.Target) + + Eventually(Get(sourceServerBootConfiguration)).Should(Satisfy(apierrors.IsNotFound)) + Eventually(Get(targetHTTPBootConfig)).Should(Succeed()) + Expect(targetHTTPBootConfig.Spec.IgnitionSecretRef.Name).To(Equal(targetHttpSecret.Name)) + Expect(targetHTTPBootConfig.GetOwnerReferences()).To(BeEmpty()) + + Eventually(Get(targetIPXESecret)).Should(Succeed()) + Eventually(Get(targetIPXEBootConfig)).Should(Succeed()) + Expect(targetIPXEBootConfig.Spec.IgnitionSecretRef.Name).To(Equal(targetIPXESecret.Name)) + Expect(targetHTTPBootConfig.GetOwnerReferences()).To(BeEmpty()) + }) +}) diff --git a/cmdutils/suite_test.go b/cmdutils/suite_test.go new file mode 100644 index 00000000..1b37c3cf --- /dev/null +++ b/cmdutils/suite_test.go @@ -0,0 +1,105 @@ +// SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package cmdutils + +import ( + "fmt" + "path/filepath" + "runtime" + "testing" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + k8sSchema "k8s.io/client-go/kubernetes/scheme" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/envtest" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + + bootv1alphav1 "github.com/ironcore-dev/boot-operator/api/v1alpha1" + "github.com/ironcore-dev/controller-utils/modutils" + metalv1alpha1 "github.com/ironcore-dev/metal-operator/api/v1alpha1" + //+kubebuilder:scaffold:imports +) + +const ( + pollingInterval = 50 * time.Millisecond + eventuallyTimeout = 3 * time.Second + consistentlyDuration = 1 * time.Second +) + +var ( + clients Clients +) + +func TestBootctl(t *testing.T) { + SetDefaultConsistentlyPollingInterval(pollingInterval) + SetDefaultEventuallyPollingInterval(pollingInterval) + SetDefaultEventuallyTimeout(eventuallyTimeout) + SetDefaultConsistentlyDuration(consistentlyDuration) + RegisterFailHandler(Fail) + + RunSpecs(t, "Controller Suite") +} + +var _ = BeforeSuite(func() { + logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true))) + + sourceEnv := &envtest.Environment{ + CRDDirectoryPaths: []string{filepath.Join("..", "config", "crd", "bases"), + filepath.Join(modutils.Dir("github.com/ironcore-dev/metal-operator", "config", "crd", "bases")), + }, + ErrorIfCRDPathMissing: true, + + // The BinaryAssetsDirectory is only required if you want to run the tests directly + // without call the makefile target test. If not informed it will look for the + // default path defined in controller-runtime which is /usr/local/kubebuilder/. + // Note that you must have the required binaries setup under the bin directory to perform + // the tests directly. When we run make test it will be setup and used automatically. + BinaryAssetsDirectory: filepath.Join("..", "bin", "k8s", + fmt.Sprintf("1.31.0-%s-%s", runtime.GOOS, runtime.GOARCH)), + } + + sourceCfg, err := sourceEnv.Start() + Expect(err).NotTo(HaveOccurred()) + Expect(sourceCfg).NotTo(BeNil()) + + DeferCleanup(sourceEnv.Stop) + + Expect(metalv1alpha1.AddToScheme(k8sSchema.Scheme)).NotTo(HaveOccurred()) + Expect(bootv1alphav1.AddToScheme(k8sSchema.Scheme)).NotTo(HaveOccurred()) + + //+kubebuilder:scaffold:scheme + + clients.Source, err = client.New(sourceCfg, client.Options{Scheme: k8sSchema.Scheme}) + Expect(err).NotTo(HaveOccurred()) + Expect(clients.Source).NotTo(BeNil()) + + targetEnv := &envtest.Environment{ + CRDDirectoryPaths: []string{filepath.Join("..", "config", "crd", "bases"), + filepath.Join(modutils.Dir("github.com/ironcore-dev/metal-operator", "config", "crd", "bases")), + }, + ErrorIfCRDPathMissing: true, + + // The BinaryAssetsDirectory is only required if you want to run the tests directly + // without call the makefile target test. If not informed it will look for the + // default path defined in controller-runtime which is /usr/local/kubebuilder/. + // Note that you must have the required binaries setup under the bin directory to perform + // the tests directly. When we run make test it will be setup and used automatically. + BinaryAssetsDirectory: filepath.Join("..", "bin", "k8s", + fmt.Sprintf("1.31.0-%s-%s", runtime.GOOS, runtime.GOARCH)), + } + + // cfg is defined in this file globally. + targetCfg, err := targetEnv.Start() + Expect(err).NotTo(HaveOccurred()) + Expect(targetCfg).NotTo(BeNil()) + + DeferCleanup(targetEnv.Stop) + + clients.Target, err = client.New(targetCfg, client.Options{Scheme: k8sSchema.Scheme}) + Expect(err).NotTo(HaveOccurred()) + Expect(clients.Target).NotTo(BeNil()) +}) diff --git a/cmdutils/utils.go b/cmdutils/utils.go new file mode 100644 index 00000000..c7b71467 --- /dev/null +++ b/cmdutils/utils.go @@ -0,0 +1,24 @@ +// SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package cmdutils + +import ( + bootv1alphav1 "github.com/ironcore-dev/boot-operator/api/v1alpha1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// transform returns a list of transformed list elements with function f. +func transform[L ~[]E, E any, T any](list L, f func(E) T) []T { + ret := make([]T, len(list)) + for i, elem := range list { + ret[i] = f(elem) + } + return ret +} + +func HTTPBootConfigName(c bootv1alphav1.HTTPBootConfig) string { return c.Namespace + "/" + c.Name } +func IPXEBootConfigName(c bootv1alphav1.IPXEBootConfig) string { return c.Namespace + "/" + c.Name } +func objName(obj client.Object) string { + return obj.GetObjectKind().GroupVersionKind().Kind + ":" + obj.GetNamespace() + "/" + obj.GetName() +} diff --git a/docs/usage/bootctl.md b/docs/usage/bootctl.md new file mode 100644 index 00000000..bc4ca959 --- /dev/null +++ b/docs/usage/bootctl.md @@ -0,0 +1,56 @@ +# bootctl + +## Installation + +Install the `bootctl` CLI from source without cloning the repository. Requires [Go](https://go.dev) to be installed. + +```bash +go install https://github.com/ironcore-dev/boot-operator/cmd/bootctl@latest +``` + +## Commands + +### move + +The `bootctl move` command allows to move the boot Custom Resources which are `HTTPBootConfigs` and `IPXEBootConfigs` from one cluster to another. + +> Warning!: +> Before running `bootctl move`, the user should take care of preparing the target cluster, including also installing +> all the required Custom Resources Definitions. + +You can use: + +```bash +bootctl move --source-kubeconfig="path-to-source-kubeconfig.yaml" --target-kubeconfig="path-to-target-kubeconfig.yaml" +``` +to move the boot Custom Resources existing in all namespaces of the source cluster. In case you want to move the boot +Custom Resources defined in a single namespace, you can use the `--namespace` flag. + +Secrets referred in the specification and ownership of a boot Custom Resource is also moved. If for a moved boot Custom Resource with an ownership there is no `ServerBootConfiguration` matching resource name on the target cluster, the owner won't be set and the move operation will succeed. To fail when such situation occurs set `--require-owners` to true. If a boot Custom Resource present on the source cluster exists on the target cluster with identical specification it and its secret won't be moved and no ownership of this object will be set on the target cluster. If the boot Custom Resource is absent on the target cluster but its secret is present, there will be no errors and the move operation will succeed. In case of any errors during the process there will be performed a cleanup and the target cluster will be restored to its previous state. + +> Warning!: +`bootctl move` has been designed and developed around the bootstrap use case described below, and currently this is +the only use case verified. +> +>If someone intends to use `bootctl move` outside of this scenario, it's recommended to set up a custom validation +pipeline of it before using the command on a production environment. +> +>Also, it is important to notice that move has not been designed for being used as a backup/restore solution and it has +several limitation for this scenario, like e.g. the implementation assumes the cluster must be stable while doing the +move operation, and possible race conditions happening while the cluster is upgrading, scaling up, remediating etc. has +never been investigated nor addressed. + +#### Pivot + +Pivoting is a process for moving the Custom Resources and install Custom Resource Definitions from a source cluster to +a target cluster. + +This can now be achieved with the following procedure: + +1. Use `make install` to install the boot Custom Resource Definitions into the target cluster +2. Use `bootctl move` to move the boot Custom Resources from a source cluster to a target cluster + +#### Dry run + +With `--dry-run` option you can dry-run the move action by only printing logs without taking any actual actions. Use +`--verbose` flag to enable verbose logging. diff --git a/go.mod b/go.mod index 2a96542d..f88c429b 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/onsi/ginkgo/v2 v2.23.3 github.com/onsi/gomega v1.36.3 github.com/opencontainers/image-spec v1.1.1 + github.com/spf13/cobra v1.8.1 k8s.io/api v0.32.3 k8s.io/apimachinery v0.32.3 k8s.io/client-go v0.32.3 @@ -53,6 +54,7 @@ require ( github.com/google/gofuzz v1.2.0 // indirect github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad // indirect github.com/google/uuid v1.6.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/compress v1.16.7 // indirect diff --git a/go.sum b/go.sum index 1e7336cf..aa339473 100644 --- a/go.sum +++ b/go.sum @@ -36,6 +36,7 @@ github.com/coreos/ignition/v2 v2.20.0 h1:xQjrxhCbcSKpqrN2hOQavAc1rx0GOf6qh2QCauS github.com/coreos/ignition/v2 v2.20.0/go.mod h1:l7EpXNWA7jBXmjUMvnVBlrrj+LX2wA/PAyD9kstwFDQ= github.com/coreos/vcontext v0.0.0-20230201181013-d72178a18687 h1:uSmlDgJGbUB0bwQBcZomBTottKwEDF5fF8UjSwKSzWM= github.com/coreos/vcontext v0.0.0-20230201181013-d72178a18687/go.mod h1:Salmysdw7DAVuobBW/LwsKKgpyCPHUhjyJoMJD+ZJiI= +github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= @@ -88,6 +89,8 @@ github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad h1:a6HEuzUHeKH6hwfN/Z github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/ironcore-dev/controller-utils v0.9.8 h1:A7cd/n368xBh83cu7AEHaXYXnW37ICe4YKLXWsPRWW4= github.com/ironcore-dev/controller-utils v0.9.8/go.mod h1:fBA4gnfrneJzuEtL+jJj2A+YE8KmgQ4ot3A/eNRd5P0= github.com/ironcore-dev/metal v0.0.0-20240624131301-18385f342755 h1:EmR3Ngg2wmOXJkxgsdYVuPXLRfwWmO2Fi+htjih6QGY= @@ -144,8 +147,12 @@ github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0leargg github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= +github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= diff --git a/mkdocs.yml b/mkdocs.yml index 1668313d..2cffdb29 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -49,6 +49,8 @@ markdown_extensions: nav: - Home: README.md - Architecture: architecture.md +- Usage: + - Bootctl: usage/bootctl.md - Development Guide: - Documentation: development/dev_docs.md - Create Sample UKI Image: development/create_uki.md From e22faf5c7d25264476b0a5fad9598cd9112e0bf9 Mon Sep 17 00:00:00 2001 From: SzymonSAP Date: Tue, 8 Apr 2025 14:45:49 +0200 Subject: [PATCH 2/3] Update copyrights --- REUSE.toml | 4 ++-- api/v1alpha1/zz_generated.deepcopy.go | 2 +- cmd/bootctl/app/app.go | 2 +- cmd/bootctl/app/move.go | 2 +- cmd/bootctl/main.go | 2 +- cmdutils/clients.go | 2 +- cmdutils/move.go | 2 +- cmdutils/move_test.go | 2 +- cmdutils/suite_test.go | 2 +- cmdutils/utils.go | 2 +- hack/boilerplate.go.txt | 2 +- hack/license-header.txt | 2 +- 12 files changed, 13 insertions(+), 13 deletions(-) diff --git a/REUSE.toml b/REUSE.toml index 171f2912..072f9d93 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -6,11 +6,11 @@ SPDX-PackageDownloadLocation = "https://github.com/ironcore-dev/boot-operator" [[annotations]] path = [".github/**", ".gitignore", "CODEOWNERS", "Dockerfile", "Makefile", "PROJECT", "config/**", "gen/**", "go.mod", "go.sum", "hack/**", "server/**", "templates/**", "internal/**", "cmd/**", "api/**", "config/**", "test/**", "CONTRIBUTING.md", "PROJECT", "mkdocs.yml", ".dockerignore", ".golangci.yml", "REUSE.toml"] precedence = "aggregate" -SPDX-FileCopyrightText = "2024 SAP SE or an SAP affiliate company and IronCore contributors" +SPDX-FileCopyrightText = "2025 SAP SE or an SAP affiliate company and IronCore contributors" SPDX-License-Identifier = "Apache-2.0" [[annotations]] path = ["docs/**", "README.md"] precedence = "aggregate" -SPDX-FileCopyrightText = "2024 SAP SE or an SAP affiliate company and IronCore contributors" +SPDX-FileCopyrightText = "2025 SAP SE or an SAP affiliate company and IronCore contributors" SPDX-License-Identifier = "Apache-2.0" diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index edf75dba..00420843 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -1,6 +1,6 @@ //go:build !ignore_autogenerated -// SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and IronCore contributors // SPDX-License-Identifier: Apache-2.0 // Code generated by controller-gen. DO NOT EDIT. diff --git a/cmd/bootctl/app/app.go b/cmd/bootctl/app/app.go index a6852301..786b0ec1 100644 --- a/cmd/bootctl/app/app.go +++ b/cmd/bootctl/app/app.go @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and IronCore contributors // SPDX-License-Identifier: Apache-2.0 package app diff --git a/cmd/bootctl/app/move.go b/cmd/bootctl/app/move.go index 41ed50ec..c99f3020 100644 --- a/cmd/bootctl/app/move.go +++ b/cmd/bootctl/app/move.go @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and IronCore contributors // SPDX-License-Identifier: Apache-2.0 package app diff --git a/cmd/bootctl/main.go b/cmd/bootctl/main.go index 90e4c4ed..2d345b9d 100644 --- a/cmd/bootctl/main.go +++ b/cmd/bootctl/main.go @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and IronCore contributors // SPDX-License-Identifier: Apache-2.0 package main diff --git a/cmdutils/clients.go b/cmdutils/clients.go index 040bd72e..09a2f59b 100644 --- a/cmdutils/clients.go +++ b/cmdutils/clients.go @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and IronCore contributors // SPDX-License-Identifier: Apache-2.0 package cmdutils diff --git a/cmdutils/move.go b/cmdutils/move.go index e09709bc..192f8033 100644 --- a/cmdutils/move.go +++ b/cmdutils/move.go @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and IronCore contributors // SPDX-License-Identifier: Apache-2.0 package cmdutils diff --git a/cmdutils/move_test.go b/cmdutils/move_test.go index 80c6e249..61942596 100644 --- a/cmdutils/move_test.go +++ b/cmdutils/move_test.go @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and IronCore contributors // SPDX-License-Identifier: Apache-2.0 package cmdutils diff --git a/cmdutils/suite_test.go b/cmdutils/suite_test.go index 1b37c3cf..8a70eb9f 100644 --- a/cmdutils/suite_test.go +++ b/cmdutils/suite_test.go @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and IronCore contributors // SPDX-License-Identifier: Apache-2.0 package cmdutils diff --git a/cmdutils/utils.go b/cmdutils/utils.go index c7b71467..1d4c08e4 100644 --- a/cmdutils/utils.go +++ b/cmdutils/utils.go @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and IronCore contributors // SPDX-License-Identifier: Apache-2.0 package cmdutils diff --git a/hack/boilerplate.go.txt b/hack/boilerplate.go.txt index 5ced055a..780a90f9 100644 --- a/hack/boilerplate.go.txt +++ b/hack/boilerplate.go.txt @@ -1,2 +1,2 @@ -// SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and IronCore contributors // SPDX-License-Identifier: Apache-2.0 \ No newline at end of file diff --git a/hack/license-header.txt b/hack/license-header.txt index ca2098f0..59ff81c4 100644 --- a/hack/license-header.txt +++ b/hack/license-header.txt @@ -1,2 +1,2 @@ -SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and IronCore contributors +SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and IronCore contributors SPDX-License-Identifier: Apache-2.0 \ No newline at end of file From a7c57577953d165fde6c4767f04c4ea45b1f60b4 Mon Sep 17 00:00:00 2001 From: SzymonSAP Date: Tue, 8 Apr 2025 15:52:24 +0200 Subject: [PATCH 3/3] Add comments to bootctl move command --- cmdutils/clients.go | 1 + cmdutils/move.go | 10 ++++++---- cmdutils/utils.go | 4 ++-- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/cmdutils/clients.go b/cmdutils/clients.go index 09a2f59b..26ee48fe 100644 --- a/cmdutils/clients.go +++ b/cmdutils/clients.go @@ -5,6 +5,7 @@ package cmdutils import "sigs.k8s.io/controller-runtime/pkg/client" +// Clients structure stores information about source and destination cluster clients. type Clients struct { Source client.Client Target client.Client diff --git a/cmdutils/move.go b/cmdutils/move.go index 192f8033..1d11d3fd 100644 --- a/cmdutils/move.go +++ b/cmdutils/move.go @@ -23,6 +23,8 @@ import ( "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" ) +// Move transfers all BootOperator-related CRs from a source to a target cluster. +// It verifies object equality and handles secrets and owner references. func Move( ctx context.Context, clients Clients, @@ -36,8 +38,8 @@ func Move( return err } slog.Debug(fmt.Sprintf("found %s CRs in the source cluster", bootv1alphav1.GroupVersion.Group), - slog.Any("http boot configs", transform(httpConfigs, HTTPBootConfigName)), - slog.Any("ipxe boot configs", transform(ipxeConfigs, IPXEBootConfigName))) + slog.Any("http boot configs", transform(httpConfigs, httpBootConfigName)), + slog.Any("ipxe boot configs", transform(ipxeConfigs, ipxeBootConfigName))) objsToMove, err := getObjsToBeMoved(ctx, clients, httpConfigs, ipxeConfigs) if err != nil { @@ -114,7 +116,7 @@ func getObjsToBeMoved( continue } return nil, fmt.Errorf( - "a %q object already exists in the target cluster and is different then in the source cluster", + "a %q object already exists in the target cluster and is different than in the source cluster", sourceObjNN.String()) } return objsToMove, nil @@ -171,7 +173,7 @@ func getSecret( return nil, nil } return nil, fmt.Errorf( - "a %q secret already exists in the target cluster and is different then in the source cluster", + "a %q secret already exists in the target cluster and is different than in the source cluster", nn.String()) } diff --git a/cmdutils/utils.go b/cmdutils/utils.go index 1d4c08e4..543b73a7 100644 --- a/cmdutils/utils.go +++ b/cmdutils/utils.go @@ -17,8 +17,8 @@ func transform[L ~[]E, E any, T any](list L, f func(E) T) []T { return ret } -func HTTPBootConfigName(c bootv1alphav1.HTTPBootConfig) string { return c.Namespace + "/" + c.Name } -func IPXEBootConfigName(c bootv1alphav1.IPXEBootConfig) string { return c.Namespace + "/" + c.Name } +func httpBootConfigName(c bootv1alphav1.HTTPBootConfig) string { return c.Namespace + "/" + c.Name } +func ipxeBootConfigName(c bootv1alphav1.IPXEBootConfig) string { return c.Namespace + "/" + c.Name } func objName(obj client.Object) string { return obj.GetObjectKind().GroupVersionKind().Kind + ":" + obj.GetNamespace() + "/" + obj.GetName() }