package controller import ( "context" "crypto/rand" "encoding/hex" "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" v1alpha1 "git.unkin.net/unkin/kea-operator/api/v1alpha1" ) // KeaAPIReconciler spawns the REST API service that CRUDs KeaSubnet and // KeaClientClass CRs (a Terraform-friendly alternative to argocd-managed CRs). type KeaAPIReconciler struct { client.Client Scheme *runtime.Scheme } // +kubebuilder:rbac:groups=kea.unkin.net,resources=keaapis,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=kea.unkin.net,resources=keaapis/status,verbs=get;update;patch // +kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups="",resources=serviceaccounts;secrets,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 *KeaAPIReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { var api v1alpha1.KeaAPI if err := r.Get(ctx, req.NamespacedName, &api); err != nil { return ctrl.Result{}, client.IgnoreNotFound(err) } for _, step := range []func(context.Context, *v1alpha1.KeaAPI) error{ r.reconcileServiceAccount, r.reconcileRBAC, r.reconcileTokenSecret, r.reconcileDeployment, r.reconcileService, } { if err := step(ctx, &api); err != nil { api.Status.Phase = "Error" setReady(&api.Status.Conditions, api.Generation, false, "ReconcileError", err.Error()) _ = r.Status().Update(ctx, &api) return ctrl.Result{}, err } } var dep appsv1.Deployment _ = r.Get(ctx, types.NamespacedName{Namespace: api.Namespace, Name: api.Name}, &dep) port := api.Spec.Service.Port if port == 0 { port = 8080 } api.Status.ReadyReplicas = dep.Status.ReadyReplicas api.Status.Endpoint = fmt.Sprintf("http://%s.%s.svc:%d", api.Name, api.Namespace, port) api.Status.ObservedGeneration = api.Generation if dep.Status.ReadyReplicas > 0 { api.Status.Phase = "Ready" setReady(&api.Status.Conditions, api.Generation, true, "Ready", "api ready") } else { api.Status.Phase = "Progressing" setReady(&api.Status.Conditions, api.Generation, false, "Progressing", "waiting for api pods") } if err := r.Status().Update(ctx, &api); err != nil { return ctrl.Result{}, err } if api.Status.Phase != "Ready" { return ctrl.Result{RequeueAfter: requeueShort}, nil } return ctrl.Result{RequeueAfter: requeueLong}, nil } func (r *KeaAPIReconciler) reconcileServiceAccount(ctx context.Context, api *v1alpha1.KeaAPI) error { sa := &corev1.ServiceAccount{ObjectMeta: metav1.ObjectMeta{Name: api.Name, Namespace: api.Namespace}} _, err := ctrl.CreateOrUpdate(ctx, r.Client, sa, func() error { sa.Labels = apiLabels(api.Name) return ctrl.SetControllerReference(api, sa, r.Scheme) }) return err } func (r *KeaAPIReconciler) reconcileRBAC(ctx context.Context, api *v1alpha1.KeaAPI) error { role := &rbacv1.Role{ObjectMeta: metav1.ObjectMeta{Name: api.Name, Namespace: api.Namespace}} if _, err := ctrl.CreateOrUpdate(ctx, r.Client, role, func() error { role.Labels = apiLabels(api.Name) role.Rules = []rbacv1.PolicyRule{ { APIGroups: []string{"kea.unkin.net"}, Resources: []string{"keasubnets", "keaclientclasses"}, Verbs: []string{"get", "list", "watch", "create", "update", "patch", "delete"}, }, { APIGroups: []string{"kea.unkin.net"}, Resources: []string{"keasubnets/status", "keaclientclasses/status"}, Verbs: []string{"get"}, }, } return ctrl.SetControllerReference(api, role, r.Scheme) }); err != nil { return err } rb := &rbacv1.RoleBinding{ObjectMeta: metav1.ObjectMeta{Name: api.Name, Namespace: api.Namespace}} _, err := ctrl.CreateOrUpdate(ctx, r.Client, rb, func() error { rb.Labels = apiLabels(api.Name) 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}} return ctrl.SetControllerReference(api, rb, r.Scheme) }) return err } // reconcileTokenSecret creates the bearer-token Secret only when absent, so it // may instead be pre-seeded (e.g. by a Vault static secret). It is intentionally // not owned/overwritten once it exists. func (r *KeaAPIReconciler) reconcileTokenSecret(ctx context.Context, api *v1alpha1.KeaAPI) error { name := tokenSecretName(api) var existing corev1.Secret err := r.Get(ctx, types.NamespacedName{Namespace: api.Namespace, Name: name}, &existing) if err == nil { return nil } if !apierrors.IsNotFound(err) { return err } tok := make([]byte, 32) if _, err := rand.Read(tok); err != nil { return err } sec := &corev1.Secret{ ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: api.Namespace, Labels: apiLabels(api.Name)}, Type: corev1.SecretTypeOpaque, StringData: map[string]string{"token": hex.EncodeToString(tok)}, } return r.Create(ctx, sec) } func (r *KeaAPIReconciler) reconcileDeployment(ctx context.Context, api *v1alpha1.KeaAPI) error { image := api.Spec.Image if image == "" { image = defaultAPIImage } replicas := int32(1) if api.Spec.Replicas != nil { replicas = *api.Spec.Replicas } port := api.Spec.Service.Port if port == 0 { port = 8080 } dep := &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Name: api.Name, Namespace: api.Namespace}} _, err := ctrl.CreateOrUpdate(ctx, r.Client, dep, func() error { dep.Labels = apiLabels(api.Name) dep.Spec.Replicas = int32ptr(replicas) dep.Spec.Selector = &metav1.LabelSelector{MatchLabels: apiLabels(api.Name)} dep.Spec.Template = corev1.PodTemplateSpec{ ObjectMeta: metav1.ObjectMeta{Labels: apiLabels(api.Name)}, Spec: corev1.PodSpec{ ServiceAccountName: api.Name, NodeSelector: api.Spec.NodeSelector, Tolerations: api.Spec.Tolerations, Affinity: api.Spec.Affinity, Containers: []corev1.Container{{ Name: "kea-api", Image: image, Command: []string{"kea-api"}, Ports: []corev1.ContainerPort{{Name: "http", ContainerPort: port}}, Env: []corev1.EnvVar{ {Name: "LISTEN_ADDR", Value: fmt.Sprintf(":%d", port)}, {Name: "TARGET_NAMESPACE", Value: api.Namespace}, {Name: "KEA_API_TOKEN", ValueFrom: &corev1.EnvVarSource{ SecretKeyRef: &corev1.SecretKeySelector{ LocalObjectReference: corev1.LocalObjectReference{Name: tokenSecretName(api)}, Key: "token", }, }}, }, Resources: api.Spec.Resources, ReadinessProbe: &corev1.Probe{ ProbeHandler: corev1.ProbeHandler{HTTPGet: &corev1.HTTPGetAction{ Path: "/healthz", Port: intstrFromInt(int(port)), }}, InitialDelaySeconds: 3, PeriodSeconds: 10, }, SecurityContext: &corev1.SecurityContext{ RunAsNonRoot: ptr(true), AllowPrivilegeEscalation: ptr(false), ReadOnlyRootFilesystem: ptr(true), Capabilities: &corev1.Capabilities{Drop: []corev1.Capability{"ALL"}}, }, }}, }, } return ctrl.SetControllerReference(api, dep, r.Scheme) }) return err } func (r *KeaAPIReconciler) reconcileService(ctx context.Context, api *v1alpha1.KeaAPI) error { port := api.Spec.Service.Port if port == 0 { port = 8080 } svcType := api.Spec.Service.Type if svcType == "" { svcType = corev1.ServiceTypeClusterIP } svc := &corev1.Service{ObjectMeta: metav1.ObjectMeta{Name: api.Name, Namespace: api.Namespace}} _, err := ctrl.CreateOrUpdate(ctx, r.Client, svc, func() error { svc.Labels = apiLabels(api.Name) svc.Annotations = api.Spec.Service.Annotations svc.Spec.Type = svcType svc.Spec.Selector = apiLabels(api.Name) svc.Spec.Ports = []corev1.ServicePort{{Name: "http", Port: port, TargetPort: intstrFromInt(int(port))}} return ctrl.SetControllerReference(api, svc, r.Scheme) }) return err } func tokenSecretName(api *v1alpha1.KeaAPI) string { if api.Spec.TokenSecretName != "" { return api.Spec.TokenSecretName } return api.Name + "-token" } func apiLabels(name string) map[string]string { return map[string]string{ managedByLabel: managedByValue, "app.kubernetes.io/name": "kea-api", "app.kubernetes.io/instance": name, } } func (r *KeaAPIReconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). For(&v1alpha1.KeaAPI{}). Owns(&appsv1.Deployment{}). Owns(&corev1.Service{}). Owns(&corev1.ServiceAccount{}). Owns(&rbacv1.Role{}). Owns(&rbacv1.RoleBinding{}). Complete(r) }