package dennis import ( "context" "encoding/json" "fmt" "reflect" "strings" "time" compute "github.com/GoogleCloudPlatform/k8s-config-connector/pkg/clients/generated/apis/compute/v1beta1" dns "github.com/GoogleCloudPlatform/k8s-config-connector/pkg/clients/generated/apis/dns/v1beta1" "github.com/GoogleCloudPlatform/k8s-config-connector/pkg/clients/generated/apis/k8s/v1alpha1" "github.com/fluxcd/pkg/ssa" "github.com/go-logr/logr" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "sigs.k8s.io/cli-utils/pkg/kstatus/polling" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/event" "sigs.k8s.io/controller-runtime/pkg/predicate" "edge-infra.dev/pkg/edge/constants" "edge-infra.dev/pkg/k8s/konfigkonnector/apis/meta" unstructuredutil "edge-infra.dev/pkg/k8s/unstructured" ) // +kubebuilder:rbac:groups="compute.cnrm.cloud.google.com",resources=computeaddresses,verbs=get;list;update;patch;watch // +kubebuilder:rbac:groups="compute.cnrm.cloud.google.com",resources=computeaddresses/status,verbs=get // +kubebuilder:rbac:groups="dns.cnrm.cloud.google.com",resources=dnsrecordsets,verbs=create;get;list;update;patch;watch // +kubebuilder:rbac:groups="dns.cnrm.cloud.google.com",resources=dnsrecordsets/status,verbs=get var ( domain = "dns." + constants.Domain // NameAnnotation is used to provide the domain name for the DNS record NameAnnotation = domain + "/name" // ManagedZoneAnnotation is used to provide a reference to the DNSManagedZone // to use for this DNSRecordSet. It can be provided in the form of `namespace/name`, // or simply just `name` (if the DNSManagedZone is in the same namespace as the // ComputeAddress) ManagedZoneAnnotation = domain + "/managed-zone" // DNSProjectAnnotation controls the GCP project that the DNS record will be // created in. It must be the same project as the Cloud DNS Zone DNSProjectAnnotation = domain + "/dns-project-id" // DNSExternalZoneAnnotation controls whether the zone is referenced via k8s object // or the GCP resource directly DNSExternalZoneAnnotation = domain + "/external" // RecordConfigsAnnotation is a JSON string containing structured configuration. // Supports the same options as individual annotations but allows for outputting // multiple DNSRecordSets for a single ComputeAddress. Must parse as []RecordConfig RecordConfigsAnnotation = domain + "/record-configs" // time-to-live ttl = 300 ) // ComputeAddressReconciler reconciles ComputeAddress objects to in order to // create corresponding DNSRecordSet objects based on annotations. // // Without this controller, in order to create a DNSRecordSet from a ComputeAddress, // you have to apply the ComputeAddress and read the IP from the Status. Using // these annotations allows configuring that relationship statically. type ComputeAddressReconciler struct { client.Client Log logr.Logger ResourceManager *ssa.ResourceManager Name string } // RecordConfig is a structured representation of a single desired output. To be parsed from // the dns.edge.ncr.com/record-configs annotation type RecordConfig struct { Name string `json:"name"` ManagedZone string `json:"managed-zone"` DNSProjectID string `json:"dns-project-id"` External string `json:"external"` genSuffix string } // only process ComputeAddresses with a minimum set of annotations; // we cant create a DNSRecordSet without this information func hasRequiredAnnotations(annos map[string]string) bool { _, name := annos[NameAnnotation] _, zone := annos[ManagedZoneAnnotation] _, dns := annos[DNSProjectAnnotation] return name && zone && dns } func hasStructuredAnnotation(annos map[string]string) bool { _, ok := annos[RecordConfigsAnnotation] return ok } func filter() predicate.Predicate { return predicate.Funcs{ UpdateFunc: func(e event.UpdateEvent) bool { a := e.ObjectNew.GetAnnotations() return hasRequiredAnnotations(a) || hasStructuredAnnotation(a) }, CreateFunc: func(e event.CreateEvent) bool { a := e.Object.GetAnnotations() return hasRequiredAnnotations(a) || hasStructuredAnnotation(a) }, DeleteFunc: func(_ event.DeleteEvent) bool { return false }, } } // SetupWithManager sets up ComputeAddressReconciler with the manager func (r *ComputeAddressReconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). For(&compute.ComputeAddress{}). WithEventFilter(filter()). Complete(r) } func (r *ComputeAddressReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { r.setResourceManager() log := r.Log.WithValues("computeaddress", req.NamespacedName) addy := &compute.ComputeAddress{} if err := r.Client.Get(ctx, req.NamespacedName, addy); err != nil { log.Error(err, "failed to get computeaddress") return ctrl.Result{}, err } if ready, reason := meta.IsReady(addy.Status.Conditions); !ready { log.Info("waiting for computeaddress to become ready", "reason", reason) return ctrl.Result{Requeue: true, RequeueAfter: 5 * time.Second}, nil } cfgs, err := parseRecordConfigsAnnotation(addy.ObjectMeta) if err != nil { log.Error(err, fmt.Sprintf("failed to parse the %s annotation", RecordConfigsAnnotation)) return ctrl.Result{}, err } if cfg := getRecordConfigFromSingularAnnotations(addy.ObjectMeta); cfg != nil { cfgs = append(cfgs, cfg) } if err := validateRecordConfigs(cfgs); err != nil { return ctrl.Result{}, fmt.Errorf("invalid dns configuration: %w", err) } uobjs := []*unstructured.Unstructured{} for _, recordCfg := range cfgs { recordName := addy.ObjectMeta.Name + recordCfg.genSuffix dns := buildDNSRecordSet(*recordCfg, recordName, req.Namespace, addy) uobj, err := unstructuredutil.ToUnstructured(dns) if err != nil { return ctrl.Result{}, fmt.Errorf("failed to convert dnsrecordset/%s/%s to unstructured: %w", uobj.GetNamespace(), uobj.GetName(), err) } uobjs = append(uobjs, uobj) } changeset, err := r.ResourceManager.ApplyAll(ctx, uobjs, ssa.ApplyOptions{Force: true}) if err != nil { return ctrl.Result{}, fmt.Errorf("failed to apply dnsrecordset(s): %w", err) } log.Info("applied DNSRecordSets", "changeset", changeset.ToMap()) return ctrl.Result{}, nil } func parseRecordConfigsAnnotation(meta metav1.ObjectMeta) ([]*RecordConfig, error) { cfgs := []*RecordConfig{} if metav1.HasAnnotation(meta, RecordConfigsAnnotation) { if err := json.Unmarshal([]byte(meta.Annotations[RecordConfigsAnnotation]), &cfgs); err != nil { return nil, err } } for i, cfg := range cfgs { cfg.genSuffix = genSuffix(i) } return cfgs, nil } func getRecordConfigFromSingularAnnotations(meta metav1.ObjectMeta) *RecordConfig { if hasRequiredAnnotations(meta.Annotations) { return &RecordConfig{ Name: meta.Annotations[NameAnnotation], ManagedZone: meta.Annotations[ManagedZoneAnnotation], DNSProjectID: meta.Annotations[DNSProjectAnnotation], External: meta.Annotations[DNSExternalZoneAnnotation], } } return nil } func buildDNSRecordSet(cfg RecordConfig, name, namespace string, addy *compute.ComputeAddress) *dns.DNSRecordSet { owner := *metav1.NewControllerRef(addy, compute.ComputeAddressGVK) dns := &dns.DNSRecordSet{ TypeMeta: metav1.TypeMeta{ Kind: reflect.TypeOf(dns.DNSRecordSet{}).Name(), APIVersion: dns.SchemeGroupVersion.String(), }, ObjectMeta: metav1.ObjectMeta{ Name: name, Namespace: namespace, // indicate that the computeaddress owns this dnsrecordset, so that if one // is deleted, the other is automatically cleaned up OwnerReferences: []metav1.OwnerReference{owner}, Annotations: map[string]string{meta.ProjectAnnotation: cfg.DNSProjectID}, }, Spec: dns.DNSRecordSetSpec{ Name: cfg.Name, Rrdatas: []string{*addy.Spec.Address}, Type: "A", Ttl: &ttl, ManagedZoneRef: v1alpha1.ResourceRef{}, }, } // copy "cnrm.cloud.google.com/deletion-policy" from parent if present if policy, ok := addy.ObjectMeta.Annotations[meta.DeletionPolicyAnnotation]; ok { dns.ObjectMeta.Annotations[meta.DeletionPolicyAnnotation] = policy } zone := cfg.ManagedZone zoneTokens := strings.Split(zone, "/") external := cfg.External if external == "true" { dns.Spec.ManagedZoneRef.External = zoneTokens[0] } else { // no namespace was provided if len(zoneTokens) == 1 { dns.Spec.ManagedZoneRef.Name = zoneTokens[0] } else { dns.Spec.ManagedZoneRef.Namespace = zoneTokens[0] dns.Spec.ManagedZoneRef.Name = zoneTokens[1] } } return dns } func (r *ComputeAddressReconciler) setResourceManager() { if r.ResourceManager == nil { mgr := ssa.NewResourceManager( r.Client, polling.NewStatusPoller(r.Client, r.Client.RESTMapper(), polling.Options{}), ssa.Owner{Field: r.Name}, ) r.ResourceManager = mgr } } func validateRecordConfigs(cfgs []*RecordConfig) error { // uniqueness keyed by project_zone_name uniqDomainsByProjectZone := map[string]bool{} for _, cfg := range cfgs { key := fmt.Sprintf("%s_%s_%s", cfg.DNSProjectID, cfg.ManagedZone, cfg.Name) if !uniqDomainsByProjectZone[key] { uniqDomainsByProjectZone[key] = true } else { return fmt.Errorf("duplicate domain %s. uniqueness key: %s", cfg.Name, key) } errStrFmt := "missing required field %s on object in %s" if cfg.DNSProjectID == "" { return fmt.Errorf(errStrFmt, DNSProjectAnnotation, RecordConfigsAnnotation) } if cfg.ManagedZone == "" { return fmt.Errorf(errStrFmt, ManagedZoneAnnotation, RecordConfigsAnnotation) } if cfg.Name == "" { return fmt.Errorf(errStrFmt, NameAnnotation, RecordConfigsAnnotation) } } return nil } func genSuffix(num int) string { return fmt.Sprintf("-c%d", num) }