package controller import ( "context" "fmt" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" rbacv1 "k8s.io/api/rbac/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/types" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/log" bindv1alpha1 "git.unkin.net/unkin/bind-operator/api/v1alpha1" "git.unkin.net/unkin/bind-operator/internal/bind" ) // BindTSIGAPIReconciler deploys the companion TSIG API (Deployment, Service, // ConfigMap, token Secret and RBAC) when a BindTSIGAPI resource exists. type BindTSIGAPIReconciler struct { client.Client Scheme *runtime.Scheme } // +kubebuilder:rbac:groups=bind.unkin.net,resources=bindtsigapis,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=bind.unkin.net,resources=bindtsigapis/status,verbs=get;update;patch // +kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups="",resources=serviceaccounts,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=rbac.authorization.k8s.io,resources=roles;rolebindings,verbs=get;list;watch;create;update;patch;delete func (r *BindTSIGAPIReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { logger := log.FromContext(ctx) var api bindv1alpha1.BindTSIGAPI if err := r.Get(ctx, req.NamespacedName, &api); err != nil { return ctrl.Result{}, client.IgnoreNotFound(err) } tokenSecret := api.Spec.TokenSecretName if tokenSecret == "" { tokenSecret = api.Name + "-token" } targetNS := api.Spec.TargetNamespace if targetNS == "" { targetNS = api.Namespace } for _, step := range []func(context.Context, *bindv1alpha1.BindTSIGAPI, string, string) error{ r.reconcileServiceAccount, r.reconcileRBAC, r.reconcileTokenSecret, r.reconcileConfigMap, r.reconcileDeployment, r.reconcileService, } { if err := step(ctx, &api, tokenSecret, targetNS); err != nil { return ctrl.Result{}, err } } // Status from the Deployment. var dep appsv1.Deployment _ = r.Get(ctx, types.NamespacedName{Namespace: api.Namespace, Name: api.Name}, &dep) port := api.Spec.Port if port == 0 { port = 8443 } api.Status.ReadyReplicas = dep.Status.ReadyReplicas api.Status.Endpoint = fmt.Sprintf("http://%s.%s.svc:%d", api.Name, api.Namespace, port) api.Status.TokenSecret = tokenSecret api.Status.ObservedGeneration = api.Generation ready := dep.Status.ReadyReplicas > 0 if ready { api.Status.Phase = "Ready" } else { api.Status.Phase = "Progressing" } setReady(&api.Status.Conditions, api.Generation, ready, "Reconciled", fmt.Sprintf("%d ready", dep.Status.ReadyReplicas)) if err := r.Status().Update(ctx, &api); err != nil { return ctrl.Result{}, err } if !ready { return ctrl.Result{RequeueAfter: requeueShort}, nil } logger.V(1).Info("tsig api reconciled", "api", api.Name) return ctrl.Result{}, nil } func tsigAPILabels(name string) map[string]string { return map[string]string{ managedByLabel: managedByValue, "app.kubernetes.io/name": "bind-tsig-api", "app.kubernetes.io/instance": name, "app.kubernetes.io/component": "tsig-api", } } func (r *BindTSIGAPIReconciler) reconcileServiceAccount(ctx context.Context, api *bindv1alpha1.BindTSIGAPI, _, _ string) error { sa := &corev1.ServiceAccount{ObjectMeta: metav1.ObjectMeta{Name: api.Name, Namespace: api.Namespace, Labels: tsigAPILabels(api.Name)}} return r.apply(ctx, api, sa, func() {}) } func (r *BindTSIGAPIReconciler) reconcileRBAC(ctx context.Context, api *bindv1alpha1.BindTSIGAPI, _, targetNS string) error { role := &rbacv1.Role{ObjectMeta: metav1.ObjectMeta{Name: api.Name, Namespace: targetNS, Labels: tsigAPILabels(api.Name)}} if err := r.applyIn(ctx, api, role, targetNS, func() { role.Rules = []rbacv1.PolicyRule{ {APIGroups: []string{"bind.unkin.net"}, Resources: []string{"bindtsigkeys"}, Verbs: []string{"get", "list", "watch", "create", "update", "patch", "delete"}}, {APIGroups: []string{"bind.unkin.net"}, Resources: []string{"bindtsigkeys/status"}, Verbs: []string{"get"}}, {APIGroups: []string{""}, Resources: []string{"secrets"}, Verbs: []string{"get", "list", "watch", "delete"}}, } }); err != nil { return err } rb := &rbacv1.RoleBinding{ObjectMeta: metav1.ObjectMeta{Name: api.Name, Namespace: targetNS, Labels: tsigAPILabels(api.Name)}} return r.applyIn(ctx, api, rb, targetNS, func() { rb.RoleRef = rbacv1.RoleRef{APIGroup: "rbac.authorization.k8s.io", Kind: "Role", Name: api.Name} rb.Subjects = []rbacv1.Subject{{Kind: "ServiceAccount", Name: api.Name, Namespace: api.Namespace}} }) } // reconcileTokenSecret creates the master-access-token Secret only when it does // not already exist, so a VaultStaticSecret may pre-seed it. Not owned by the // BindTSIGAPI, so it survives and stays overwritable. func (r *BindTSIGAPIReconciler) reconcileTokenSecret(ctx context.Context, api *bindv1alpha1.BindTSIGAPI, tokenSecret, _ string) error { var existing corev1.Secret err := r.Get(ctx, types.NamespacedName{Namespace: api.Namespace, Name: tokenSecret}, &existing) if err == nil { return nil } if !apierrors.IsNotFound(err) { return err } token, genErr := bind.GenerateSecret(32) if genErr != nil { return genErr } s := &corev1.Secret{ ObjectMeta: metav1.ObjectMeta{Name: tokenSecret, Namespace: api.Namespace, Labels: tsigAPILabels(api.Name)}, Data: map[string][]byte{"token": []byte(token)}, } return r.Create(ctx, s) } func (r *BindTSIGAPIReconciler) reconcileConfigMap(ctx context.Context, api *bindv1alpha1.BindTSIGAPI, _, targetNS string) error { cm := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: api.Name + "-config", Namespace: api.Namespace, Labels: tsigAPILabels(api.Name)}} return r.apply(ctx, api, cm, func() { port := api.Spec.Port if port == 0 { port = 8443 } data := map[string]string{ "LISTEN_ADDR": fmt.Sprintf(":%d", port), "TARGET_NAMESPACE": targetNS, } for k, v := range api.Spec.Env { data[k] = v } cm.Data = data }) } func (r *BindTSIGAPIReconciler) reconcileDeployment(ctx context.Context, api *bindv1alpha1.BindTSIGAPI, tokenSecret, _ string) error { dep := &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Name: api.Name, Namespace: api.Namespace, Labels: tsigAPILabels(api.Name)}} return r.apply(ctx, api, dep, func() { replicas := api.Spec.Replicas if replicas == 0 { replicas = 1 } image := api.Spec.Image if image == "" { image = "git.unkin.net/unkin/bind-tsig-api:latest" } port := api.Spec.Port if port == 0 { port = 8443 } labels := tsigAPILabels(api.Name) dep.Spec = appsv1.DeploymentSpec{ Replicas: &replicas, Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"app.kubernetes.io/instance": api.Name}}, Template: corev1.PodTemplateSpec{ ObjectMeta: metav1.ObjectMeta{Labels: labels}, Spec: corev1.PodSpec{ ServiceAccountName: api.Name, SecurityContext: &corev1.PodSecurityContext{RunAsNonRoot: ptr(true)}, Containers: []corev1.Container{{ Name: "tsig-api", Image: image, ImagePullPolicy: api.Spec.ImagePullPolicy, Command: []string{"tsig-api"}, Ports: []corev1.ContainerPort{{Name: "https", ContainerPort: port}}, EnvFrom: []corev1.EnvFromSource{{ConfigMapRef: &corev1.ConfigMapEnvSource{LocalObjectReference: corev1.LocalObjectReference{Name: api.Name + "-config"}}}}, Env: []corev1.EnvVar{{ Name: "API_TOKEN", ValueFrom: &corev1.EnvVarSource{SecretKeyRef: &corev1.SecretKeySelector{LocalObjectReference: corev1.LocalObjectReference{Name: tokenSecret}, Key: "token"}}, }}, Resources: api.Spec.Resources, ReadinessProbe: &corev1.Probe{ ProbeHandler: corev1.ProbeHandler{HTTPGet: &corev1.HTTPGetAction{Path: "/healthz", Port: intstrFromInt(int(port))}}, InitialDelaySeconds: 5, PeriodSeconds: 10, }, SecurityContext: &corev1.SecurityContext{ AllowPrivilegeEscalation: ptr(false), ReadOnlyRootFilesystem: ptr(true), Capabilities: &corev1.Capabilities{Drop: []corev1.Capability{"ALL"}}, }, }}, }, }, } }) } func (r *BindTSIGAPIReconciler) reconcileService(ctx context.Context, api *bindv1alpha1.BindTSIGAPI, _, _ string) error { svc := &corev1.Service{ObjectMeta: metav1.ObjectMeta{Name: api.Name, Namespace: api.Namespace, Labels: tsigAPILabels(api.Name), Annotations: api.Spec.Service.Annotations}} return r.apply(ctx, api, svc, func() { port := api.Spec.Port if port == 0 { port = 8443 } svcType := api.Spec.Service.Type if svcType == "" { svcType = corev1.ServiceTypeClusterIP } svc.Spec.Type = svcType svc.Spec.Selector = map[string]string{"app.kubernetes.io/instance": api.Name} svc.Spec.Ports = []corev1.ServicePort{{Name: "https", Port: port, TargetPort: intstrFromInt(int(port))}} if api.Spec.Service.LoadBalancerIP != "" { svc.Spec.LoadBalancerIP = api.Spec.Service.LoadBalancerIP } }) } // apply creates or updates an owned object (in the BindTSIGAPI's namespace). func (r *BindTSIGAPIReconciler) apply(ctx context.Context, api *bindv1alpha1.BindTSIGAPI, obj client.Object, mutate func()) error { return r.applyIn(ctx, api, obj, api.Namespace, mutate) } // applyIn creates or updates an object; ownership is set only for objects in // the BindTSIGAPI's own namespace (cross-namespace owner refs are not allowed). func (r *BindTSIGAPIReconciler) applyIn(ctx context.Context, api *bindv1alpha1.BindTSIGAPI, obj client.Object, namespace string, mutate func()) error { key := client.ObjectKeyFromObject(obj) err := r.Get(ctx, key, obj) if apierrors.IsNotFound(err) { mutate() if namespace == api.Namespace { if serr := ctrl.SetControllerReference(api, obj, r.Scheme); serr != nil { return serr } } return r.Create(ctx, obj) } if err != nil { return err } mutate() return r.Update(ctx, obj) } func (r *BindTSIGAPIReconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). For(&bindv1alpha1.BindTSIGAPI{}). Owns(&appsv1.Deployment{}). Owns(&corev1.Service{}). Owns(&corev1.ConfigMap{}). Owns(&corev1.ServiceAccount{}). Complete(r) } func ptr[T any](v T) *T { return &v }