From 26ce430ca07ff1617f99ed3a34696686841be482 Mon Sep 17 00:00:00 2001 From: Georgi Chulkov Date: Wed, 17 Apr 2024 18:29:53 +0200 Subject: [PATCH] Add OOB controller --- cmd/main.go | 14 +- config/rbac/role.yaml | 21 + go.mod | 3 +- go.sum | 2 + internal/bmc/bmc.go | 9 + internal/controller/indexes.go | 11 + internal/controller/oob_controller.go | 632 ++++++++++++++++++++- internal/controller/oob_controller_test.go | 309 ++++++++++ internal/controller/suite_test.go | 12 +- internal/cru/cru.go | 88 +++ 10 files changed, 1090 insertions(+), 11 deletions(-) create mode 100644 internal/bmc/bmc.go create mode 100644 internal/cru/cru.go diff --git a/cmd/main.go b/cmd/main.go index 7d782971..a7de4d3a 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -47,6 +47,10 @@ type params struct { enableMachineController bool enableMachineClaimController bool enableOOBController bool + oobIpLabelSelector string + oobMacDB string + oobUsernamePrefix string + oobTemporaryPasswordSecret string enableOOBSecretController bool } @@ -66,6 +70,10 @@ func parseCmdLine() params { pflag.Bool("enable-machine-controller", true, "Enable the Machine controller.") pflag.Bool("enable-machineclaim-controller", true, "Enable the MachineClaim controller.") pflag.Bool("enable-oob-controller", true, "Enable the OOB controller.") + pflag.String("oob-ip-label-selector", "", "OOB: Filter IP objects by labels.") + pflag.String("oob-mac-db", "", "OOB: Load MAC DB from file.") + pflag.String("oob-username-prefix", "metal-", "OOB: Use a prefix when creating BMC users. Cannot be empty.") + pflag.String("oob-temporary-password-secret", "bmc-temporary-password", "OOB: Secret to store a temporary password in. Will be generated if it does not exist.") pflag.Bool("enable-oobsecret-controller", true, "Enable the OOBSecret controller.") var help bool @@ -96,6 +104,10 @@ func parseCmdLine() params { enableMachineController: viper.GetBool("enable-machine-controller"), enableMachineClaimController: viper.GetBool("enable-machineclaim-controller"), enableOOBController: viper.GetBool("enable-oob-controller"), + oobIpLabelSelector: viper.GetString("oob-ip-label-selector"), + oobMacDB: viper.GetString("oob-mac-db"), + oobUsernamePrefix: viper.GetString("oob-username-prefix"), + oobTemporaryPasswordSecret: viper.GetString("oob-temporary-password-secret"), enableOOBSecretController: viper.GetBool("enable-oobsecret-controller"), } } @@ -247,7 +259,7 @@ func main() { if p.enableOOBController { var oobReconciler *controller.OOBReconciler - oobReconciler, err = controller.NewOOBReconciler() + oobReconciler, err = controller.NewOOBReconciler(p.systemNamespace, p.oobIpLabelSelector, p.oobMacDB, p.oobUsernamePrefix, p.oobTemporaryPasswordSecret) if err != nil { log.Error(ctx, fmt.Errorf("cannot create controller: %w", err), "controller", "OOB") exitCode = 1 diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 5b6a495c..21214fb2 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -4,6 +4,27 @@ kind: ClusterRole metadata: name: manager-role rules: +- apiGroups: + - "" + resources: + - secrets + verbs: + - get + - list +- apiGroups: + - ipam.metal.ironcore.dev + resources: + - ips + verbs: + - get + - list + - watch +- apiGroups: + - ipam.metal.ironcore.dev + resources: + - ips/status + verbs: + - get - apiGroups: - metal.ironcore.dev resources: diff --git a/go.mod b/go.mod index 9af55e9a..f75988d3 100644 --- a/go.mod +++ b/go.mod @@ -13,8 +13,10 @@ require ( github.com/onsi/ginkgo/v2 v2.17.1 github.com/onsi/gomega v1.32.0 github.com/rs/zerolog v1.32.0 + github.com/sethvargo/go-password v0.2.0 github.com/spf13/pflag v1.0.5 github.com/spf13/viper v1.18.2 + gopkg.in/yaml.v3 v3.0.1 k8s.io/api v0.29.4 k8s.io/apimachinery v0.29.4 k8s.io/client-go v0.29.4 @@ -235,7 +237,6 @@ require ( gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect honnef.co/go/tools v0.4.7 // indirect k8s.io/apiextensions-apiserver v0.29.2 // indirect k8s.io/component-base v0.29.2 // indirect diff --git a/go.sum b/go.sum index fae391ee..0d1d93ea 100644 --- a/go.sum +++ b/go.sum @@ -539,6 +539,8 @@ github.com/securego/gosec/v2 v2.19.0 h1:gl5xMkOI0/E6Hxx0XCY2XujA3V7SNSefA8sC+3f1 github.com/securego/gosec/v2 v2.19.0/go.mod h1:hOkDcHz9J/XIgIlPDXalxjeVYsHxoWUc5zJSHxcB8YM= github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ= github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= +github.com/sethvargo/go-password v0.2.0 h1:BTDl4CC/gjf/axHMaDQtw507ogrXLci6XRiLc7i/UHI= +github.com/sethvargo/go-password v0.2.0/go.mod h1:Ym4Mr9JXLBycr02MFuVQ/0JHidNetSgbzutTr3zsYXE= github.com/shazow/go-diff v0.0.0-20160112020656-b6b7b6733b8c h1:W65qqJCIOVP4jpqPQ0YvHYKwcMEMVWIzWC5iNQQfBTU= github.com/shazow/go-diff v0.0.0-20160112020656-b6b7b6733b8c/go.mod h1:/PevMnwAxekIXwN8qQyfc5gl2NlkB3CQlkizAbOkeBs= github.com/shurcooL/go v0.0.0-20180423040247-9e1955d9fb6e/go.mod h1:TDJrrUr11Vxrven61rcy3hJMUqaf/CLWYhHNPmT14Lk= diff --git a/internal/bmc/bmc.go b/internal/bmc/bmc.go new file mode 100644 index 00000000..e33b46fb --- /dev/null +++ b/internal/bmc/bmc.go @@ -0,0 +1,9 @@ +// SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package bmc + +type Credentials struct { + Username string `yaml:"username"` + Password string `yaml:"password"` +} diff --git a/internal/controller/indexes.go b/internal/controller/indexes.go index da1317ba..e0c47d21 100644 --- a/internal/controller/indexes.go +++ b/internal/controller/indexes.go @@ -27,5 +27,16 @@ func CreateIndexes(ctx context.Context, mgr manager.Manager) error { return fmt.Errorf("cannot index field %s: %w", MachineClaimSpecMachineRef, err) } + err = indexer.IndexField(ctx, &metalv1alpha1.OOB{}, OOBSpecMACAddress, func(obj client.Object) []string { + oob := obj.(*metalv1alpha1.OOB) + if oob.Spec.MACAddress == "" { + return nil + } + return []string{oob.Spec.MACAddress} + }) + if err != nil { + return fmt.Errorf("cannot index field %s: %w", OOBSpecMACAddress, err) + } + return nil } diff --git a/internal/controller/oob_controller.go b/internal/controller/oob_controller.go index 243af373..02d3dfc2 100644 --- a/internal/controller/oob_controller.go +++ b/internal/controller/oob_controller.go @@ -5,40 +5,656 @@ package controller import ( "context" + "fmt" + "os" + "regexp" + "strings" + ipamv1alpha1 "github.com/ironcore-dev/ipam/api/ipam/v1alpha1" + ipamv1alpha1apply "github.com/ironcore-dev/ipam/clientgo/applyconfiguration/ipam/v1alpha1" + "github.com/sethvargo/go-password/password" + "gopkg.in/yaml.v3" + v1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/types" + v1apply "k8s.io/client-go/applyconfigurations/core/v1" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + "sigs.k8s.io/controller-runtime/pkg/source" metalv1alpha1 "github.com/ironcore-dev/metal/api/v1alpha1" + metalv1alpha1apply "github.com/ironcore-dev/metal/client/applyconfiguration/api/v1alpha1" + "github.com/ironcore-dev/metal/internal/bmc" + "github.com/ironcore-dev/metal/internal/cru" + "github.com/ironcore-dev/metal/internal/log" + "github.com/ironcore-dev/metal/internal/ssa" + "github.com/ironcore-dev/metal/internal/util" ) // +kubebuilder:rbac:groups=metal.ironcore.dev,resources=oobs,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=metal.ironcore.dev,resources=oobs/status,verbs=get;update;patch // +kubebuilder:rbac:groups=metal.ironcore.dev,resources=oobs/finalizers,verbs=update +// +kubebuilder:rbac:groups=ipam.metal.ironcore.dev,resources=ips,verbs=get;list;watch +// +kubebuilder:rbac:groups=ipam.metal.ironcore.dev,resources=ips/status,verbs=get +// +kubebuilder:rbac:groups=core,resources=secrets,verbs=get;list -func NewOOBReconciler() (*OOBReconciler, error) { - return &OOBReconciler{}, nil +const ( + OOBFieldManager = "metal.ironcore.dev/oob" + OOBFinalizer = "metal.ironcore.dev/oob" + OOBIPMacLabel = "mac" + OOBIgnoreAnnotation = "metal.ironcore.dev/oob-ignore" + OOBMacRegex = `^[0-9A-Fa-f]{12}$` + OOBUsernameRegexSuffix = `[a-z]{6}` + OOBSpecMACAddress = ".spec.MACAddress" + // OOBTemporaryNamespaceHack TODO: Remove temporary namespace hack. + OOBTemporaryNamespaceHack = "oob" +) + +func NewOOBReconciler(systemNamespace, ipLabelSelector, macDB, usernamePrefix, temporaryPasswordSecret string) (*OOBReconciler, error) { + r := &OOBReconciler{ + systemNamespace: systemNamespace, + usernamePrefix: usernamePrefix, + temporaryPasswordSecret: temporaryPasswordSecret, + } + var err error + + if r.systemNamespace == "" { + return nil, fmt.Errorf("system namespace cannot be empty") + } + if r.usernamePrefix == "" { + return nil, fmt.Errorf("username prefix cannot be empty") + } + if r.temporaryPasswordSecret == "" { + return nil, fmt.Errorf("temporary password secret name cannot be empty") + } + + r.ipLabelSelector, err = labels.Parse(ipLabelSelector) + if err != nil { + return nil, fmt.Errorf("cannot parse IP label selector: %w", err) + } + + r.macDB, err = loadMacDB(macDB) + if err != nil { + return nil, fmt.Errorf("cannot load MAC DB: %w", err) + } + + r.usernameRegex, err = regexp.Compile(r.usernamePrefix + OOBUsernameRegexSuffix) + if err != nil { + return nil, fmt.Errorf("cannot compile username regex: %w", err) + } + + r.macRegex, err = regexp.Compile(OOBMacRegex) + if err != nil { + return nil, fmt.Errorf("cannot compile MAC regex: %w", err) + } + + return r, nil } // OOBReconciler reconciles a OOB object type OOBReconciler struct { client.Client + systemNamespace string + ipLabelSelector labels.Selector + macDB util.PrefixMap[access] + usernamePrefix string + temporaryPassword string + temporaryPasswordSecret string + usernameRegex *regexp.Regexp + macRegex *regexp.Regexp +} + +type access struct { + Ignore bool `yaml:"ignore"` + Protocol metalv1alpha1.Protocol `yaml:"protocol"` + Flags map[string]string `yaml:"flags"` + DefaultCredentials []bmc.Credentials `yaml:"defaultCredentials"` +} + +func (r *OOBReconciler) PreStart(ctx context.Context) error { + return r.ensureTemporaryPassword(ctx) } // Reconcile is part of the main kubernetes reconciliation loop which aims to // move the current state of the cluster closer to the desired state. -func (r *OOBReconciler) Reconcile(ctx context.Context, _ ctrl.Request) (ctrl.Result, error) { - _ = log.FromContext(ctx) +func (r *OOBReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + var oob metalv1alpha1.OOB + err := r.Get(ctx, req.NamespacedName, &oob) + if err != nil { + return ctrl.Result{}, client.IgnoreNotFound(fmt.Errorf("cannot get OOB: %w", err)) + } + + if !oob.DeletionTimestamp.IsZero() { + return ctrl.Result{}, r.finalize(ctx, &oob) + } + return r.reconcile(ctx, &oob) +} + +func (r *OOBReconciler) finalize(ctx context.Context, oob *metalv1alpha1.OOB) error { + if !controllerutil.ContainsFinalizer(oob, OOBFinalizer) { + return nil + } + log.Debug(ctx, "Finalizing") + + err := r.finalizeEndpoint(ctx, oob) + if err != nil { + return err + } + + log.Debug(ctx, "Removing finalizer") + var apply *metalv1alpha1apply.OOBApplyConfiguration + apply, err = metalv1alpha1apply.ExtractOOB(oob, OOBFieldManager) + if err != nil { + return err + } + apply.Finalizers = util.Clear(apply.Finalizers, OOBFinalizer) + err = r.Patch(ctx, oob, ssa.Apply(apply), client.FieldOwner(OOBFieldManager), client.ForceOwnership) + if err != nil { + return fmt.Errorf("cannot apply OOB: %w", err) + } + + log.Debug(ctx, "Finalized successfully") + return nil +} + +func (r *OOBReconciler) finalizeEndpoint(ctx context.Context, oob *metalv1alpha1.OOB) error { + if oob.Spec.EndpointRef == nil { + return nil + } + ctx = log.WithValues(ctx, "endpoint", oob.Spec.EndpointRef.Name) + + var ip ipamv1alpha1.IP + err := r.Get(ctx, client.ObjectKey{ + Namespace: OOBTemporaryNamespaceHack, + Name: oob.Spec.EndpointRef.Name, + }, &ip) + if err != nil && !errors.IsNotFound(err) { + return fmt.Errorf("cannot get IP: %w", err) + } + if errors.IsNotFound(err) { + return nil + } + + log.Debug(ctx, "Removing finalizer from IP") + var ipApply *ipamv1alpha1apply.IPApplyConfiguration + ipApply, err = ipamv1alpha1apply.ExtractIP(&ip, OOBFieldManager) + if err != nil { + return err + } + ipApply.Finalizers = util.Clear(ipApply.Finalizers, OOBFinalizer) + ipApply.Spec = nil + err = r.Patch(ctx, &ip, ssa.Apply(ipApply), client.FieldOwner(OOBFieldManager), client.ForceOwnership) + if err != nil { + return fmt.Errorf("cannot apply IP: %w", err) + } + return nil +} + +func (r *OOBReconciler) reconcile(ctx context.Context, oob *metalv1alpha1.OOB) (ctrl.Result, error) { + log.Debug(ctx, "Reconciling") + + var ok bool + var err error + + ctx, ok, err = r.applyOrContinue(log.WithValues(ctx, "phase", "IgnoreAnnotation"), oob, r.processIgnoreAnnotation) + _, ignored := oob.Annotations[OOBIgnoreAnnotation] + if !ok || ignored { + if err == nil { + log.Debug(ctx, "Reconciled successfully") + } + return ctrl.Result{}, err + } + + ctx, ok, err = r.applyOrContinue(log.WithValues(ctx, "phase", "InitialState"), oob, r.processInitialState) + if !ok { + if err == nil { + log.Debug(ctx, "Reconciled successfully") + } + return ctrl.Result{}, err + } + + ctx, ok, err = r.applyOrContinue(log.WithValues(ctx, "phase", "Endpoint"), oob, r.processEndpoint) + if !ok { + if err == nil { + log.Debug(ctx, "Reconciled successfully") + } + return ctrl.Result{}, err + } + + ctx = log.WithValues(ctx, "phase", "all") + log.Debug(ctx, "Reconciled successfully") return ctrl.Result{}, nil } +type oobProcessFunc func(context.Context, *metalv1alpha1.OOB) (context.Context, *metalv1alpha1apply.OOBApplyConfiguration, *metalv1alpha1apply.OOBStatusApplyConfiguration, error) + +func (r *OOBReconciler) applyOrContinue(ctx context.Context, oob *metalv1alpha1.OOB, pfunc oobProcessFunc) (context.Context, bool, error) { + var apply *metalv1alpha1apply.OOBApplyConfiguration + var status *metalv1alpha1apply.OOBStatusApplyConfiguration + var err error + + ctx, apply, status, err = pfunc(ctx, oob) + if err != nil { + return ctx, false, err + } + + if apply != nil { + log.Debug(ctx, "Applying") + err = r.Patch(ctx, oob, ssa.Apply(apply), client.FieldOwner(OOBFieldManager), client.ForceOwnership) + if err != nil { + return ctx, false, fmt.Errorf("cannot apply OOB: %w", err) + } + } + + if status != nil { + apply = metalv1alpha1apply.OOB(oob.Name, oob.Namespace).WithStatus(status) + + log.Debug(ctx, "Applying status") + err = r.Status().Patch(ctx, oob, ssa.Apply(apply), client.FieldOwner(OOBFieldManager), client.ForceOwnership) + if err != nil { + return ctx, false, fmt.Errorf("cannot apply OOB status: %w", err) + } + + cond, ok := ssa.GetCondition(status.Conditions, metalv1alpha1.OOBConditionTypeReady) + if ok && cond.Status == metav1.ConditionFalse && cond.Reason == metalv1alpha1.OOBConditionReasonError { + err = fmt.Errorf(cond.Message) + } + } + + return ctx, apply == nil, err +} + +func (r *OOBReconciler) processIgnoreAnnotation(ctx context.Context, oob *metalv1alpha1.OOB) (context.Context, *metalv1alpha1apply.OOBApplyConfiguration, *metalv1alpha1apply.OOBStatusApplyConfiguration, error) { + _, ok := oob.Annotations[OOBIgnoreAnnotation] + if ok { + var status *metalv1alpha1apply.OOBStatusApplyConfiguration + state := metalv1alpha1.OOBStateIgnored + conds, mod := ssa.SetCondition(oob.Status.Conditions, metav1.Condition{ + Type: metalv1alpha1.OOBConditionTypeReady, + Status: metav1.ConditionFalse, + Reason: metalv1alpha1.OOBConditionReasonIgnored, + }) + if oob.Status.State != state || mod { + applyst, err := metalv1alpha1apply.ExtractOOBStatus(oob, OOBFieldManager) + if err != nil { + return ctx, nil, nil, err + } + status = util.Ensure(applyst.Status). + WithState(state) + status.Conditions = conds + } + return ctx, nil, status, nil + } else if oob.Status.State == metalv1alpha1.OOBStateIgnored { + oob.Status.State = "" + } + + return ctx, nil, nil, nil +} + +func (r *OOBReconciler) processInitialState(ctx context.Context, oob *metalv1alpha1.OOB) (context.Context, *metalv1alpha1apply.OOBApplyConfiguration, *metalv1alpha1apply.OOBStatusApplyConfiguration, error) { + var apply *metalv1alpha1apply.OOBApplyConfiguration + var status *metalv1alpha1apply.OOBStatusApplyConfiguration + var err error + + ctx = log.WithValues(ctx, "mac", oob.Spec.MACAddress) + + if !controllerutil.ContainsFinalizer(oob, OOBFinalizer) { + apply, err = metalv1alpha1apply.ExtractOOB(oob, OOBFieldManager) + if err != nil { + return ctx, nil, nil, err + } + apply.Finalizers = util.Set(apply.Finalizers, OOBFinalizer) + } + + _, ok := ssa.GetCondition(oob.Status.Conditions, metalv1alpha1.OOBConditionTypeReady) + if oob.Status.State == "" || !ok { + var applyst *metalv1alpha1apply.OOBApplyConfiguration + applyst, err = metalv1alpha1apply.ExtractOOBStatus(oob, OOBFieldManager) + if err != nil { + return ctx, nil, nil, err + } + status = util.Ensure(applyst.Status). + WithState(metalv1alpha1.OOBStateUnready) + status.Conditions, _ = ssa.SetCondition(oob.Status.Conditions, metav1.Condition{ + Type: metalv1alpha1.OOBConditionTypeReady, + Status: metav1.ConditionFalse, + Reason: metalv1alpha1.OOBConditionReasonInProgress, + }) + } + + return ctx, apply, status, nil +} + +func (r *OOBReconciler) processEndpoint(ctx context.Context, oob *metalv1alpha1.OOB) (context.Context, *metalv1alpha1apply.OOBApplyConfiguration, *metalv1alpha1apply.OOBStatusApplyConfiguration, error) { + var apply *metalv1alpha1apply.OOBApplyConfiguration + var status *metalv1alpha1apply.OOBStatusApplyConfiguration + + var ip ipamv1alpha1.IP + if oob.Spec.EndpointRef != nil { + err := r.Get(ctx, client.ObjectKey{ + Namespace: OOBTemporaryNamespaceHack, + Name: oob.Spec.EndpointRef.Name, + }, &ip) + if err != nil && !errors.IsNotFound(err) { + return ctx, nil, nil, fmt.Errorf("cannot get IP: %w", err) + } + + valid := ip.DeletionTimestamp == nil && r.ipLabelSelector.Matches(labels.Set(ip.Labels)) && ip.Namespace == OOBTemporaryNamespaceHack + if errors.IsNotFound(err) || !valid { + if !valid && controllerutil.ContainsFinalizer(&ip, OOBFinalizer) { + log.Debug(ctx, "Removing finalizer from IP") + var ipApply *ipamv1alpha1apply.IPApplyConfiguration + ipApply, err = ipamv1alpha1apply.ExtractIP(&ip, OOBFieldManager) + if err != nil { + return ctx, nil, nil, err + } + ipApply.Finalizers = util.Clear(ipApply.Finalizers, OOBFinalizer) + err = r.Patch(ctx, &ip, ssa.Apply(ipApply), client.FieldOwner(OOBFieldManager), client.ForceOwnership) + if err != nil { + return ctx, nil, nil, fmt.Errorf("cannot apply IP: %w", err) + } + } + + oob.Spec.EndpointRef = nil + + apply, err = metalv1alpha1apply.ExtractOOB(oob, OOBFieldManager) + if err != nil { + return ctx, nil, nil, err + } + apply = apply.WithSpec(util.Ensure(apply.Spec)) + apply.Spec.EndpointRef = nil + } else if ip.Status.Reserved != nil { + ctx = log.WithValues(ctx, "ip", ip.Status.Reserved.String()) + } + } + if oob.Spec.EndpointRef == nil { + var ipList ipamv1alpha1.IPList + err := r.List(ctx, &ipList, client.MatchingLabelsSelector{Selector: r.ipLabelSelector}, client.MatchingLabels{OOBIPMacLabel: oob.Spec.MACAddress}) + if err != nil { + return ctx, nil, nil, fmt.Errorf("cannot list OOBs: %w", err) + } + + found := false + for _, i := range ipList.Items { + if i.Namespace != OOBTemporaryNamespaceHack { + continue + } + if i.DeletionTimestamp != nil || i.Status.State != ipamv1alpha1.CFinishedIPState || i.Status.Reserved == nil || !i.Status.Reserved.Net.IsValid() { + continue + } + ip = i + found = true + ctx = log.WithValues(ctx, "ip", ip.Status.Reserved.String()) + + oob.Spec.EndpointRef = &v1.LocalObjectReference{ + Name: ip.Name, + } + + if apply == nil { + apply, err = metalv1alpha1apply.ExtractOOB(oob, OOBFieldManager) + if err != nil { + return ctx, nil, nil, err + } + } + apply = apply.WithSpec(util.Ensure(apply.Spec). + WithEndpointRef(*oob.Spec.EndpointRef)) + + state := metalv1alpha1.OOBStateUnready + conds, mod := ssa.SetCondition(oob.Status.Conditions, metav1.Condition{ + Type: metalv1alpha1.OOBConditionTypeReady, + Status: metav1.ConditionFalse, + Reason: metalv1alpha1.OOBConditionReasonInProgress, + }) + if oob.Status.State != state || mod { + var applyst *metalv1alpha1apply.OOBApplyConfiguration + applyst, err = metalv1alpha1apply.ExtractOOBStatus(oob, OOBFieldManager) + if err != nil { + return ctx, nil, nil, err + } + status = util.Ensure(applyst.Status). + WithState(state) + status.Conditions = conds + } + + break + } + if !found { + state := metalv1alpha1.OOBStateUnready + conds, mod := ssa.SetCondition(oob.Status.Conditions, metav1.Condition{ + Type: metalv1alpha1.OOBConditionTypeReady, + Status: metav1.ConditionFalse, + Reason: metalv1alpha1.OOBConditionReasonNoEndpoint, + }) + if oob.Status.State != state || mod { + var applyst *metalv1alpha1apply.OOBApplyConfiguration + applyst, err = metalv1alpha1apply.ExtractOOBStatus(oob, OOBFieldManager) + if err != nil { + return ctx, nil, nil, err + } + status = util.Ensure(applyst.Status). + WithState(state) + status.Conditions = conds + } + return ctx, apply, status, nil + } + } + + if !controllerutil.ContainsFinalizer(&ip, OOBFinalizer) { + log.Debug(ctx, "Adding finalizer to IP") + ipApply, err := ipamv1alpha1apply.ExtractIP(&ip, OOBFieldManager) + if err != nil { + return ctx, nil, nil, err + } + ipApply.Finalizers = util.Set(ipApply.Finalizers, OOBFinalizer) + err = r.Patch(ctx, &ip, ssa.Apply(ipApply), client.FieldOwner(OOBFieldManager), client.ForceOwnership) + if err != nil { + return ctx, nil, nil, fmt.Errorf("cannot apply IP: %w", err) + } + } + + if ip.Labels[OOBIPMacLabel] != oob.Spec.MACAddress { + state := metalv1alpha1.OOBStateError + conds, mod := ssa.SetErrorCondition(oob.Status.Conditions, metalv1alpha1.OOBConditionTypeReady, + fmt.Errorf("BadEndpoint: endpoint has incorrect MAC address: expected %s, actual %s", oob.Spec.MACAddress, ip.Labels[OOBIPMacLabel])) + if oob.Status.State != state || mod { + applyst, err := metalv1alpha1apply.ExtractOOBStatus(oob, OOBFieldManager) + if err != nil { + return ctx, nil, nil, err + } + status = util.Ensure(applyst.Status). + WithState(state) + status.Conditions = conds + } + return ctx, apply, status, nil + } + + if ip.Status.State != ipamv1alpha1.CFinishedIPState || ip.Status.Reserved == nil || !ip.Status.Reserved.Net.IsValid() { + state := metalv1alpha1.OOBStateError + conds, mod := ssa.SetErrorCondition(oob.Status.Conditions, metalv1alpha1.OOBConditionTypeReady, + fmt.Errorf("BadEndpoint: endpoint has no valid IP address")) + if oob.Status.State != state || mod { + applyst, err := metalv1alpha1apply.ExtractOOBStatus(oob, OOBFieldManager) + if err != nil { + return ctx, nil, nil, err + } + status = util.Ensure(applyst.Status). + WithState(state) + status.Conditions = conds + } + return ctx, apply, status, nil + } + + if oob.Status.State == metalv1alpha1.OOBStateError { + cond, _ := ssa.GetCondition(oob.Status.Conditions, metalv1alpha1.OOBConditionTypeReady) + if strings.HasPrefix(cond.Message, "BadEndpoint: ") { + state := metalv1alpha1.OOBStateUnready + conds, _ := ssa.SetCondition(oob.Status.Conditions, metav1.Condition{ + Type: metalv1alpha1.OOBConditionTypeReady, + Status: metav1.ConditionFalse, + Reason: metalv1alpha1.OOBConditionReasonInProgress, + }) + applyst, err := metalv1alpha1apply.ExtractOOBStatus(oob, OOBFieldManager) + if err != nil { + return ctx, nil, nil, err + } + status = util.Ensure(applyst.Status). + WithState(state) + status.Conditions = conds + + return ctx, apply, status, nil + } + } + + return ctx, apply, status, nil +} + // SetupWithManager sets up the controller with the Manager. func (r *OOBReconciler) SetupWithManager(mgr ctrl.Manager) error { r.Client = mgr.GetClient() - return ctrl.NewControllerManagedBy(mgr). - For(&metalv1alpha1.OOB{}). - Complete(r) + c, err := cru.CreateController(mgr, &metalv1alpha1.OOB{}, r) + if err != nil { + return err + } + + err = c.Watch(source.Kind(mgr.GetCache(), &ipamv1alpha1.IP{}), r.enqueueOOBFromIP()) + if err != nil { + return err + } + + return mgr.Add(c) +} + +func (r *OOBReconciler) enqueueOOBFromIP() handler.EventHandler { + return handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, obj client.Object) []reconcile.Request { + ip := obj.(*ipamv1alpha1.IP) + + if ip.Namespace != OOBTemporaryNamespaceHack { + return nil + } + if !r.ipLabelSelector.Matches(labels.Set(ip.Labels)) { + return nil + } + + mac, ok := ip.Labels[OOBIPMacLabel] + if !ok || !r.macRegex.MatchString(mac) { + log.Error(ctx, fmt.Errorf("invalid MAC address: %s", mac)) + return nil + } + + oobList := metalv1alpha1.OOBList{} + err := r.List(ctx, &oobList, client.MatchingFields{OOBSpecMACAddress: mac}) + if err != nil { + log.Error(ctx, fmt.Errorf("cannot list OOBs: %w", err)) + return nil + } + + var reqs []reconcile.Request + for _, o := range oobList.Items { + if o.DeletionTimestamp != nil { + continue + } + + reqs = append(reqs, reconcile.Request{NamespacedName: types.NamespacedName{ + Name: o.Name, + }}) + } + + if len(oobList.Items) == 0 && ip.Status.State == ipamv1alpha1.CFinishedIPState && ip.Status.Reserved != nil { + oob := metalv1alpha1.OOB{ + ObjectMeta: metav1.ObjectMeta{ + Name: mac, + }, + } + apply := metalv1alpha1apply.OOB(oob.Name, oob.Namespace). + WithFinalizers(OOBFinalizer). + WithSpec(metalv1alpha1apply.OOBSpec(). + WithMACAddress(mac)) + err = r.Patch(ctx, &oob, ssa.Apply(apply), client.FieldOwner(OOBFieldManager), client.ForceOwnership) + if err != nil { + log.Error(ctx, fmt.Errorf("cannot apply OOB: %w", err)) + } + } + + return reqs + }) +} + +func loadMacDB(dbFile string) (util.PrefixMap[access], error) { + if dbFile == "" { + return make(util.PrefixMap[access]), nil + } + + data, err := os.ReadFile(dbFile) + if err != nil { + return nil, fmt.Errorf("cannot read %s: %w", dbFile, err) + } + + var dbf struct { + MACs []struct { + Prefix string `yaml:"prefix"` + access `yaml:",inline"` + } `yaml:"macs"` + } + err = yaml.Unmarshal(data, &dbf) + if err != nil { + return nil, fmt.Errorf("cannot unmarshal %s: %w", dbFile, err) + } + + db := make(util.PrefixMap[access], len(dbf.MACs)) + for _, m := range dbf.MACs { + db[m.Prefix] = m.access + } + return db, nil +} + +func (r *OOBReconciler) ensureTemporaryPassword(ctx context.Context) error { + secret := v1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: r.temporaryPasswordSecret, + Namespace: r.systemNamespace, + }, + } + + err := r.Get(ctx, client.ObjectKeyFromObject(&secret), &secret) + if err != nil && !errors.IsNotFound(err) { + return fmt.Errorf("cannot get secret %s: %w", r.temporaryPasswordSecret, err) + } + ctx = log.WithValues(ctx, "name", secret.Name, "namesapce", secret.Namespace) + + if errors.IsNotFound(err) { + var pw string + pw, err = password.Generate(12, 0, 0, false, true) + if err != nil { + return fmt.Errorf("cannot generate temporary password: %w", err) + } + + log.Info(ctx, "Creating new temporary password Secret") + apply := v1apply.Secret(secret.Name, secret.Namespace). + WithType(v1.SecretTypeBasicAuth). + WithStringData(map[string]string{v1.BasicAuthPasswordKey: pw}) + err = r.Patch(ctx, &secret, ssa.Apply(apply), client.FieldOwner(OOBFieldManager), client.ForceOwnership) + if err != nil { + return fmt.Errorf("cannot apply Secret: %w", err) + } + } else { + log.Info(ctx, "Loading existing temporary password Secret") + } + + if secret.Type != v1.SecretTypeBasicAuth { + return fmt.Errorf("cannot use Secret with incorrect type: %s", secret.Type) + } + + r.temporaryPassword = string(secret.Data[v1.BasicAuthPasswordKey]) + if r.temporaryPassword == "" { + return fmt.Errorf("cannot use Secret with missing or empty password") + } + + return nil } diff --git a/internal/controller/oob_controller_test.go b/internal/controller/oob_controller_test.go index 4b5d42ec..2ebee87b 100644 --- a/internal/controller/oob_controller_test.go +++ b/internal/controller/oob_controller_test.go @@ -4,8 +4,317 @@ package controller import ( + "fmt" + + ipamv1alpha1 "github.com/ironcore-dev/ipam/api/ipam/v1alpha1" . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + . "sigs.k8s.io/controller-runtime/pkg/envtest/komega" + + metalv1alpha1 "github.com/ironcore-dev/metal/api/v1alpha1" + "github.com/ironcore-dev/metal/internal/ssa" ) var _ = Describe("OOB Controller", func() { + It("should create an OOB from an IP", func(ctx SpecContext) { + By("Creating an IP") + ip := &ipamv1alpha1.IP{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "test-", + Namespace: OOBTemporaryNamespaceHack, + Labels: map[string]string{ + OOBIPMacLabel: "aabbccddeeff", + "test": "test", + }, + }, + } + Expect(k8sClient.Create(ctx, ip)).To(Succeed()) + DeferCleanup(func(ctx SpecContext) { + Expect(k8sClient.Delete(ctx, ip)).To(Succeed()) + Eventually(Get(ip)).Should(Satisfy(errors.IsNotFound)) + }) + + By("Patching IP reservation and state") + ipAddr, err := ipamv1alpha1.IPAddrFromString("1.2.3.4") + Expect(err).NotTo(HaveOccurred()) + Eventually(UpdateStatus(ip, func() { + ip.Status.Reserved = ipAddr + ip.Status.State = ipamv1alpha1.CFinishedIPState + })).Should(Succeed()) + + By("Expecting finalizer, mac, and endpointref to be correct on the OOB") + oob := &metalv1alpha1.OOB{ + ObjectMeta: metav1.ObjectMeta{ + Name: "aabbccddeeff", + }, + } + Eventually(Object(oob)).Should(SatisfyAll( + HaveField("Finalizers", ContainElement(OOBFinalizer)), + HaveField("Spec.MACAddress", "aabbccddeeff"), + HaveField("Spec.EndpointRef.Name", ip.Name), + HaveField("Status.State", metalv1alpha1.OOBStateUnready), + WithTransform(readyReason, Equal(metalv1alpha1.OOBConditionReasonInProgress)), + )) + + By("Expecting finalizer to be correct on the IP") + Eventually(Object(ip)).Should(HaveField("Finalizers", ContainElement(OOBFinalizer))) + + By("Deleting the OOB") + Expect(k8sClient.Delete(ctx, oob)).To(Succeed()) + + By("Expecting OOB to be deleted") + Eventually(Get(oob)).Should(Satisfy(errors.IsNotFound)) + + By("Expecting finalizer to be cleared on the IP") + Eventually(Object(ip)).Should(HaveField("Finalizers", Not(ContainElement(OOBFinalizer)))) + }) + + It("should set the OOB to ignored if the ignore annotation is set", func(ctx SpecContext) { + By("Creating an IP") + ip := &ipamv1alpha1.IP{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "test-", + Namespace: OOBTemporaryNamespaceHack, + Labels: map[string]string{ + OOBIPMacLabel: "aabbccddeeff", + "test": "test", + }, + }, + } + Expect(k8sClient.Create(ctx, ip)).To(Succeed()) + DeferCleanup(func(ctx SpecContext) { + Expect(k8sClient.Delete(ctx, ip)).To(Succeed()) + Eventually(Get(ip)).Should(Satisfy(errors.IsNotFound)) + }) + + By("Patching IP reservation and state") + ipAddr, err := ipamv1alpha1.IPAddrFromString("1.2.3.4") + Expect(err).NotTo(HaveOccurred()) + Eventually(UpdateStatus(ip, func() { + ip.Status.Reserved = ipAddr + ip.Status.State = ipamv1alpha1.CFinishedIPState + })).Should(Succeed()) + + oob := &metalv1alpha1.OOB{ + ObjectMeta: metav1.ObjectMeta{ + Name: "aabbccddeeff", + }, + } + DeferCleanup(func(ctx SpecContext) { + Expect(k8sClient.Delete(ctx, oob)).To(Succeed()) + Eventually(Get(oob)).Should(Satisfy(errors.IsNotFound)) + }) + + By("Setting an ignore annoation on the OOB") + Eventually(Update(oob, func() { + if oob.Annotations == nil { + oob.Annotations = make(map[string]string, 1) + } + oob.Annotations[OOBIgnoreAnnotation] = "" + })).Should(Succeed()) + + By("Expecting OOB to be ignored") + Eventually(Object(oob)).Should(SatisfyAll( + HaveField("Status.State", metalv1alpha1.OOBStateIgnored), + WithTransform(readyReason, Equal(metalv1alpha1.OOBConditionReasonIgnored)), + )) + + By("Clearing the ignore annoation on the OOB") + Eventually(Update(oob, func() { + delete(oob.Annotations, OOBIgnoreAnnotation) + })).Should(Succeed()) + + By("Expecting OOB not to be ignored") + Eventually(Object(oob)).Should(SatisfyAll( + HaveField("Status.State", metalv1alpha1.OOBStateUnready), + WithTransform(readyReason, Equal(metalv1alpha1.OOBConditionReasonInProgress)), + )) + }) + + It("should handle an unavailable endpoint", func(ctx SpecContext) { + By("Creating an IP") + ip := &ipamv1alpha1.IP{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "test-", + Namespace: OOBTemporaryNamespaceHack, + Labels: map[string]string{ + OOBIPMacLabel: "aabbccddeeff", + "test": "test", + }, + }, + } + Expect(k8sClient.Create(ctx, ip)).To(Succeed()) + DeferCleanup(func(ctx SpecContext) { + Expect(k8sClient.Delete(ctx, ip)).To(Succeed()) + Eventually(Get(ip)).Should(Satisfy(errors.IsNotFound)) + }) + + By("Patching IP reservation and state") + ipAddr, err := ipamv1alpha1.IPAddrFromString("1.2.3.4") + Expect(err).NotTo(HaveOccurred()) + Eventually(UpdateStatus(ip, func() { + ip.Status.Reserved = ipAddr + ip.Status.State = ipamv1alpha1.CFinishedIPState + })).Should(Succeed()) + + oob := &metalv1alpha1.OOB{ + ObjectMeta: metav1.ObjectMeta{ + Name: "aabbccddeeff", + }, + } + DeferCleanup(func(ctx SpecContext) { + Expect(k8sClient.Delete(ctx, oob)).To(Succeed()) + Eventually(Get(oob)).Should(Satisfy(errors.IsNotFound)) + }) + + By("Expecting finalizer, mac, and endpointref to be correct on the OOB") + Eventually(Object(oob)).Should(SatisfyAll( + HaveField("Finalizers", ContainElement(OOBFinalizer)), + HaveField("Spec.MACAddress", "aabbccddeeff"), + HaveField("Spec.EndpointRef.Name", ip.Name), + HaveField("Status.State", metalv1alpha1.OOBStateUnready), + WithTransform(readyReason, Equal(metalv1alpha1.OOBConditionReasonInProgress)), + )) + + By("Deleting the IP") + Expect(k8sClient.Delete(ctx, ip)).To(Succeed()) + Eventually(Get(ip)).Should(Satisfy(errors.IsNotFound)) + + By("Expecting the OOB to have no endpoint") + Eventually(Object(oob)).Should(SatisfyAll( + HaveField("Spec.EndpointRef", BeNil()), + HaveField("Status.State", metalv1alpha1.OOBStateUnready), + WithTransform(readyReason, Equal(metalv1alpha1.OOBConditionReasonNoEndpoint)), + )) + + By("Recreating the IP") + ip = &ipamv1alpha1.IP{ + ObjectMeta: metav1.ObjectMeta{ + Name: ip.Name, + Namespace: OOBTemporaryNamespaceHack, + Labels: map[string]string{ + OOBIPMacLabel: "aabbccddeeff", + "test": "test", + }, + }, + } + Expect(k8sClient.Create(ctx, ip)).To(Succeed()) + Eventually(UpdateStatus(ip, func() { + ip.Status.Reserved = ipAddr + ip.Status.State = ipamv1alpha1.CFinishedIPState + })).Should(Succeed()) + + By("Expecting the OOB to have an endpoint") + Eventually(Object(oob)).Should(SatisfyAll( + HaveField("Spec.EndpointRef.Name", ip.Name), + HaveField("Status.State", metalv1alpha1.OOBStateUnready), + WithTransform(readyReason, Equal(metalv1alpha1.OOBConditionReasonInProgress)), + )) + }) + + It("should handle a bad endpoint", func(ctx SpecContext) { + By("Creating an IP") + ip := &ipamv1alpha1.IP{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "test-", + Namespace: OOBTemporaryNamespaceHack, + Labels: map[string]string{ + OOBIPMacLabel: "aabbccddeeff", + "test": "test", + }, + }, + } + Expect(k8sClient.Create(ctx, ip)).To(Succeed()) + DeferCleanup(func(ctx SpecContext) { + Expect(k8sClient.Delete(ctx, ip)).To(Succeed()) + Eventually(Get(ip)).Should(Satisfy(errors.IsNotFound)) + }) + + By("Patching IP reservation and state") + ipAddr, err := ipamv1alpha1.IPAddrFromString("1.2.3.4") + Expect(err).NotTo(HaveOccurred()) + Eventually(UpdateStatus(ip, func() { + ip.Status.Reserved = ipAddr + ip.Status.State = ipamv1alpha1.CFinishedIPState + })).Should(Succeed()) + + oob := &metalv1alpha1.OOB{ + ObjectMeta: metav1.ObjectMeta{ + Name: "aabbccddeeff", + }, + } + DeferCleanup(func(ctx SpecContext) { + Expect(k8sClient.Delete(ctx, oob)).To(Succeed()) + Eventually(Get(oob)).Should(Satisfy(errors.IsNotFound)) + }) + + By("Expecting finalizer, mac, and endpointref to be correct on the OOB") + Eventually(Object(oob)).Should(SatisfyAll( + HaveField("Finalizers", ContainElement(OOBFinalizer)), + HaveField("Spec.MACAddress", "aabbccddeeff"), + HaveField("Spec.EndpointRef.Name", ip.Name), + HaveField("Status.State", metalv1alpha1.OOBStateUnready), + WithTransform(readyReason, Equal(metalv1alpha1.OOBConditionReasonInProgress)), + )) + + By("Setting an incorrect MAC on the IP") + Eventually(Update(ip, func() { + ip.Labels[OOBIPMacLabel] = "xxxxxxyyyyyy" + })).Should(Succeed()) + + By("Expecting the OOB to be in an error state") + Eventually(Object(oob)).Should(SatisfyAll( + HaveField("Status.State", metalv1alpha1.OOBStateError), + WithTransform(readyReason, Equal(metalv1alpha1.OOBConditionReasonError)), + )) + + By("Restoring the MAC on the IP") + Eventually(Update(ip, func() { + ip.Labels[OOBIPMacLabel] = "aabbccddeeff" + })).Should(Succeed()) + + By("Expecting the OOB to recover") + Eventually(Object(oob)).Should(SatisfyAll( + HaveField("Status.State", metalv1alpha1.OOBStateUnready), + WithTransform(readyReason, Equal(metalv1alpha1.OOBConditionReasonInProgress)), + )) + + By("Setting a failed state on the IP") + Eventually(UpdateStatus(ip, func() { + ip.Status.State = ipamv1alpha1.CFailedIPState + })).Should(Succeed()) + + By("Expecting the OOB to be in an error state") + Eventually(Object(oob)).Should(SatisfyAll( + HaveField("Status.State", metalv1alpha1.OOBStateError), + WithTransform(readyReason, Equal(metalv1alpha1.OOBConditionReasonError)), + )) + + By("Restoring the state on the IP") + Eventually(UpdateStatus(ip, func() { + ip.Status.State = ipamv1alpha1.CFinishedIPState + })).Should(Succeed()) + + By("Expecting the OOB to recover") + Eventually(Object(oob)).Should(SatisfyAll( + HaveField("Status.State", metalv1alpha1.OOBStateUnready), + WithTransform(readyReason, Equal(metalv1alpha1.OOBConditionReasonInProgress)), + )) + }) }) + +func readyReason(o client.Object) (string, error) { + oob, ok := o.(*metalv1alpha1.OOB) + if !ok { + return "", fmt.Errorf("%s is not an OOB", o.GetName()) + } + var cond metav1.Condition + cond, ok = ssa.GetCondition(oob.Status.Conditions, metalv1alpha1.OOBConditionTypeReady) + if !ok { + return "", fmt.Errorf("%s has no condition of type %s", oob.Name, metalv1alpha1.OOBConditionTypeReady) + } + return cond.Reason, nil +} diff --git a/internal/controller/suite_test.go b/internal/controller/suite_test.go index c5a72345..02248b5c 100644 --- a/internal/controller/suite_test.go +++ b/internal/controller/suite_test.go @@ -89,6 +89,16 @@ var _ = BeforeSuite(func() { Expect(k8sClient.Delete(ctx, ns)).To(Succeed()) }) + ns = &v1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: OOBTemporaryNamespaceHack, + }, + } + Expect(k8sClient.Create(ctx, ns)).To(Succeed()) + DeferCleanup(func(ctx SpecContext) { + Expect(k8sClient.Delete(ctx, ns)).To(Succeed()) + }) + var mgr manager.Manager mgr, err = ctrl.NewManager(cfg, ctrl.Options{ Scheme: scheme, @@ -113,7 +123,7 @@ var _ = BeforeSuite(func() { Expect(machineClaimReconciler.SetupWithManager(mgr)).To(Succeed()) var oobReconciler *OOBReconciler - oobReconciler, err = NewOOBReconciler() + oobReconciler, err = NewOOBReconciler(ns.Name, "", "", "metal-", "bmc-temporary-password") Expect(err).NotTo(HaveOccurred()) Expect(oobReconciler).NotTo(BeNil()) Expect(oobReconciler.SetupWithManager(mgr)).To(Succeed()) diff --git a/internal/cru/cru.go b/internal/cru/cru.go new file mode 100644 index 00000000..11cbb82e --- /dev/null +++ b/internal/cru/cru.go @@ -0,0 +1,88 @@ +// SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package cru + +import ( + "context" + "os" + "strings" + + "github.com/go-logr/logr" + "k8s.io/klog/v2" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/apiutil" + "sigs.k8s.io/controller-runtime/pkg/controller" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + "sigs.k8s.io/controller-runtime/pkg/source" +) + +type preStartReconciler interface { + reconcile.Reconciler + PreStart(context.Context) error +} + +type preStartController struct { + controller.Controller + preStart func(context.Context) error +} + +func (c *preStartController) Start(ctx context.Context) error { + err := c.preStart(ctx) + if err != nil { + return err + } + + return c.Controller.Start(ctx) +} + +func CreateController(mgr ctrl.Manager, obj client.Object, reconciler reconcile.Reconciler) (controller.Controller, error) { + gvk, err := apiutil.GVKForObject(obj, mgr.GetScheme()) + if err != nil { + return nil, err + } + + name := strings.ToLower(gvk.Kind) + cl := mgr.GetLogger().WithValues("controller", name, "controllerGroup", gvk.Group, "controllerKind", gvk.Kind) + + var c controller.Controller + c, err = controller.NewUnmanaged(name, mgr, controller.Options{ + MaxConcurrentReconciles: mgr.GetControllerOptions().GroupKindConcurrency[gvk.GroupKind().String()], + Reconciler: reconciler, + LogConstructor: func(req *reconcile.Request) logr.Logger { + rl := cl + if req != nil { + rl = rl.WithValues(gvk.Kind, klog.KRef(req.Namespace, req.Name), "namespace", req.Namespace, "name", req.Name) + } + return rl + }, + }) + if err != nil { + return nil, err + } + + err = c.Watch(source.Kind(mgr.GetCache(), obj), &handler.EnqueueRequestForObject{}) + if err != nil { + return nil, err + } + + psr, ok := reconciler.(preStartReconciler) + if ok { + return &preStartController{ + Controller: c, + preStart: psr.PreStart, + }, nil + } + + return c, nil +} + +func InClusterNamespace() string { + ns, err := os.ReadFile("/var/run/secrets/kubernetes.io/serviceaccount/namespace") + if err != nil { + return "" + } + return string(ns) +}