From d628025cb179085eaf9f8aab3967b68a1ddd96b6 Mon Sep 17 00:00:00 2001 From: Mickael Stanislas Date: Thu, 31 Oct 2024 09:15:47 +0100 Subject: [PATCH] chore: auto create mutatingwebhookconfig (#87) --- .changelog/87.txt | 3 + cmd/operator/main.go | 10 ++ go.mod | 2 +- internal/actions/action_alert_discord.go | 2 +- internal/actions/action_alert_email.go | 2 +- internal/actions/actions.go | 19 ++- internal/actions/apply.go | 2 +- internal/controller/namespace_controller.go | 117 ++++++++++++++++++ internal/kubeclient/mutating.go | 26 +++- internal/kubeclient/mutatingMatchCondition.go | 30 +++-- internal/models/action.go | 16 --- internal/models/mutator.go | 6 +- internal/utils/random.go | 15 +++ manifests/operator/role.yaml | 8 ++ tools/mutator/main.go | 2 +- 15 files changed, 220 insertions(+), 40 deletions(-) create mode 100644 .changelog/87.txt create mode 100644 internal/controller/namespace_controller.go create mode 100644 internal/utils/random.go diff --git a/.changelog/87.txt b/.changelog/87.txt new file mode 100644 index 0000000..b52a27a --- /dev/null +++ b/.changelog/87.txt @@ -0,0 +1,3 @@ +```release-note:feature +`chore` - Now the mutating webhook configuration used for mutate image tag on pod creation is created by the operator itself. This is done to avoid the need for the user to create the mutating webhook configuration manually. The operator will also update the mutating webhook configuration if the user changes the configuration (annotations) in the namespace. +``` \ No newline at end of file diff --git a/cmd/operator/main.go b/cmd/operator/main.go index 085a4c2..547c93e 100644 --- a/cmd/operator/main.go +++ b/cmd/operator/main.go @@ -144,6 +144,16 @@ func main() { c <- syscall.SIGINT } + if err = (&controller.NamespaceReconciler{ + Client: mgr.GetClient(), + KubeAPIClient: kubeAPIClient, + Scheme: mgr.GetScheme(), + Recorder: mgr.GetEventRecorderFor("kimup-operator"), + }).SetupWithManager(mgr); err != nil { + log.WithError(err).Error(err, "unable to create controller", "controller", "Namespace") + c <- syscall.SIGINT + } + // +kubebuilder:scaffold:builder ctx, cancel := context.WithCancel(context.Background()) diff --git a/go.mod b/go.mod index f519745..bdb79d1 100644 --- a/go.mod +++ b/go.mod @@ -26,6 +26,7 @@ require ( github.com/sirupsen/logrus v1.9.3 github.com/stretchr/testify v1.9.0 github.com/thanhpk/randstr v1.0.6 + golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 golang.org/x/term v0.25.0 k8s.io/api v0.31.2 k8s.io/apimachinery v0.31.2 @@ -109,7 +110,6 @@ require ( github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect github.com/xeipuuv/gojsonschema v1.2.0 // indirect go.uber.org/zap v1.27.0 // indirect - golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect golang.org/x/mod v0.20.0 // indirect golang.org/x/net v0.28.0 // indirect golang.org/x/oauth2 v0.22.0 // indirect diff --git a/internal/actions/action_alert_discord.go b/internal/actions/action_alert_discord.go index 45b3aac..df91f3e 100644 --- a/internal/actions/action_alert_discord.go +++ b/internal/actions/action_alert_discord.go @@ -13,7 +13,7 @@ import ( ) var ( - _ models.ActionInterface = &alertDiscord{} + _ ActionInterface = &alertDiscord{} _ models.AlertInterface[models.AlertDiscord] = &alertDiscord{} ) diff --git a/internal/actions/action_alert_email.go b/internal/actions/action_alert_email.go index aa7d409..ad428fb 100644 --- a/internal/actions/action_alert_email.go +++ b/internal/actions/action_alert_email.go @@ -13,7 +13,7 @@ import ( ) var ( - _ models.ActionInterface = &alertEmail{} + _ ActionInterface = &alertEmail{} _ models.AlertInterface[models.AlertEmail] = &alertEmail{} ) diff --git a/internal/actions/actions.go b/internal/actions/actions.go index bcb9226..1468834 100644 --- a/internal/actions/actions.go +++ b/internal/actions/actions.go @@ -1,13 +1,24 @@ package actions import ( + "context" + "github.com/orange-cloudavenue/kube-image-updater/api/v1alpha1" "github.com/orange-cloudavenue/kube-image-updater/internal/kubeclient" "github.com/orange-cloudavenue/kube-image-updater/internal/models" ) type ( - _actions map[models.ActionName]models.ActionInterface + ActionInterface interface { + Init(kubeClient kubeclient.Interface, tags models.Tags, image *v1alpha1.Image, data v1alpha1.ValueOrValueFrom) + Execute(context.Context) error + GetName() models.ActionName + GetActualTag() string + GetNewTag() string + GetAvailableTags() []string + } + + _actions map[models.ActionName]ActionInterface action struct { tags models.Tags @@ -25,7 +36,7 @@ const ( AlertEmail models.ActionName = "alert-email" ) -func register(name models.ActionName, action models.ActionInterface) { +func register(name models.ActionName, action ActionInterface) { actions[name] = action } @@ -56,7 +67,7 @@ func ParseActionName(name string) (models.ActionName, error) { // Returns: // - ActionInterface: The action associated with the given name. // - error: An error indicating if the action was not found (ErrActionNotFound). -func GetAction(name models.ActionName) (models.ActionInterface, error) { +func GetAction(name models.ActionName) (ActionInterface, error) { if _, ok := actions[name]; !ok { return nil, ErrActionNotFound } @@ -74,7 +85,7 @@ func GetAction(name models.ActionName) (models.ActionInterface, error) { // Returns: // - An ActionInterface corresponding to the parsed action name, or nil if not found. // - An error if the action name could not be parsed. -func GetActionWithUntypedName(name string) (models.ActionInterface, error) { +func GetActionWithUntypedName(name string) (ActionInterface, error) { n, err := ParseActionName(name) if err != nil { return nil, err diff --git a/internal/actions/apply.go b/internal/actions/apply.go index 77aacab..1b2a8f5 100644 --- a/internal/actions/apply.go +++ b/internal/actions/apply.go @@ -7,7 +7,7 @@ import ( "github.com/orange-cloudavenue/kube-image-updater/internal/models" ) -var _ models.ActionInterface = &apply{} +var _ ActionInterface = &apply{} type ( // apply is an action that applies the new tag to the image diff --git a/internal/controller/namespace_controller.go b/internal/controller/namespace_controller.go new file mode 100644 index 0000000..9547bcc --- /dev/null +++ b/internal/controller/namespace_controller.go @@ -0,0 +1,117 @@ +/* +Copyright 2024. + +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 controller + +import ( + "context" + + "github.com/sirupsen/logrus" + admissionregistrationv1 "k8s.io/api/admissionregistration/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/tools/record" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/orange-cloudavenue/kube-image-updater/internal/annotations" + "github.com/orange-cloudavenue/kube-image-updater/internal/kubeclient" + "github.com/orange-cloudavenue/kube-image-updater/internal/log" + "github.com/orange-cloudavenue/kube-image-updater/internal/models" + "github.com/orange-cloudavenue/kube-image-updater/internal/utils" +) + +// NamespaceReconciler reconciles a Namespace object +type NamespaceReconciler struct { + client.Client + KubeAPIClient *kubeclient.Client + Scheme *runtime.Scheme + Recorder record.EventRecorder +} + +// +kubebuilder:rbac:groups=core,resources=namespaces,verbs=get;list;watch + +// Reconcile is part of the main kubernetes reconciliation loop which aims to +// move the current state of the cluster closer to the desired state. +// the Image object against the actual cluster state, and then +// perform operations to make the cluster state reflect the state specified by +// the user. +// +// For more details, check Reconcile and its Result here: +// - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.19.0/pkg/reconcile +func (r *NamespaceReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + xlog := log.WithContext(ctx).WithFields(logrus.Fields{ + "namespace": req.Namespace, + "name": req.Name, + }) + + xlog.Info("Reconciling Namespace") + + var ( + ns corev1.Namespace + foundInMutatorConfig bool + ) + + if err := r.Client.Get(ctx, req.NamespacedName, &ns); err != nil { + if client.IgnoreNotFound(err) == nil { + xlog.WithError(err).Error("could not get the namespace object") + foundInMutatorConfig = true // Force rebuilding the mutating configuration + } else { + return ctrl.Result{}, err + } + } + + // get mutator configuration + mutator, _ := r.KubeAPIClient.Mutator().GetMutatingConfiguration(ctx, models.MutatorWebhookConfigurationName) + // ignore error, we will create it if it does not exist + if mutator != nil { + wName := kubeclient.NamespaceMatchConditionBuilder{}.New(req.Name).GetName() + for _, webhook := range mutator.Webhooks { + if webhook.Name == wName { + foundInMutatorConfig = true + break + } + } + } + + an := annotations.New(ctx, &ns) + + if an.Enabled().Get() || foundInMutatorConfig { + _, err := r.KubeAPIClient.Mutator().CreateOrUpdateMutatingConfiguration( + ctx, + models.MutatorWebhookConfigurationName, + admissionregistrationv1.ServiceReference{ + Name: "mutator", + Namespace: "kimup-operator", + Path: &models.MutatorWebhookPathMutateImageTag, + }, + admissionregistrationv1.Fail, + ) + if err != nil { + xlog.WithError(err).Error("could not create or update mutating configuration") + return ctrl.Result{RequeueAfter: utils.RandomSecondInRange(1, 7)}, err + } + } + + return ctrl.Result{}, nil +} + +// SetupWithManager sets up the controller with the Manager. +func (r *NamespaceReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&corev1.Namespace{}). + Complete(r) +} diff --git a/internal/kubeclient/mutating.go b/internal/kubeclient/mutating.go index 76e5267..80f56db 100644 --- a/internal/kubeclient/mutating.go +++ b/internal/kubeclient/mutating.go @@ -8,6 +8,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "github.com/orange-cloudavenue/kube-image-updater/internal/annotations" + "github.com/orange-cloudavenue/kube-image-updater/internal/log" "github.com/orange-cloudavenue/kube-image-updater/internal/utils" ) @@ -33,6 +34,16 @@ func (a *MutatorObj) GetMutatingConfiguration(ctx context.Context, name string) } func (a *MutatorObj) CreateOrUpdateMutatingConfiguration(ctx context.Context, name string, svc admissionregistrationv1.ServiceReference, policy admissionregistrationv1.FailurePolicyType) (*admissionregistrationv1.MutatingWebhookConfiguration, error) { + // Get kimup-operator deployment to get UID and inject owner reference to the mutating configuration + // This is needed to ensure that the mutating configuration is deleted when the operator is deleted + // This is a workaround for the lack of garbage collection in the admissionregistration.k8s.io/v1 API + operatorDeployment, err := a.AppsV1().Deployments("kimup-operator").List(ctx, metav1.ListOptions{ + LabelSelector: "app.kubernetes.io/instance=kimup-operator", + }) + if err != nil { + log.WithError(err).Warn("could not get the operator deployment") + } + // Get All Namespaces with the "enabled" label nsList, err := a.CoreV1().Namespaces().List(ctx, metav1.ListOptions{}) if err != nil { @@ -53,6 +64,17 @@ func (a *MutatorObj) CreateOrUpdateMutatingConfiguration(ctx context.Context, na } } + if operatorDeployment != nil && len(operatorDeployment.Items) > 0 && mwc.OwnerReferences == nil { + mwc.OwnerReferences = []metav1.OwnerReference{ + { + APIVersion: "apps/v1", + Kind: "Deployment", + Name: operatorDeployment.Items[0].Name, + UID: operatorDeployment.Items[0].UID, + }, + } + } + // reset webhooks settings mwc.Webhooks = []admissionregistrationv1.MutatingWebhook{} @@ -62,7 +84,7 @@ func (a *MutatorObj) CreateOrUpdateMutatingConfiguration(ctx context.Context, na continue } - mwc.Webhooks = append(mwc.Webhooks, a.buildMutatingWebhookConfiguration(svc, policy, &namespaceMatchConditionBuilder{namespace: ns.Name})) + mwc.Webhooks = append(mwc.Webhooks, a.buildMutatingWebhookConfiguration(svc, policy, &namespaceMatchConditionBuilder{Namespace: ns.Name})) } // Add the default matchCondition (All pods with annotation enabled == true) @@ -77,7 +99,7 @@ func (a *MutatorObj) CreateOrUpdateMutatingConfiguration(ctx context.Context, na func (a *MutatorObj) buildMutatingWebhookConfiguration(svc admissionregistrationv1.ServiceReference, policy admissionregistrationv1.FailurePolicyType, matchConditionBuilder matchConditionBuilderInterface) admissionregistrationv1.MutatingWebhook { return admissionregistrationv1.MutatingWebhook{ - Name: matchConditionBuilder.getName() + ".image-tag.kimup.cloudavenue.io", + Name: matchConditionBuilder.GetName(), AdmissionReviewVersions: []string{"v1", "v1beta1"}, SideEffects: utils.ToPTR(admissionregistrationv1.SideEffectClassNone), ClientConfig: admissionregistrationv1.WebhookClientConfig{ diff --git a/internal/kubeclient/mutatingMatchCondition.go b/internal/kubeclient/mutatingMatchCondition.go index 74a5044..c621db6 100644 --- a/internal/kubeclient/mutatingMatchCondition.go +++ b/internal/kubeclient/mutatingMatchCondition.go @@ -6,26 +6,36 @@ import ( admissionregistrationv1 "k8s.io/api/admissionregistration/v1" "github.com/orange-cloudavenue/kube-image-updater/internal/annotations" + "github.com/orange-cloudavenue/kube-image-updater/internal/models" ) type ( matchConditionBuilderInterface interface { buildMatchCondition() []admissionregistrationv1.MatchCondition - getName() string + GetName() string } + NamespaceMatchConditionBuilder struct { + namespaceMatchConditionBuilder + } namespaceMatchConditionBuilder struct { - namespace string + Namespace string } defaultMatchConditionBuilder struct{} ) +func (n NamespaceMatchConditionBuilder) New(namespace string) matchConditionBuilderInterface { + return &namespaceMatchConditionBuilder{ + Namespace: namespace, + } +} + // defaultMatchConditionBuilder var _ matchConditionBuilderInterface = &defaultMatchConditionBuilder{} -func (m *defaultMatchConditionBuilder) buildMatchCondition() []admissionregistrationv1.MatchCondition { +func (m defaultMatchConditionBuilder) buildMatchCondition() []admissionregistrationv1.MatchCondition { return []admissionregistrationv1.MatchCondition{ { Name: "annotation-is-true", @@ -34,27 +44,27 @@ func (m *defaultMatchConditionBuilder) buildMatchCondition() []admissionregistra } } -func (m *defaultMatchConditionBuilder) getName() string { - return "default" +func (m defaultMatchConditionBuilder) GetName() string { + return "default." + models.MutatorWebhookName } // * namespaceMatchConditionBuilder var _ matchConditionBuilderInterface = &namespaceMatchConditionBuilder{} -func (n *namespaceMatchConditionBuilder) buildMatchCondition() []admissionregistrationv1.MatchCondition { +func (n namespaceMatchConditionBuilder) buildMatchCondition() []admissionregistrationv1.MatchCondition { return []admissionregistrationv1.MatchCondition{ { Name: "annotation-is-not-false", Expression: fmt.Sprintf("object.metadata.?annotations['%s'].orValue('') != 'false'", annotations.KeyEnabled), }, { - Name: fmt.Sprintf("namespace-%s-match", n.namespace), - Expression: fmt.Sprintf("object.metadata.namespace == '%s'", n.namespace), + Name: fmt.Sprintf("namespace-%s-match", n.Namespace), + Expression: fmt.Sprintf("object.metadata.namespace == '%s'", n.Namespace), }, } } -func (n *namespaceMatchConditionBuilder) getName() string { - return n.namespace + ".ns" +func (n namespaceMatchConditionBuilder) GetName() string { + return n.Namespace + ".ns." + models.MutatorWebhookName } diff --git a/internal/models/action.go b/internal/models/action.go index ebaeb5f..76e17b5 100644 --- a/internal/models/action.go +++ b/internal/models/action.go @@ -1,22 +1,6 @@ package models -import ( - "context" - - "github.com/orange-cloudavenue/kube-image-updater/api/v1alpha1" - "github.com/orange-cloudavenue/kube-image-updater/internal/kubeclient" -) - type ( - ActionInterface interface { - Init(kubeClient kubeclient.Interface, tags Tags, image *v1alpha1.Image, data v1alpha1.ValueOrValueFrom) - Execute(context.Context) error - GetName() ActionName - GetActualTag() string - GetNewTag() string - GetAvailableTags() []string - } - ActionName string ) diff --git a/internal/models/mutator.go b/internal/models/mutator.go index 1c8b88e..09a93a3 100644 --- a/internal/models/mutator.go +++ b/internal/models/mutator.go @@ -6,9 +6,9 @@ var ( MutatorDefaultPort int32 = 8443 MutatorDefaultAddr = fmt.Sprintf(":%d", MutatorDefaultPort) - MutatorMutatingWebhookConfigurationName = "kimup-admission-controller-mutating" - MutatorMutatingWebhookName = "image-tag.kimup.io" - MutatorServiceName = MutatorMutatingWebhookConfigurationName + MutatorWebhookConfigurationName = "kimup-mutator" + MutatorWebhookName = "image-tag.kimup.cloudavenue.io" + MutatorServiceName = MutatorWebhookConfigurationName MutatorWebhookPathMutateImageTag = "/mutate/image-tag" ) diff --git a/internal/utils/random.go b/internal/utils/random.go new file mode 100644 index 0000000..aa09c48 --- /dev/null +++ b/internal/utils/random.go @@ -0,0 +1,15 @@ +package utils + +import ( + "time" + + "golang.org/x/exp/rand" +) + +func RandomInRange(start, end int) int { + return rand.Intn(end-start+1) + start +} + +func RandomSecondInRange(start, end int) time.Duration { + return time.Duration(RandomInRange(start, end)) * time.Second +} diff --git a/manifests/operator/role.yaml b/manifests/operator/role.yaml index 4b2a653..333a1de 100644 --- a/manifests/operator/role.yaml +++ b/manifests/operator/role.yaml @@ -48,6 +48,14 @@ rules: - patch - update - watch +- apiGroups: + - "" + resources: + - namespaces + verbs: + - get + - list + - watch - apiGroups: - kimup.cloudavenue.io resources: diff --git a/tools/mutator/main.go b/tools/mutator/main.go index 44aeb9e..48fd3e5 100644 --- a/tools/mutator/main.go +++ b/tools/mutator/main.go @@ -35,7 +35,7 @@ func main() { _, err = k.Mutator().CreateOrUpdateMutatingConfiguration( context.Background(), - models.MutatorMutatingWebhookConfigurationName, + models.MutatorWebhookConfigurationName, admissionregistrationv1.ServiceReference{ Name: "mutator", Namespace: "kimup-operator",