diff --git a/.gitignore b/.gitignore index 5959d984..3a59d1b3 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ *.dll *.so *.dylib +*.env bin/* Dockerfile.cross @@ -29,3 +30,4 @@ Dockerfile.cross tmp config/local-setup/**/*.env +local \ No newline at end of file diff --git a/api/v1alpha1/dnsrecord_types.go b/api/v1alpha1/dnsrecord_types.go index f36a88d5..76b2d142 100644 --- a/api/v1alpha1/dnsrecord_types.go +++ b/api/v1alpha1/dnsrecord_types.go @@ -26,6 +26,11 @@ import ( // DNSRecordSpec defines the desired state of DNSRecord type DNSRecordSpec struct { + + // rootHost is the single root for all endpoints in a DNSRecord. + //If rootHost is set, it is expected all defined endpoints are children of or equal to this rootHost + // +optional + RootHost *string `json:"rootHost,omitempty"` // +kubebuilder:validation:Required // +required ManagedZoneRef *ManagedZoneReference `json:"managedZone,omitempty"` @@ -101,31 +106,37 @@ const ( DefaultGeo string = "default" ) -// GetRootDomain returns the shortest domain that is shared across all spec.Endpoints dns names. -// Validates that all endpoints share an equal root domain and returns an error if they don't. -func (s *DNSRecord) GetRootDomain() (string, error) { - domain := "" - dnsNames := []string{} - for idx := range s.Spec.Endpoints { - dnsNames = append(dnsNames, s.Spec.Endpoints[idx].DNSName) - } - for idx := range dnsNames { - if domain == "" || len(domain) > len(dnsNames[idx]) { - domain = dnsNames[idx] - } - } +const WildcardPrefix = "*." - if domain == "" { - return "", fmt.Errorf("unable to determine root domain from %v", dnsNames) - } +func (s *DNSRecord) Validate() error { + if s.Spec.RootHost != nil { + root := *s.Spec.RootHost + if len(strings.Split(root, ".")) <= 1 { + return fmt.Errorf("invalid domain format no tld discovered") + } + if len(s.Spec.Endpoints) == 0 { + return fmt.Errorf("no endpoints defined for DNSRecord. Nothing to do.") + } - for idx := range dnsNames { - if !strings.HasSuffix(dnsNames[idx], domain) { - return "", fmt.Errorf("inconsitent domains, got %s, expected suffix %s", dnsNames[idx], domain) + root, _ = strings.CutPrefix(root, WildcardPrefix) + + rootEndpointFound := false + for _, ep := range s.Spec.Endpoints { + if !strings.HasSuffix(ep.DNSName, root) { + return fmt.Errorf("invalid endpoint discovered %s all endpoints should be equal to or end with the rootHost %s", ep.DNSName, root) + } + if !rootEndpointFound { + //check original root + if ep.DNSName == *s.Spec.RootHost { + rootEndpointFound = true + } + } + } + if !rootEndpointFound { + return fmt.Errorf("invalid endpoint set. rootHost is set but found no endpoint defining a record for the rootHost %s", root) } } - - return domain, nil + return nil } func init() { diff --git a/api/v1alpha1/dnsrecord_types_test.go b/api/v1alpha1/dnsrecord_types_test.go index 4d0cacd1..bc403b33 100644 --- a/api/v1alpha1/dnsrecord_types_test.go +++ b/api/v1alpha1/dnsrecord_types_test.go @@ -3,79 +3,87 @@ package v1alpha1 import ( "testing" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/external-dns/endpoint" ) -func TestDNSRecord_GetRootDomain(t *testing.T) { +func TestValidate(t *testing.T) { tests := []struct { name string + rootHost string dnsNames []string - want string wantErr bool }{ { - name: "single endpoint", + name: "invalid domain", + rootHost: "example", + wantErr: true, + }, + { + name: "no endpoints", + rootHost: "example.com", + wantErr: true, + }, + { + name: "invalid domain", + rootHost: "example.com", dnsNames: []string{ - "test.example.com", + "example.com", + "a.exmple.com", }, - want: "test.example.com", - wantErr: false, + wantErr: true, }, { - name: "multiple endpoints matching", + name: "valid domain", + rootHost: "example.com", dnsNames: []string{ - "bar.baz.test.example.com", - "bar.test.example.com", - "test.example.com", - "foo.bar.baz.test.example.com", + "example.com", + "a.b.example.com", + "b.a.example.com", + "a.example.com", + "b.example.com", }, - want: "test.example.com", wantErr: false, }, { - name: "no endpoints", - dnsNames: []string{}, - want: "", - wantErr: true, + name: "valid wildcard domain", + rootHost: "*.example.com", + dnsNames: []string{ + "*.example.com", + "a.b.example.com", + "b.a.example.com", + "a.example.com", + "b.example.com", + }, + wantErr: false, }, { - name: "multiple endpoints mismatching", + name: "valid wildcard domain no endpoint", + rootHost: "*.example.com", dnsNames: []string{ - "foo.bar.test.example.com", - "bar.test.example.com", - "baz.example.com", + "a.b.example.com", + "b.a.example.com", + "a.example.com", + "b.example.com", }, - want: "", wantErr: true, }, } + for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - s := &DNSRecord{ - TypeMeta: metav1.TypeMeta{ - Kind: "DNSRecord", - APIVersion: GroupVersion.String(), - }, - ObjectMeta: metav1.ObjectMeta{ - Name: "testRecord", - Namespace: "testNS", - }, + record := &DNSRecord{ Spec: DNSRecordSpec{ - Endpoints: []*endpoint.Endpoint{}, + RootHost: &tt.rootHost, }, } for idx := range tt.dnsNames { - s.Spec.Endpoints = append(s.Spec.Endpoints, &endpoint.Endpoint{DNSName: tt.dnsNames[idx]}) + record.Spec.Endpoints = append(record.Spec.Endpoints, &endpoint.Endpoint{DNSName: tt.dnsNames[idx]}) } - got, err := s.GetRootDomain() + err := record.Validate() if (err != nil) != tt.wantErr { - t.Errorf("GetRootDomain() error = %v, wantErr %v", err, tt.wantErr) + t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr) return } - if got != tt.want { - t.Errorf("GetRootDomain() got = %v, want %v", got, tt.want) - } }) } } diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 298cd8e3..82cba831 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -89,6 +89,11 @@ func (in *DNSRecordList) DeepCopyObject() runtime.Object { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *DNSRecordSpec) DeepCopyInto(out *DNSRecordSpec) { *out = *in + if in.RootHost != nil { + in, out := &in.RootHost, &out.RootHost + *out = new(string) + **out = **in + } if in.ManagedZoneRef != nil { in, out := &in.ManagedZoneRef, &out.ManagedZoneRef *out = new(ManagedZoneReference) diff --git a/bundle/manifests/dns-operator.clusterserviceversion.yaml b/bundle/manifests/dns-operator.clusterserviceversion.yaml index cde091a2..fa685547 100644 --- a/bundle/manifests/dns-operator.clusterserviceversion.yaml +++ b/bundle/manifests/dns-operator.clusterserviceversion.yaml @@ -56,7 +56,7 @@ metadata: capabilities: Basic Install categories: Integration & Delivery containerImage: quay.io/kuadrant/dns-operator:latest - createdAt: "2024-03-11T11:01:25Z" + createdAt: "2024-03-11T14:53:47Z" description: A Kubernetes Operator to manage the lifecycle of DNS resources operators.operatorframework.io/builder: operator-sdk-v1.33.0 operators.operatorframework.io/project_layout: go.kubebuilder.io/v4 diff --git a/bundle/manifests/kuadrant.io_dnsrecords.yaml b/bundle/manifests/kuadrant.io_dnsrecords.yaml index eed45a53..658f1f9c 100644 --- a/bundle/manifests/kuadrant.io_dnsrecords.yaml +++ b/bundle/manifests/kuadrant.io_dnsrecords.yaml @@ -94,6 +94,11 @@ spec: required: - name type: object + rootHost: + description: "rootHost is the single root for all endpoints in a DNSRecord. + If rootHost is set, it is expected all defined endpoints are children + \tof or equal to this rootHost" + type: string type: object status: description: DNSRecordStatus defines the observed state of DNSRecord diff --git a/config/crd/bases/kuadrant.io_dnsrecords.yaml b/config/crd/bases/kuadrant.io_dnsrecords.yaml index ac503169..f9cd7e03 100644 --- a/config/crd/bases/kuadrant.io_dnsrecords.yaml +++ b/config/crd/bases/kuadrant.io_dnsrecords.yaml @@ -94,6 +94,11 @@ spec: required: - name type: object + rootHost: + description: "rootHost is the single root for all endpoints in a DNSRecord. + If rootHost is set, it is expected all defined endpoints are children + \tof or equal to this rootHost" + type: string type: object status: description: DNSRecordStatus defines the observed state of DNSRecord diff --git a/internal/controller/dnsrecord_controller.go b/internal/controller/dnsrecord_controller.go index 3a6fe412..89359e31 100644 --- a/internal/controller/dnsrecord_controller.go +++ b/internal/controller/dnsrecord_controller.go @@ -31,6 +31,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/reconcile" externaldnsendpoint "sigs.k8s.io/external-dns/endpoint" externaldnsplan "sigs.k8s.io/external-dns/plan" externaldnsprovider "sigs.k8s.io/external-dns/provider" @@ -99,31 +100,39 @@ func (r *DNSRecordReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( status := metav1.ConditionTrue reason = "ProviderSuccess" message = "Provider ensured the dns record" - + err = dnsRecord.Validate() + if err != nil { + status = metav1.ConditionFalse + reason = "ValidationError" + message = fmt.Sprintf("validation of DNSRecord failed: %v", err) + setDNSRecordCondition(dnsRecord, string(conditions.ConditionTypeReady), status, reason, message) + return r.updateStatus(ctx, previous, dnsRecord) + } // Publish the record err = r.publishRecord(ctx, dnsRecord) if err != nil { status = metav1.ConditionFalse reason = "ProviderError" message = fmt.Sprintf("The DNS provider failed to ensure the record: %v", provider.SanitizeError(err)) - } else { - dnsRecord.Status.ObservedGeneration = dnsRecord.Generation - dnsRecord.Status.Endpoints = dnsRecord.Spec.Endpoints + setDNSRecordCondition(dnsRecord, string(conditions.ConditionTypeReady), status, reason, message) + return r.updateStatus(ctx, previous, dnsRecord) } + // success setDNSRecordCondition(dnsRecord, string(conditions.ConditionTypeReady), status, reason, message) + dnsRecord.Status.ObservedGeneration = dnsRecord.Generation + dnsRecord.Status.Endpoints = dnsRecord.Spec.Endpoints + return r.updateStatus(ctx, previous, dnsRecord) +} - if !equality.Semantic.DeepEqual(previous.Status, dnsRecord.Status) { - updateErr := r.Status().Update(ctx, dnsRecord) - if updateErr != nil { - // Ignore conflicts, resource might just be outdated. - if apierrors.IsConflict(updateErr) { - return ctrl.Result{Requeue: true}, nil - } - return ctrl.Result{}, updateErr +func (r *DNSRecordReconciler) updateStatus(ctx context.Context, previous, current *v1alpha1.DNSRecord) (reconcile.Result, error) { + if !equality.Semantic.DeepEqual(previous.Status, current.Status) { + updateError := r.Status().Update(ctx, current) + if apierrors.IsConflict(updateError) { + return ctrl.Result{Requeue: true}, nil } + return ctrl.Result{}, updateError } - - return ctrl.Result{}, err + return ctrl.Result{}, nil } // SetupWithManager sets up the controller with the Manager. @@ -175,7 +184,6 @@ func (r *DNSRecordReconciler) deleteRecord(ctx context.Context, dnsRecord *v1alp // DNSRecord (dnsRecord.Status.ParentManagedZone). func (r *DNSRecordReconciler) publishRecord(ctx context.Context, dnsRecord *v1alpha1.DNSRecord) error { logger := log.FromContext(ctx) - managedZone := &v1alpha1.ManagedZone{ ObjectMeta: metav1.ObjectMeta{ Name: dnsRecord.Spec.ManagedZoneRef.Name, @@ -220,22 +228,18 @@ func setDNSRecordCondition(dnsRecord *v1alpha1.DNSRecord, conditionType string, func (r *DNSRecordReconciler) applyChanges(ctx context.Context, dnsRecord *v1alpha1.DNSRecord, managedZone *v1alpha1.ManagedZone, isDelete bool) error { logger := log.FromContext(ctx) - - rootDomain, err := dnsRecord.GetRootDomain() - if err != nil { - return err - } - if !strings.HasSuffix(rootDomain, managedZone.Spec.DomainName) { - return fmt.Errorf("inconsitent domains, does not match managedzone, got %s, expected suffix %s", rootDomain, managedZone.Spec.DomainName) + filterDomain, _ := strings.CutPrefix(managedZone.Spec.DomainName, v1alpha1.WildcardPrefix) + if dnsRecord.Spec.RootHost != nil { + filterDomain = *dnsRecord.Spec.RootHost } - rootDomainFilter := externaldnsendpoint.NewDomainFilter([]string{rootDomain}) + rootDomainFilter := externaldnsendpoint.NewDomainFilter([]string{filterDomain}) providerConfig := provider.Config{ DomainFilter: externaldnsendpoint.NewDomainFilter([]string{managedZone.Spec.DomainName}), ZoneTypeFilter: externaldnsprovider.NewZoneTypeFilter(""), ZoneIDFilter: externaldnsprovider.NewZoneIDFilter([]string{managedZone.Status.ID}), } - logger.V(3).Info("applyChanges", "rootDomain", rootDomain, "rootDomainFilter", rootDomainFilter, "providerConfig", providerConfig) + logger.V(3).Info("applyChanges", "zone", managedZone.Spec.DomainName, "rootDomainFilter", rootDomainFilter, "providerConfig", providerConfig) dnsProvider, err := r.ProviderFactory.ProviderFor(ctx, managedZone, providerConfig) if err != nil { return err