From 84e2c1b6df9a91cbd8bfbce159f4701510b8f265 Mon Sep 17 00:00:00 2001 From: Phil Brookes Date: Wed, 3 Apr 2024 11:11:54 +0200 Subject: [PATCH] aws health checks --- api/v1alpha1/dnsrecord_types.go | 10 +- api/v1alpha1/zz_generated.deepcopy.go | 20 ++ .../dns-operator.clusterserviceversion.yaml | 2 +- bundle/manifests/kuadrant.io_dnsrecords.yaml | 17 ++ config/crd/bases/kuadrant.io_dnsrecords.yaml | 17 ++ go.mod | 1 + go.sum | 2 + internal/controller/dnsrecord_controller.go | 38 ++- internal/controller/dnsrecord_healthchecks.go | 183 +++++++++++++ internal/provider/aws/aws.go | 24 +- internal/provider/aws/health.go | 240 ++++++++++++++++++ internal/provider/cached.go | 68 +++++ internal/provider/endpointUtils.go | 30 +++ internal/provider/fake.go | 21 ++ internal/provider/fake/provider.go | 9 + internal/provider/google/google.go | 8 + internal/provider/google/health.go | 27 ++ internal/provider/health.go | 58 +++++ internal/provider/provider.go | 5 + 19 files changed, 767 insertions(+), 13 deletions(-) create mode 100644 internal/controller/dnsrecord_healthchecks.go create mode 100644 internal/provider/aws/health.go create mode 100644 internal/provider/cached.go create mode 100644 internal/provider/endpointUtils.go create mode 100644 internal/provider/fake.go create mode 100644 internal/provider/google/health.go create mode 100644 internal/provider/health.go diff --git a/api/v1alpha1/dnsrecord_types.go b/api/v1alpha1/dnsrecord_types.go index e57109d1..14787038 100644 --- a/api/v1alpha1/dnsrecord_types.go +++ b/api/v1alpha1/dnsrecord_types.go @@ -45,7 +45,15 @@ type HealthCheckSpec struct { } type HealthCheckStatus struct { - Conditions []metav1.Condition `json:"conditions,omitempty"` + Conditions []metav1.Condition `json:"conditions,omitempty"` + Probes []HealthCheckStatusProbe `json:"probes,omitempty"` +} + +type HealthCheckStatusProbe struct { + ID string `json:"id"` + IPAddress string `json:"ipAddress"` + Host string `json:"host"` + Synced bool `json:"synced,omitempty"` } // DNSRecordSpec defines the desired state of DNSRecord diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 0c53b82e..6e64de1a 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -212,6 +212,11 @@ func (in *HealthCheckStatus) DeepCopyInto(out *HealthCheckStatus) { (*in)[i].DeepCopyInto(&(*out)[i]) } } + if in.Probes != nil { + in, out := &in.Probes, &out.Probes + *out = make([]HealthCheckStatusProbe, len(*in)) + copy(*out, *in) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HealthCheckStatus. @@ -224,6 +229,21 @@ func (in *HealthCheckStatus) DeepCopy() *HealthCheckStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *HealthCheckStatusProbe) DeepCopyInto(out *HealthCheckStatusProbe) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HealthCheckStatusProbe. +func (in *HealthCheckStatusProbe) DeepCopy() *HealthCheckStatusProbe { + if in == nil { + return nil + } + out := new(HealthCheckStatusProbe) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ManagedHost) DeepCopyInto(out *ManagedHost) { *out = *in diff --git a/bundle/manifests/dns-operator.clusterserviceversion.yaml b/bundle/manifests/dns-operator.clusterserviceversion.yaml index 6669ea02..bcbfc77f 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-04-04T08:27:01Z" + createdAt: "2024-04-17T03:35:09Z" 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 6cf5b6a7..75880028 100644 --- a/bundle/manifests/kuadrant.io_dnsrecords.yaml +++ b/bundle/manifests/kuadrant.io_dnsrecords.yaml @@ -320,6 +320,23 @@ spec: - type type: object type: array + probes: + items: + properties: + host: + type: string + id: + type: string + ipAddress: + type: string + synced: + type: boolean + required: + - host + - id + - ipAddress + type: object + type: array type: object observedGeneration: description: observedGeneration is the most recently observed generation diff --git a/config/crd/bases/kuadrant.io_dnsrecords.yaml b/config/crd/bases/kuadrant.io_dnsrecords.yaml index e186b2c1..af42fa85 100644 --- a/config/crd/bases/kuadrant.io_dnsrecords.yaml +++ b/config/crd/bases/kuadrant.io_dnsrecords.yaml @@ -320,6 +320,23 @@ spec: - type type: object type: array + probes: + items: + properties: + host: + type: string + id: + type: string + ipAddress: + type: string + synced: + type: boolean + required: + - host + - id + - ipAddress + type: object + type: array type: object observedGeneration: description: observedGeneration is the most recently observed generation diff --git a/go.mod b/go.mod index 366efa54..a4cc04e6 100644 --- a/go.mod +++ b/go.mod @@ -71,6 +71,7 @@ require ( github.com/prometheus/client_model v0.5.0 // indirect github.com/prometheus/common v0.45.0 // indirect github.com/prometheus/procfs v0.12.0 // indirect + github.com/rs/xid v1.5.0 // indirect github.com/spf13/pflag v1.0.5 // indirect go.opencensus.io v0.24.0 // indirect go.uber.org/multierr v1.11.0 // indirect diff --git a/go.sum b/go.sum index b8b48dc6..92b22494 100644 --- a/go.sum +++ b/go.sum @@ -680,6 +680,8 @@ github.com/rogpeppe/go-internal v1.3.2/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTE github.com/rogpeppe/go-internal v1.4.0/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/rs/xid v1.5.0 h1:mKX4bl4iPYJtEIxp6CYiUuLQ/8DYMoz0PUdtGgMFRVc= +github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= github.com/rubenv/sql-migrate v0.0.0-20200212082348-64f95ea68aa3/go.mod h1:rtQlpHw+eR6UrqaS3kX1VYeaCxzCVdimDS7g5Ln4pPc= github.com/rubenv/sql-migrate v0.0.0-20200616145509-8d140a17f351/go.mod h1:DCgfY80j8GYL7MLEfvcpSFvjD0L5yZq/aZUJmhZklyg= github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= diff --git a/internal/controller/dnsrecord_controller.go b/internal/controller/dnsrecord_controller.go index 725b10c3..9a1fd359 100644 --- a/internal/controller/dnsrecord_controller.go +++ b/internal/controller/dnsrecord_controller.go @@ -89,6 +89,9 @@ func (r *DNSRecordReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( dnsRecord := previous.DeepCopy() if dnsRecord.DeletionTimestamp != nil && !dnsRecord.DeletionTimestamp.IsZero() { + if err := r.ReconcileHealthChecks(ctx, dnsRecord); err != nil { + return ctrl.Result{}, err + } requeueTime, err := r.deleteRecord(ctx, dnsRecord) if err != nil { logger.Error(err, "Failed to delete DNSRecord") @@ -100,6 +103,7 @@ func (r *DNSRecordReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( if requeueTime == validationRequeueTime { return ctrl.Result{RequeueAfter: requeueTime}, nil } + logger.Info("Removing Finalizer", "name", DNSRecordFinalizer) controllerutil.RemoveFinalizer(dnsRecord, DNSRecordFinalizer) if err = r.Update(ctx, dnsRecord); client.IgnoreNotFound(err) != nil { @@ -142,6 +146,11 @@ func (r *DNSRecordReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( // success dnsRecord.Status.ObservedGeneration = dnsRecord.Generation dnsRecord.Status.Endpoints = dnsRecord.Spec.Endpoints + + if err := r.ReconcileHealthChecks(ctx, dnsRecord); err != nil { + return ctrl.Result{}, err + } + return r.updateStatus(ctx, previous, dnsRecord, requeueAfter) } @@ -266,6 +275,27 @@ func setDNSRecordCondition(dnsRecord *v1alpha1.DNSRecord, conditionType string, meta.SetStatusCondition(&dnsRecord.Status.Conditions, cond) } +func (r *DNSRecordReconciler) getDNSProvider(ctx context.Context, dnsRecord *v1alpha1.DNSRecord) (provider.Provider, error) { + managedZone := &v1alpha1.ManagedZone{ + ObjectMeta: metav1.ObjectMeta{ + Name: dnsRecord.Spec.ManagedZoneRef.Name, + Namespace: dnsRecord.Namespace, + }, + } + err := r.Get(ctx, client.ObjectKeyFromObject(managedZone), managedZone, &client.GetOptions{}) + if err != nil { + return nil, err + } + + providerConfig := provider.Config{ + DomainFilter: externaldnsendpoint.NewDomainFilter([]string{managedZone.Spec.DomainName}), + ZoneTypeFilter: externaldnsprovider.NewZoneTypeFilter(""), + ZoneIDFilter: externaldnsprovider.NewZoneIDFilter([]string{managedZone.Status.ID}), + } + + return r.ProviderFactory.ProviderFor(ctx, managedZone, providerConfig) +} + func (r *DNSRecordReconciler) applyChanges(ctx context.Context, dnsRecord *v1alpha1.DNSRecord, managedZone *v1alpha1.ManagedZone, isDelete bool) (time.Duration, error) { logger := log.FromContext(ctx) filterDomain, _ := strings.CutPrefix(managedZone.Spec.DomainName, v1alpha1.WildcardPrefix) @@ -274,13 +304,7 @@ func (r *DNSRecordReconciler) applyChanges(ctx context.Context, dnsRecord *v1alp } 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", "zone", managedZone.Spec.DomainName, "rootDomainFilter", rootDomainFilter, "providerConfig", providerConfig) - dnsProvider, err := r.ProviderFactory.ProviderFor(ctx, managedZone, providerConfig) + dnsProvider, err := r.getDNSProvider(ctx, dnsRecord) if err != nil { return noRequeueDuration, err } diff --git a/internal/controller/dnsrecord_healthchecks.go b/internal/controller/dnsrecord_healthchecks.go new file mode 100644 index 00000000..ed214679 --- /dev/null +++ b/internal/controller/dnsrecord_healthchecks.go @@ -0,0 +1,183 @@ +package controller + +import ( + "context" + "crypto/md5" + "fmt" + "io" + "reflect" + + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + externaldns "sigs.k8s.io/external-dns/endpoint" + + "github.com/kuadrant/dns-operator/api/v1alpha1" + "github.com/kuadrant/dns-operator/internal/provider" +) + +// healthChecksConfig represents the user configuration for the health checks +type healthChecksConfig struct { + Endpoint string + Port *int64 + FailureThreshold *int64 + Protocol *provider.HealthCheckProtocol +} + +func (r *DNSRecordReconciler) ReconcileHealthChecks(ctx context.Context, dnsRecord *v1alpha1.DNSRecord) error { + var results []provider.HealthCheckResult + var err error + + dnsProvider, err := r.getDNSProvider(ctx, dnsRecord) + if err != nil { + return err + } + + healthCheckReconciler := dnsProvider.HealthCheckReconciler() + + // Get the configuration for the health checks. If no configuration is + // set, ensure that the health checks are deleted + config := getHealthChecksConfig(dnsRecord) + + for _, dnsEndpoint := range dnsRecord.Spec.Endpoints { + addresses := provider.GetExternalAddresses(dnsEndpoint, dnsRecord) + for _, address := range addresses { + probeStatus := r.getProbeStatus(address, dnsRecord) + + // no config means delete the health checks + if config == nil { + result, err := healthCheckReconciler.Delete(ctx, dnsEndpoint, probeStatus) + if err != nil { + return err + } + + results = append(results, result) + continue + } + + // creating / updating health checks + endpointId, err := idForEndpoint(dnsRecord, dnsEndpoint, address) + if err != nil { + return err + } + + spec := provider.HealthCheckSpec{ + Id: endpointId, + Name: fmt.Sprintf("%s-%s-%s", *dnsRecord.Spec.RootHost, dnsEndpoint.DNSName, address), + Host: dnsRecord.Spec.RootHost, + Path: config.Endpoint, + Port: config.Port, + Protocol: config.Protocol, + FailureThreshold: config.FailureThreshold, + } + + result, err := healthCheckReconciler.Reconcile(ctx, spec, dnsEndpoint, probeStatus, address) + if err != nil { + return err + } + results = append(results, result) + } + } + + result := r.reconcileHealthCheckStatus(results, dnsRecord) + return result +} + +func (r *DNSRecordReconciler) getProbeStatus(address string, dnsRecord *v1alpha1.DNSRecord) *v1alpha1.HealthCheckStatusProbe { + if dnsRecord.Status.HealthCheck == nil || dnsRecord.Status.HealthCheck.Probes == nil { + return nil + } + for _, probeStatus := range dnsRecord.Status.HealthCheck.Probes { + if probeStatus.IPAddress == address { + return &probeStatus + } + } + + return nil +} + +func (r *DNSRecordReconciler) reconcileHealthCheckStatus(results []provider.HealthCheckResult, dnsRecord *v1alpha1.DNSRecord) error { + var previousCondition *metav1.Condition + probesCondition := &metav1.Condition{ + Reason: "AllProbesSynced", + Type: "healthProbesSynced", + } + + var allSynced = metav1.ConditionTrue + + if dnsRecord.Status.HealthCheck == nil { + dnsRecord.Status.HealthCheck = &v1alpha1.HealthCheckStatus{ + Conditions: []metav1.Condition{}, + Probes: []v1alpha1.HealthCheckStatusProbe{}, + } + } + + previousCondition = meta.FindStatusCondition(dnsRecord.Status.HealthCheck.Conditions, "HealthProbesSynced") + if previousCondition != nil { + probesCondition = previousCondition + } + + dnsRecord.Status.HealthCheck.Probes = []v1alpha1.HealthCheckStatusProbe{} + + for _, result := range results { + if result.ID == "" { + continue + } + status := true + if result.Result == provider.HealthCheckFailed { + status = false + allSynced = metav1.ConditionFalse + } + + dnsRecord.Status.HealthCheck.Probes = append(dnsRecord.Status.HealthCheck.Probes, v1alpha1.HealthCheckStatusProbe{ + ID: result.ID, + IPAddress: result.IPAddress, + Host: result.Host, + Synced: status, + }) + } + + probesCondition.ObservedGeneration = dnsRecord.Generation + probesCondition.Status = allSynced + + if allSynced == metav1.ConditionTrue { + probesCondition.Message = fmt.Sprintf("all %v probes synced successfully", len(dnsRecord.Status.HealthCheck.Probes)) + probesCondition.Reason = "AllProbesSynced" + } else { + probesCondition.Reason = "UnsyncedProbes" + probesCondition.Message = "some probes have not yet successfully synced to the DNS Provider" + } + + //probe condition changed? - update transition time + if !reflect.DeepEqual(previousCondition, probesCondition) { + probesCondition.LastTransitionTime = metav1.Now() + } + + dnsRecord.Status.HealthCheck.Conditions = []metav1.Condition{*probesCondition} + + return nil +} + +func getHealthChecksConfig(dnsRecord *v1alpha1.DNSRecord) *healthChecksConfig { + if dnsRecord.Spec.HealthCheck == nil || dnsRecord.DeletionTimestamp != nil { + return nil + } + + port := int64(*dnsRecord.Spec.HealthCheck.Port) + failureThreshold := int64(*dnsRecord.Spec.HealthCheck.FailureThreshold) + + return &healthChecksConfig{ + Endpoint: dnsRecord.Spec.HealthCheck.Endpoint, + Port: &port, + FailureThreshold: &failureThreshold, + Protocol: (*provider.HealthCheckProtocol)(dnsRecord.Spec.HealthCheck.Protocol), + } +} + +// idForEndpoint returns a unique identifier for an endpoint +func idForEndpoint(dnsRecord *v1alpha1.DNSRecord, endpoint *externaldns.Endpoint, address string) (string, error) { + hash := md5.New() + if _, err := io.WriteString(hash, fmt.Sprintf("%s/%s@%s:%s", dnsRecord.Name, endpoint.SetIdentifier, endpoint.DNSName, address)); err != nil { + return "", fmt.Errorf("unexpected error creating ID for endpoint %s", endpoint.SetIdentifier) + } + return fmt.Sprintf("%x", hash.Sum(nil)), nil +} diff --git a/internal/provider/aws/aws.go b/internal/provider/aws/aws.go index 957c8a96..c80329b1 100644 --- a/internal/provider/aws/aws.go +++ b/internal/provider/aws/aws.go @@ -38,6 +38,7 @@ import ( ) const ( + ProviderSpecificHealthCheckID = "aws/health-check-id" providerSpecificWeight = "aws/weight" providerSpecificGeolocationCountryCode = "aws/geolocation-country-code" providerSpecificGeolocationContinentCode = "aws/geolocation-continent-code" @@ -50,10 +51,11 @@ const ( type Route53DNSProvider struct { *externaldnsprovideraws.AWSProvider - awsConfig externaldnsprovideraws.AWSConfig - logger logr.Logger - route53Client *route53.Route53 - ctx context.Context + awsConfig externaldnsprovideraws.AWSConfig + logger logr.Logger + route53Client *route53.Route53 + ctx context.Context + healthCheckReconciler provider.HealthCheckReconciler } var _ provider.Provider = &Route53DNSProvider{} @@ -108,6 +110,13 @@ func NewProviderFromSecret(ctx context.Context, s *v1.Secret, c provider.Config) } // #### External DNS Provider #### +func (p *Route53DNSProvider) HealthCheckReconciler() provider.HealthCheckReconciler { + if p.healthCheckReconciler == nil { + p.healthCheckReconciler = NewRoute53HealthCheckReconciler(p.route53Client) + } + + return p.healthCheckReconciler +} func (p *Route53DNSProvider) AdjustEndpoints(endpoints []*externaldnsendpoint.Endpoint) ([]*externaldnsendpoint.Endpoint, error) { endpoints, err := p.AWSProvider.AdjustEndpoints(endpoints) @@ -231,6 +240,13 @@ func (p *Route53DNSProvider) DeleteManagedZone(zone *v1alpha1.ManagedZone) error return nil } +func (*Route53DNSProvider) ProviderSpecific() provider.ProviderSpecificLabels { + return provider.ProviderSpecificLabels{ + Weight: providerSpecificWeight, + HealthCheckID: ProviderSpecificHealthCheckID, + } +} + // Register this Provider with the provider factory func init() { provider.RegisterProvider("aws", NewProviderFromSecret) diff --git a/internal/provider/aws/health.go b/internal/provider/aws/health.go new file mode 100644 index 00000000..25ccaf25 --- /dev/null +++ b/internal/provider/aws/health.go @@ -0,0 +1,240 @@ +package aws + +import ( + "context" + "fmt" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/route53" + "github.com/aws/aws-sdk-go/service/route53/route53iface" + "github.com/rs/xid" + + externaldns "sigs.k8s.io/external-dns/endpoint" + + "github.com/kuadrant/dns-operator/api/v1alpha1" + "github.com/kuadrant/dns-operator/internal/provider" +) + +const ( + idTag = "kuadrant.dev/healthcheck" + + defaultHealthCheckPath = "/" + defaultHealthCheckPort = 80 + defaultHealthCheckFailureThreshold = 3 +) + +var ( + callerReference func(id string) *string +) + +type Route53HealthCheckReconciler struct { + client route53iface.Route53API +} + +var _ provider.HealthCheckReconciler = &Route53HealthCheckReconciler{} + +func NewRoute53HealthCheckReconciler(client route53iface.Route53API) *Route53HealthCheckReconciler { + return &Route53HealthCheckReconciler{ + client: client, + } +} + +func (r *Route53HealthCheckReconciler) Reconcile(ctx context.Context, spec provider.HealthCheckSpec, endpoint *externaldns.Endpoint, probeStatus *v1alpha1.HealthCheckStatusProbe, address string) (provider.HealthCheckResult, error) { + healthCheck, exists, err := r.findHealthCheck(ctx, probeStatus) + if err != nil { + return provider.HealthCheckResult{}, err + } + + if exists { + status, err := r.updateHealthCheck(ctx, spec, endpoint, healthCheck, address) + if err != nil { + return provider.HealthCheckResult{}, err + } + + return provider.NewHealthCheckResult(status, *healthCheck.Id, address, *spec.Host, ""), nil + } + + healthCheck, err = r.createHealthCheck(ctx, spec, address) + if err != nil { + return provider.HealthCheckResult{}, err + } + + return provider.NewHealthCheckResult(provider.HealthCheckCreated, *healthCheck.Id, address, *spec.Host, fmt.Sprintf("Created health check with ID %s", *healthCheck.Id)), nil +} + +func (r *Route53HealthCheckReconciler) Delete(ctx context.Context, _ *externaldns.Endpoint, probeStatus *v1alpha1.HealthCheckStatusProbe) (provider.HealthCheckResult, error) { + healthCheck, found, err := r.findHealthCheck(ctx, probeStatus) + if err != nil { + return provider.HealthCheckResult{}, err + } + if !found { + return provider.NewHealthCheckResult(provider.HealthCheckNoop, "", "", "", ""), nil + } + + _, err = r.client.DeleteHealthCheckWithContext(ctx, &route53.DeleteHealthCheckInput{ + HealthCheckId: healthCheck.Id, + }) + + if err != nil { + return provider.HealthCheckResult{}, err + } + + return provider.NewHealthCheckResult(provider.HealthCheckDeleted, *healthCheck.Id, "", "", ""), nil +} + +func (r *Route53HealthCheckReconciler) findHealthCheck(ctx context.Context, probeStatus *v1alpha1.HealthCheckStatusProbe) (*route53.HealthCheck, bool, error) { + if probeStatus == nil || probeStatus.ID == "" { + return nil, false, nil + } + + response, err := r.client.GetHealthCheckWithContext(ctx, &route53.GetHealthCheckInput{ + HealthCheckId: &probeStatus.ID, + }) + if err != nil { + return nil, false, err + } + + return response.HealthCheck, true, nil + +} + +func (r *Route53HealthCheckReconciler) createHealthCheck(ctx context.Context, spec provider.HealthCheckSpec, address string) (*route53.HealthCheck, error) { + // Create the health check + output, err := r.client.CreateHealthCheck(&route53.CreateHealthCheckInput{ + + CallerReference: callerReference(spec.Id), + + HealthCheckConfig: &route53.HealthCheckConfig{ + IPAddress: &address, + FullyQualifiedDomainName: spec.Host, + Port: spec.Port, + ResourcePath: &spec.Path, + Type: healthCheckType(spec.Protocol), + FailureThreshold: spec.FailureThreshold, + }, + }) + if err != nil { + return nil, err + } + + // Add the tag to identify it + _, err = r.client.ChangeTagsForResourceWithContext(ctx, &route53.ChangeTagsForResourceInput{ + AddTags: []*route53.Tag{ + { + Key: aws.String(idTag), + Value: aws.String(spec.Id), + }, + { + Key: aws.String("Name"), + Value: &spec.Name, + }, + }, + ResourceId: output.HealthCheck.Id, + ResourceType: aws.String(route53.TagResourceTypeHealthcheck), + }) + if err != nil { + return nil, err + } + + return output.HealthCheck, nil +} + +func (r *Route53HealthCheckReconciler) updateHealthCheck(ctx context.Context, spec provider.HealthCheckSpec, endpoint *externaldns.Endpoint, healthCheck *route53.HealthCheck, address string) (provider.HealthCheckReconciliationResult, error) { + diff := healthCheckDiff(healthCheck, spec, endpoint, address) + if diff == nil { + return provider.HealthCheckNoop, nil + } + + _, err := r.client.UpdateHealthCheckWithContext(ctx, diff) + if err != nil { + return provider.HealthCheckFailed, err + } + + return provider.HealthCheckUpdated, nil +} + +// healthCheckDiff creates a `UpdateHealthCheckInput` object with the fields to +// update on healthCheck based on the given spec. +// If the health check matches the spec, returns `nil` +func healthCheckDiff(healthCheck *route53.HealthCheck, spec provider.HealthCheckSpec, endpoint *externaldns.Endpoint, address string) *route53.UpdateHealthCheckInput { + var result *route53.UpdateHealthCheckInput + + // "Lazily" set the value for result only once and only when there is + // a change, to ensure that it's nil if there's no change + diff := func() *route53.UpdateHealthCheckInput { + if result == nil { + result = &route53.UpdateHealthCheckInput{ + HealthCheckId: healthCheck.Id, + } + } + + return result + } + + if !valuesEqual(&endpoint.DNSName, healthCheck.HealthCheckConfig.FullyQualifiedDomainName) { + diff().FullyQualifiedDomainName = spec.Host + } + + if !valuesEqual(&address, healthCheck.HealthCheckConfig.IPAddress) { + diff().IPAddress = &address + } + if !valuesEqualWithDefault(&spec.Path, healthCheck.HealthCheckConfig.ResourcePath, defaultHealthCheckPath) { + diff().ResourcePath = &spec.Path + } + if !valuesEqualWithDefault(spec.Port, healthCheck.HealthCheckConfig.Port, defaultHealthCheckPort) { + diff().Port = spec.Port + } + if !valuesEqualWithDefault(spec.FailureThreshold, healthCheck.HealthCheckConfig.FailureThreshold, defaultHealthCheckFailureThreshold) { + diff().FailureThreshold = spec.FailureThreshold + } + + return result +} + +func init() { + sid := xid.New() + callerReference = func(s string) *string { + return aws.String(fmt.Sprintf("%s.%s", s, sid)) + } +} + +func healthCheckType(protocol *provider.HealthCheckProtocol) *string { + if protocol == nil { + return nil + } + + switch *protocol { + case provider.HealthCheckProtocolHTTP: + return aws.String(route53.HealthCheckTypeHttp) + + case provider.HealthCheckProtocolHTTPS: + return aws.String(route53.HealthCheckTypeHttps) + } + + return nil +} + +func valuesEqual[T comparable](ptr1, ptr2 *T) bool { + if (ptr1 == nil && ptr2 != nil) || (ptr1 != nil && ptr2 == nil) { + return false + } + if ptr1 == nil && ptr2 == nil { + return true + } + + return *ptr1 == *ptr2 +} + +func valuesEqualWithDefault[T comparable](ptr1, ptr2 *T, defaultValue T) bool { + value1 := defaultValue + if ptr1 != nil { + value1 = *ptr1 + } + + value2 := defaultValue + if ptr2 != nil { + value2 = *ptr2 + } + + return value1 == value2 +} diff --git a/internal/provider/cached.go b/internal/provider/cached.go new file mode 100644 index 00000000..7721472b --- /dev/null +++ b/internal/provider/cached.go @@ -0,0 +1,68 @@ +package provider + +import ( + "context" + "reflect" + "sync" + + externaldns "sigs.k8s.io/external-dns/endpoint" + + "github.com/kuadrant/dns-operator/api/v1alpha1" +) + +type CachedHealthCheckReconciler struct { + reconciler HealthCheckReconciler + provider Provider + + syncCache *sync.Map +} + +var _ HealthCheckReconciler = &CachedHealthCheckReconciler{} + +func NewCachedHealthCheckReconciler(provider Provider, reconciler HealthCheckReconciler) *CachedHealthCheckReconciler { + return &CachedHealthCheckReconciler{ + reconciler: reconciler, + provider: provider, + syncCache: &sync.Map{}, + } +} + +// Delete implements HealthCheckReconciler +func (r *CachedHealthCheckReconciler) Delete(ctx context.Context, endpoint *externaldns.Endpoint, probeStatus *v1alpha1.HealthCheckStatusProbe) (HealthCheckResult, error) { + id, ok := r.getHealthCheckID(endpoint) + if !ok { + return NewHealthCheckResult(HealthCheckNoop, "", "", "", ""), nil + } + + defer r.syncCache.Delete(id) + return r.reconciler.Delete(ctx, endpoint, probeStatus) +} + +// Reconcile implements HealthCheckReconciler +func (r *CachedHealthCheckReconciler) Reconcile(ctx context.Context, spec HealthCheckSpec, endpoint *externaldns.Endpoint, probeStatus *v1alpha1.HealthCheckStatusProbe, address string) (HealthCheckResult, error) { + id, ok := r.getHealthCheckID(endpoint) + if !ok { + return r.reconciler.Reconcile(ctx, spec, endpoint, probeStatus, address) + } + + // Update the cache with the new spec + defer r.syncCache.Store(id, spec) + + // If the health heck is not cached, delegate the reconciliation + existingSpec, ok := r.syncCache.Load(id) + if !ok { + return r.reconciler.Reconcile(ctx, spec, endpoint, probeStatus, address) + } + + // If the spec is unchanged, return Noop + if reflect.DeepEqual(spec, existingSpec.(HealthCheckSpec)) { + return NewHealthCheckResult(HealthCheckNoop, id, "", "", "Spec unchanged"), nil + } + + // Otherwise, delegate the reconciliation + return r.reconciler.Reconcile(ctx, spec, endpoint, probeStatus, address) +} + +func (r *CachedHealthCheckReconciler) getHealthCheckID(endpoint *externaldns.Endpoint) (string, bool) { + return endpoint.GetProviderSpecificProperty(r.provider.ProviderSpecific().HealthCheckID) +} diff --git a/internal/provider/endpointUtils.go b/internal/provider/endpointUtils.go new file mode 100644 index 00000000..aacfb8c7 --- /dev/null +++ b/internal/provider/endpointUtils.go @@ -0,0 +1,30 @@ +package provider + +import ( + "strings" + + "k8s.io/utils/net" + externaldns "sigs.k8s.io/external-dns/endpoint" + + "github.com/kuadrant/dns-operator/api/v1alpha1" +) + +func IsExternalAddress(address string, dnsRecord *v1alpha1.DNSRecord) bool { + if net.IsIPv4String(address) { + return true + } + + if dnsRecord.Spec.RootHost == nil { + return false + } + return !strings.Contains(address, *dnsRecord.Spec.RootHost) +} + +func GetExternalAddresses(endpoint *externaldns.Endpoint, dnsRecord *v1alpha1.DNSRecord) (externalAddresses []string) { + for _, a := range endpoint.Targets { + if IsExternalAddress(a, dnsRecord) { + externalAddresses = append(externalAddresses, a) + } + } + return externalAddresses +} diff --git a/internal/provider/fake.go b/internal/provider/fake.go new file mode 100644 index 00000000..5ab2c62c --- /dev/null +++ b/internal/provider/fake.go @@ -0,0 +1,21 @@ +package provider + +import ( + "context" + + externaldns "sigs.k8s.io/external-dns/endpoint" + + "github.com/kuadrant/dns-operator/api/v1alpha1" +) + +type FakeHealthCheckReconciler struct{} + +func (*FakeHealthCheckReconciler) Reconcile(_ context.Context, _ HealthCheckSpec, _ *externaldns.Endpoint, _ *v1alpha1.HealthCheckStatusProbe, _ string) (HealthCheckResult, error) { + return HealthCheckResult{HealthCheckCreated, "fakeID", "", "", ""}, nil +} + +func (*FakeHealthCheckReconciler) Delete(_ context.Context, _ *externaldns.Endpoint, _ *v1alpha1.HealthCheckStatusProbe) (HealthCheckResult, error) { + return HealthCheckResult{HealthCheckDeleted, "fakeID", "", "", ""}, nil +} + +var _ HealthCheckReconciler = &FakeHealthCheckReconciler{} diff --git a/internal/provider/fake/provider.go b/internal/provider/fake/provider.go index 70a644ec..b663c0b5 100644 --- a/internal/provider/fake/provider.go +++ b/internal/provider/fake/provider.go @@ -48,3 +48,12 @@ func (p Provider) EnsureManagedZone(managedZone *v1alpha1.ManagedZone) (provider func (p Provider) DeleteManagedZone(managedZone *v1alpha1.ManagedZone) error { return p.DeleteManagedZoneFunc(managedZone) } +func (p Provider) HealthCheckReconciler() provider.HealthCheckReconciler { + return &provider.FakeHealthCheckReconciler{} +} +func (p Provider) ProviderSpecific() provider.ProviderSpecificLabels { + return provider.ProviderSpecificLabels{ + Weight: "fake/weight", + HealthCheckID: "fake/health-check-id", + } +} diff --git a/internal/provider/google/google.go b/internal/provider/google/google.go index 582af4a3..0e267b63 100644 --- a/internal/provider/google/google.go +++ b/internal/provider/google/google.go @@ -149,6 +149,14 @@ type GoogleDNSProvider struct { ctx context.Context } +func (p *GoogleDNSProvider) HealthCheckReconciler() provider.HealthCheckReconciler { + return NewGCPHealthCheckReconciler() +} + +func (p *GoogleDNSProvider) ProviderSpecific() provider.ProviderSpecificLabels { + return provider.ProviderSpecificLabels{} +} + var _ provider.Provider = &GoogleDNSProvider{} func NewProviderFromSecret(ctx context.Context, s *v1.Secret, c provider.Config) (provider.Provider, error) { diff --git a/internal/provider/google/health.go b/internal/provider/google/health.go new file mode 100644 index 00000000..edca1ee7 --- /dev/null +++ b/internal/provider/google/health.go @@ -0,0 +1,27 @@ +package google + +import ( + "context" + + externaldns "sigs.k8s.io/external-dns/endpoint" + + "github.com/kuadrant/dns-operator/api/v1alpha1" + "github.com/kuadrant/dns-operator/internal/provider" +) + +type GCPHealthCheckReconciler struct { +} + +var _ provider.HealthCheckReconciler = &GCPHealthCheckReconciler{} + +func NewGCPHealthCheckReconciler() *GCPHealthCheckReconciler { + return &GCPHealthCheckReconciler{} +} + +func (r *GCPHealthCheckReconciler) Reconcile(_ context.Context, _ provider.HealthCheckSpec, _ *externaldns.Endpoint, _ *v1alpha1.HealthCheckStatusProbe, _ string) (provider.HealthCheckResult, error) { + return provider.HealthCheckResult{}, nil +} + +func (r *GCPHealthCheckReconciler) Delete(_ context.Context, _ *externaldns.Endpoint, _ *v1alpha1.HealthCheckStatusProbe) (provider.HealthCheckResult, error) { + return provider.HealthCheckResult{}, nil +} diff --git a/internal/provider/health.go b/internal/provider/health.go new file mode 100644 index 00000000..f536ce12 --- /dev/null +++ b/internal/provider/health.go @@ -0,0 +1,58 @@ +package provider + +import ( + "context" + + externaldns "sigs.k8s.io/external-dns/endpoint" + + "github.com/kuadrant/dns-operator/api/v1alpha1" +) + +type HealthCheckReconciler interface { + Reconcile(ctx context.Context, spec HealthCheckSpec, endpoint *externaldns.Endpoint, probeStatus *v1alpha1.HealthCheckStatusProbe, address string) (HealthCheckResult, error) + + Delete(ctx context.Context, endpoint *externaldns.Endpoint, probeStatus *v1alpha1.HealthCheckStatusProbe) (HealthCheckResult, error) +} + +type HealthCheckSpec struct { + Id string + Name string + Port *int64 + FailureThreshold *int64 + Protocol *HealthCheckProtocol + Host *string + Path string +} + +type HealthCheckResult struct { + Result HealthCheckReconciliationResult + ID string + IPAddress string + Host string + Message string +} + +func NewHealthCheckResult(result HealthCheckReconciliationResult, id, ipaddress, host, message string) HealthCheckResult { + return HealthCheckResult{ + Result: result, + Message: message, + ID: id, + IPAddress: ipaddress, + Host: host, + } +} + +type HealthCheckReconciliationResult string + +const ( + HealthCheckCreated HealthCheckReconciliationResult = "Created" + HealthCheckUpdated HealthCheckReconciliationResult = "Updated" + HealthCheckDeleted HealthCheckReconciliationResult = "Deleted" + HealthCheckNoop HealthCheckReconciliationResult = "Noop" + HealthCheckFailed HealthCheckReconciliationResult = "Failed" +) + +type HealthCheckProtocol string + +const HealthCheckProtocolHTTP HealthCheckProtocol = "HTTP" +const HealthCheckProtocolHTTPS HealthCheckProtocol = "HTTPS" diff --git a/internal/provider/provider.go b/internal/provider/provider.go index e6a4a452..03cb84a8 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -19,6 +19,11 @@ type Provider interface { // Delete will delete a managed zone. DeleteManagedZone(managedZone *v1alpha1.ManagedZone) error + + // Get an instance of HealthCheckReconciler for this provider + HealthCheckReconciler() HealthCheckReconciler + + ProviderSpecific() ProviderSpecificLabels } type Config struct {