...

Source file src/edge-infra.dev/pkg/f8n/gcp/k8s/controllers/dennis/computeaddress_controller.go

Documentation: edge-infra.dev/pkg/f8n/gcp/k8s/controllers/dennis

     1  package dennis
     2  
     3  import (
     4  	"context"
     5  	"encoding/json"
     6  	"fmt"
     7  	"reflect"
     8  	"strings"
     9  	"time"
    10  
    11  	compute "github.com/GoogleCloudPlatform/k8s-config-connector/pkg/clients/generated/apis/compute/v1beta1"
    12  	dns "github.com/GoogleCloudPlatform/k8s-config-connector/pkg/clients/generated/apis/dns/v1beta1"
    13  	"github.com/GoogleCloudPlatform/k8s-config-connector/pkg/clients/generated/apis/k8s/v1alpha1"
    14  	"github.com/fluxcd/pkg/ssa"
    15  	"github.com/go-logr/logr"
    16  	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    17  	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
    18  	"sigs.k8s.io/cli-utils/pkg/kstatus/polling"
    19  	ctrl "sigs.k8s.io/controller-runtime"
    20  	"sigs.k8s.io/controller-runtime/pkg/client"
    21  	"sigs.k8s.io/controller-runtime/pkg/event"
    22  	"sigs.k8s.io/controller-runtime/pkg/predicate"
    23  
    24  	"edge-infra.dev/pkg/edge/constants"
    25  	"edge-infra.dev/pkg/k8s/konfigkonnector/apis/meta"
    26  	unstructuredutil "edge-infra.dev/pkg/k8s/unstructured"
    27  )
    28  
    29  // +kubebuilder:rbac:groups="compute.cnrm.cloud.google.com",resources=computeaddresses,verbs=get;list;update;patch;watch
    30  // +kubebuilder:rbac:groups="compute.cnrm.cloud.google.com",resources=computeaddresses/status,verbs=get
    31  // +kubebuilder:rbac:groups="dns.cnrm.cloud.google.com",resources=dnsrecordsets,verbs=create;get;list;update;patch;watch
    32  // +kubebuilder:rbac:groups="dns.cnrm.cloud.google.com",resources=dnsrecordsets/status,verbs=get
    33  
    34  var (
    35  	domain = "dns." + constants.Domain
    36  	// NameAnnotation is used to provide the domain name for the DNS record
    37  	NameAnnotation = domain + "/name"
    38  	// ManagedZoneAnnotation is used to provide a reference to the DNSManagedZone
    39  	// to use for this DNSRecordSet. It can be provided in the form of `namespace/name`,
    40  	// or simply just `name` (if the DNSManagedZone is in the same namespace as the
    41  	// ComputeAddress)
    42  	ManagedZoneAnnotation = domain + "/managed-zone"
    43  	// DNSProjectAnnotation controls the GCP project that the DNS record will be
    44  	// created in. It must be the same project as the Cloud DNS Zone
    45  	DNSProjectAnnotation = domain + "/dns-project-id"
    46  	// DNSExternalZoneAnnotation controls whether the zone is referenced via k8s object
    47  	// or the GCP resource directly
    48  	DNSExternalZoneAnnotation = domain + "/external"
    49  	// RecordConfigsAnnotation is a JSON string containing structured configuration.
    50  	// Supports the same options as individual annotations but allows for outputting
    51  	// multiple DNSRecordSets for a single ComputeAddress. Must parse as []RecordConfig
    52  	RecordConfigsAnnotation = domain + "/record-configs"
    53  	// time-to-live
    54  	ttl = 300
    55  )
    56  
    57  // ComputeAddressReconciler reconciles ComputeAddress objects to in order to
    58  // create corresponding DNSRecordSet objects based on annotations.
    59  //
    60  // Without this controller, in order to create a DNSRecordSet from a ComputeAddress,
    61  // you have to apply the ComputeAddress and read the IP from the Status. Using
    62  // these annotations allows configuring that relationship statically.
    63  type ComputeAddressReconciler struct {
    64  	client.Client
    65  	Log             logr.Logger
    66  	ResourceManager *ssa.ResourceManager
    67  	Name            string
    68  }
    69  
    70  // RecordConfig is a structured representation of a single desired output. To be parsed from
    71  // the dns.edge.ncr.com/record-configs annotation
    72  type RecordConfig struct {
    73  	Name         string `json:"name"`
    74  	ManagedZone  string `json:"managed-zone"`
    75  	DNSProjectID string `json:"dns-project-id"`
    76  	External     string `json:"external"`
    77  
    78  	genSuffix string
    79  }
    80  
    81  // only process ComputeAddresses with a minimum set of annotations;
    82  // we cant create a DNSRecordSet without this information
    83  func hasRequiredAnnotations(annos map[string]string) bool {
    84  	_, name := annos[NameAnnotation]
    85  	_, zone := annos[ManagedZoneAnnotation]
    86  	_, dns := annos[DNSProjectAnnotation]
    87  	return name && zone && dns
    88  }
    89  
    90  func hasStructuredAnnotation(annos map[string]string) bool {
    91  	_, ok := annos[RecordConfigsAnnotation]
    92  	return ok
    93  }
    94  
    95  func filter() predicate.Predicate {
    96  	return predicate.Funcs{
    97  		UpdateFunc: func(e event.UpdateEvent) bool {
    98  			a := e.ObjectNew.GetAnnotations()
    99  			return hasRequiredAnnotations(a) || hasStructuredAnnotation(a)
   100  		},
   101  		CreateFunc: func(e event.CreateEvent) bool {
   102  			a := e.Object.GetAnnotations()
   103  			return hasRequiredAnnotations(a) || hasStructuredAnnotation(a)
   104  		},
   105  		DeleteFunc: func(_ event.DeleteEvent) bool {
   106  			return false
   107  		},
   108  	}
   109  }
   110  
   111  // SetupWithManager sets up ComputeAddressReconciler with the manager
   112  func (r *ComputeAddressReconciler) SetupWithManager(mgr ctrl.Manager) error {
   113  	return ctrl.NewControllerManagedBy(mgr).
   114  		For(&compute.ComputeAddress{}).
   115  		WithEventFilter(filter()).
   116  		Complete(r)
   117  }
   118  
   119  func (r *ComputeAddressReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
   120  	r.setResourceManager()
   121  	log := r.Log.WithValues("computeaddress", req.NamespacedName)
   122  
   123  	addy := &compute.ComputeAddress{}
   124  	if err := r.Client.Get(ctx, req.NamespacedName, addy); err != nil {
   125  		log.Error(err, "failed to get computeaddress")
   126  		return ctrl.Result{}, err
   127  	}
   128  
   129  	if ready, reason := meta.IsReady(addy.Status.Conditions); !ready {
   130  		log.Info("waiting for computeaddress to become ready", "reason", reason)
   131  		return ctrl.Result{Requeue: true, RequeueAfter: 5 * time.Second}, nil
   132  	}
   133  
   134  	cfgs, err := parseRecordConfigsAnnotation(addy.ObjectMeta)
   135  	if err != nil {
   136  		log.Error(err, fmt.Sprintf("failed to parse the %s annotation", RecordConfigsAnnotation))
   137  		return ctrl.Result{}, err
   138  	}
   139  
   140  	if cfg := getRecordConfigFromSingularAnnotations(addy.ObjectMeta); cfg != nil {
   141  		cfgs = append(cfgs, cfg)
   142  	}
   143  
   144  	if err := validateRecordConfigs(cfgs); err != nil {
   145  		return ctrl.Result{}, fmt.Errorf("invalid dns configuration: %w", err)
   146  	}
   147  
   148  	uobjs := []*unstructured.Unstructured{}
   149  	for _, recordCfg := range cfgs {
   150  		recordName := addy.ObjectMeta.Name + recordCfg.genSuffix
   151  		dns := buildDNSRecordSet(*recordCfg, recordName, req.Namespace, addy)
   152  		uobj, err := unstructuredutil.ToUnstructured(dns)
   153  		if err != nil {
   154  			return ctrl.Result{}, fmt.Errorf("failed to convert dnsrecordset/%s/%s to unstructured: %w", uobj.GetNamespace(), uobj.GetName(), err)
   155  		}
   156  		uobjs = append(uobjs, uobj)
   157  	}
   158  	changeset, err := r.ResourceManager.ApplyAll(ctx, uobjs, ssa.ApplyOptions{Force: true})
   159  	if err != nil {
   160  		return ctrl.Result{}, fmt.Errorf("failed to apply dnsrecordset(s): %w", err)
   161  	}
   162  	log.Info("applied DNSRecordSets", "changeset", changeset.ToMap())
   163  	return ctrl.Result{}, nil
   164  }
   165  
   166  func parseRecordConfigsAnnotation(meta metav1.ObjectMeta) ([]*RecordConfig, error) {
   167  	cfgs := []*RecordConfig{}
   168  	if metav1.HasAnnotation(meta, RecordConfigsAnnotation) {
   169  		if err := json.Unmarshal([]byte(meta.Annotations[RecordConfigsAnnotation]), &cfgs); err != nil {
   170  			return nil, err
   171  		}
   172  	}
   173  	for i, cfg := range cfgs {
   174  		cfg.genSuffix = genSuffix(i)
   175  	}
   176  	return cfgs, nil
   177  }
   178  
   179  func getRecordConfigFromSingularAnnotations(meta metav1.ObjectMeta) *RecordConfig {
   180  	if hasRequiredAnnotations(meta.Annotations) {
   181  		return &RecordConfig{
   182  			Name:         meta.Annotations[NameAnnotation],
   183  			ManagedZone:  meta.Annotations[ManagedZoneAnnotation],
   184  			DNSProjectID: meta.Annotations[DNSProjectAnnotation],
   185  			External:     meta.Annotations[DNSExternalZoneAnnotation],
   186  		}
   187  	}
   188  	return nil
   189  }
   190  
   191  func buildDNSRecordSet(cfg RecordConfig, name, namespace string, addy *compute.ComputeAddress) *dns.DNSRecordSet {
   192  	owner := *metav1.NewControllerRef(addy, compute.ComputeAddressGVK)
   193  	dns := &dns.DNSRecordSet{
   194  		TypeMeta: metav1.TypeMeta{
   195  			Kind:       reflect.TypeOf(dns.DNSRecordSet{}).Name(),
   196  			APIVersion: dns.SchemeGroupVersion.String(),
   197  		},
   198  		ObjectMeta: metav1.ObjectMeta{
   199  			Name:      name,
   200  			Namespace: namespace,
   201  			// indicate that the computeaddress owns this dnsrecordset, so that if one
   202  			// is deleted, the other is automatically cleaned up
   203  			OwnerReferences: []metav1.OwnerReference{owner},
   204  			Annotations:     map[string]string{meta.ProjectAnnotation: cfg.DNSProjectID},
   205  		},
   206  		Spec: dns.DNSRecordSetSpec{
   207  			Name:           cfg.Name,
   208  			Rrdatas:        []string{*addy.Spec.Address},
   209  			Type:           "A",
   210  			Ttl:            &ttl,
   211  			ManagedZoneRef: v1alpha1.ResourceRef{},
   212  		},
   213  	}
   214  
   215  	// copy "cnrm.cloud.google.com/deletion-policy" from parent if present
   216  	if policy, ok := addy.ObjectMeta.Annotations[meta.DeletionPolicyAnnotation]; ok {
   217  		dns.ObjectMeta.Annotations[meta.DeletionPolicyAnnotation] = policy
   218  	}
   219  
   220  	zone := cfg.ManagedZone
   221  	zoneTokens := strings.Split(zone, "/")
   222  	external := cfg.External
   223  	if external == "true" {
   224  		dns.Spec.ManagedZoneRef.External = zoneTokens[0]
   225  	} else {
   226  		// no namespace was provided
   227  		if len(zoneTokens) == 1 {
   228  			dns.Spec.ManagedZoneRef.Name = zoneTokens[0]
   229  		} else {
   230  			dns.Spec.ManagedZoneRef.Namespace = zoneTokens[0]
   231  			dns.Spec.ManagedZoneRef.Name = zoneTokens[1]
   232  		}
   233  	}
   234  	return dns
   235  }
   236  
   237  func (r *ComputeAddressReconciler) setResourceManager() {
   238  	if r.ResourceManager == nil {
   239  		mgr := ssa.NewResourceManager(
   240  			r.Client,
   241  			polling.NewStatusPoller(r.Client, r.Client.RESTMapper(), polling.Options{}), ssa.Owner{Field: r.Name},
   242  		)
   243  		r.ResourceManager = mgr
   244  	}
   245  }
   246  
   247  func validateRecordConfigs(cfgs []*RecordConfig) error {
   248  	// uniqueness keyed by project_zone_name
   249  	uniqDomainsByProjectZone := map[string]bool{}
   250  	for _, cfg := range cfgs {
   251  		key := fmt.Sprintf("%s_%s_%s", cfg.DNSProjectID, cfg.ManagedZone, cfg.Name)
   252  		if !uniqDomainsByProjectZone[key] {
   253  			uniqDomainsByProjectZone[key] = true
   254  		} else {
   255  			return fmt.Errorf("duplicate domain %s. uniqueness key: %s", cfg.Name, key)
   256  		}
   257  
   258  		errStrFmt := "missing required field %s on object in %s"
   259  		if cfg.DNSProjectID == "" {
   260  			return fmt.Errorf(errStrFmt, DNSProjectAnnotation, RecordConfigsAnnotation)
   261  		}
   262  		if cfg.ManagedZone == "" {
   263  			return fmt.Errorf(errStrFmt, ManagedZoneAnnotation, RecordConfigsAnnotation)
   264  		}
   265  		if cfg.Name == "" {
   266  			return fmt.Errorf(errStrFmt, NameAnnotation, RecordConfigsAnnotation)
   267  		}
   268  	}
   269  
   270  	return nil
   271  }
   272  
   273  func genSuffix(num int) string {
   274  	return fmt.Sprintf("-c%d", num)
   275  }
   276  

View as plain text