diff --git a/go.mod b/go.mod index 5cdc6664..d2ac2a03 100644 --- a/go.mod +++ b/go.mod @@ -4,8 +4,8 @@ go 1.22.3 require ( github.com/google/go-cmp v0.6.0 - github.com/onsi/ginkgo/v2 v2.17.2 - github.com/onsi/gomega v1.33.1 + github.com/onsi/ginkgo/v2 v2.19.1 + github.com/onsi/gomega v1.34.0 github.com/prometheus/client_golang v1.19.1 github.com/prometheus/common v0.55.0 k8s.io/api v0.30.2 @@ -17,10 +17,13 @@ require ( require ( github.com/stretchr/testify v1.9.0 - go.goms.io/fleet v0.10.5 + go.goms.io/fleet v0.10.10 ) require ( + github.com/Azure/azure-sdk-for-go/sdk/azcore v1.14.0 // indirect + github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 // indirect + github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/trafficmanager/armtrafficmanager v1.3.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect @@ -56,10 +59,10 @@ require ( go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.0 // indirect golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 // indirect - golang.org/x/net v0.26.0 // indirect + golang.org/x/net v0.27.0 // indirect golang.org/x/oauth2 v0.21.0 // indirect - golang.org/x/sys v0.21.0 // indirect - golang.org/x/term v0.21.0 // indirect + golang.org/x/sys v0.22.0 // indirect + golang.org/x/term v0.22.0 // indirect golang.org/x/text v0.16.0 // indirect golang.org/x/time v0.5.0 // indirect golang.org/x/tools v0.22.0 // indirect diff --git a/go.sum b/go.sum index 859000df..04745408 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,13 @@ +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.11.1 h1:E+OJmp2tPvt1W+amx48v1eqbjDYsgN+RzP4q16yV5eM= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.11.1/go.mod h1:a6xsAQUZg+VsS3TJ05SRp524Hs4pZ/AeFSr5ENf0Yjo= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.14.0 h1:nyQWyZvwGTvunIMxi1Y9uXkcyr+I7TeNrr/foo4Kpk8= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.14.0/go.mod h1:l38EPgmsp71HHLq9j7De57JcKOWPyhrsW1Awm1JS6K0= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.8.0 h1:jBQA3cKT4L2rWMpgE7Yt3Hwh2aUj8KXjIGLxjHeYNNo= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.8.0/go.mod h1:4OG6tQ9EOP/MT0NMjDlRzWoVFxfu9rN9B2X+tlSVktg= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 h1:ywEEhmNahHBihViHepv3xPBn1663uRv2t2q/ESv9seY= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0/go.mod h1:iZDifYGJTIgIIkYRNWPENUnqx6bJ2xnSDFI2tjwZNuY= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/trafficmanager/armtrafficmanager v1.3.0 h1:e3kTG23M5ps+DjvPolK4dcgohDY8sHsXU7zrdHj1WzY= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/trafficmanager/armtrafficmanager v1.3.0/go.mod h1:Os5dq8Cvvz97rJauZhZJAfKHN+OEvF/0nVmHzF4aVys= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= @@ -70,8 +80,12 @@ github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f h1:KUppIJq7/+ github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/onsi/ginkgo/v2 v2.17.2 h1:7eMhcy3GimbsA3hEnVKdw/PQM9XN9krpKVXsZdph0/g= github.com/onsi/ginkgo/v2 v2.17.2/go.mod h1:nP2DPOQoNsQmsVyv5rDA8JkXQoCs6goXIvr/PRJ1eCc= +github.com/onsi/ginkgo/v2 v2.19.1 h1:QXgq3Z8Crl5EL1WBAC98A5sEBHARrAJNzAmMxzLcRF0= +github.com/onsi/ginkgo/v2 v2.19.1/go.mod h1:O3DtEWQkPa/F7fBMgmZQKKsluAy8pd3rEQdrjkPb9zA= github.com/onsi/gomega v1.33.1 h1:dsYjIxxSR755MDmKVsaFQTE22ChNBcuuTWgkUDSubOk= github.com/onsi/gomega v1.33.1/go.mod h1:U4R44UsT+9eLIaYRB2a5qajjtQYn0hauxvRm16AVYg0= +github.com/onsi/gomega v1.34.0 h1:eSSPsPNp6ZpsG8X1OVmOTxig+CblTc4AxpPBykhe2Os= +github.com/onsi/gomega v1.34.0/go.mod h1:MIKI8c+f+QLWk+hxbePD4i0LMJSExPaZOVfkoex4cAo= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -96,6 +110,8 @@ github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= go.goms.io/fleet v0.10.5 h1:Zc+pLk77zWv0hAqBbFZEMMd05MVw9P8jp8YHTy7WPdI= go.goms.io/fleet v0.10.5/go.mod h1:FpVP3YsiewmyGH77Yx6sLngHbZKgepnmJDIibz2pjZo= +go.goms.io/fleet v0.10.10 h1:qdOfSCEVKFmv5K1O5/iftj5DzlxyRYNsM3DGrSO0FwE= +go.goms.io/fleet v0.10.10/go.mod h1:WkN23NUb/efeo76BwFO5xxEwR6BMvq0nwl3/GeBdYRg= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= @@ -115,6 +131,8 @@ golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ= golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= +golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys= +golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE= golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs= golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -125,8 +143,12 @@ golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= +golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.21.0 h1:WVXCp+/EBEHOj53Rvu+7KiT/iElMrO8ACK16SMZ3jaA= golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0= +golang.org/x/term v0.22.0 h1:BbsgPEJULsl2fV/AT3v15Mjva5yXKQDyKf+TbDz7QJk= +golang.org/x/term v0.22.0/go.mod h1:F3qCibpT5AMpCRfhfT53vVJwhLtIVHhB9XDjfFvnMI4= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= diff --git a/pkg/common/azureerrors/azureerrors.go b/pkg/common/azureerrors/azureerrors.go new file mode 100644 index 00000000..bb075067 --- /dev/null +++ b/pkg/common/azureerrors/azureerrors.go @@ -0,0 +1,39 @@ +/* +Copyright (c) Microsoft Corporation. +Licensed under the MIT license. +*/ + +// Package azureerrors defines shared azure error util functions. +package azureerrors + +import ( + "errors" + "net/http" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" +) + +// IsNotFound returns true if the error is a http 404 error returned by the azure server. +func IsNotFound(err error) bool { + var responseError *azcore.ResponseError + return errors.As(err, &responseError) && responseError.StatusCode == http.StatusNotFound +} + +// IsClientError returns true if the error is a client error (400-499) returned by the azure server. +func IsClientError(err error) bool { + var responseError *azcore.ResponseError + return errors.As(err, &responseError) && + responseError.StatusCode >= http.StatusBadRequest && responseError.StatusCode < http.StatusInternalServerError +} + +// IsConflict determines if the error is a http 409 error returned by the azure server. +func IsConflict(err error) bool { + var responseError *azcore.ResponseError + return errors.As(err, &responseError) && responseError.StatusCode == http.StatusConflict +} + +// IsThrottled determines if the error is a http 429 error returned by the azure server. +func IsThrottled(err error) bool { + var responseError *azcore.ResponseError + return errors.As(err, &responseError) && responseError.StatusCode == http.StatusTooManyRequests +} diff --git a/pkg/common/objectmeta/objectmeta.go b/pkg/common/objectmeta/objectmeta.go index 94a34d67..ad88d198 100644 --- a/pkg/common/objectmeta/objectmeta.go +++ b/pkg/common/objectmeta/objectmeta.go @@ -6,28 +6,36 @@ Licensed under the MIT license. // Package objectmeta defines shared meta const used by the networking objects. package objectmeta +const ( + fleetNetworkingPrefix = "networking.fleet.azure.com/" +) + // Finalizers const ( // InternalServiceExportFinalizer is the finalizer InternalServiceExport controllers adds to mark that a // InternalServiceExport can only be deleted after both ServiceImport label and ServiceExport conflict resolution // result have been updated. - InternalServiceExportFinalizer = "networking.fleet.azure.com/internal-svc-export-cleanup" + InternalServiceExportFinalizer = fleetNetworkingPrefix + "internal-svc-export-cleanup" + + // TrafficManagerProfileFinalizer a finalizer added by the TrafficManagerProfile controller to all trafficManagerProfiles, + // to make sure that the controller can react to profile deletions if necessary. + TrafficManagerProfileFinalizer = fleetNetworkingPrefix + "traffic-manager-profile-cleanup" ) // Labels const ( // MultiClusterServiceLabelDerivedService is the label added by the MCS controller, which marks the // derived Service behind a MCS. - MultiClusterServiceLabelDerivedService = "networking.fleet.azure.com/derived-service" + MultiClusterServiceLabelDerivedService = fleetNetworkingPrefix + "derived-service" ) // Annotations const ( // ServiceImportAnnotationServiceInUseBy is the key of the ServiceInUseBy annotation, which marks the list // of member clusters importing an exported Service. - ServiceImportAnnotationServiceInUseBy = "networking.fleet.azure.com/service-in-use-by" + ServiceImportAnnotationServiceInUseBy = fleetNetworkingPrefix + "service-in-use-by" // ExportedObjectAnnotationUniqueName is an annotation that marks the fleet-scoped unique name assigned to // an exported object. - ExportedObjectAnnotationUniqueName = "networking.fleet.azure.com/fleet-unique-name" + ExportedObjectAnnotationUniqueName = fleetNetworkingPrefix + "fleet-unique-name" ) diff --git a/pkg/controllers/hub/trafficmanagerprofile/controller.go b/pkg/controllers/hub/trafficmanagerprofile/controller.go new file mode 100644 index 00000000..5e28a5bb --- /dev/null +++ b/pkg/controllers/hub/trafficmanagerprofile/controller.go @@ -0,0 +1,196 @@ +/* +Copyright (c) Microsoft Corporation. +Licensed under the MIT license. +*/ + +// Package trafficmanagerprofile features the TrafficManagerProfile controller to reconcile TrafficManagerProfile CRs. +package trafficmanagerprofile + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/trafficmanager/armtrafficmanager" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/klog/v2" + "k8s.io/utils/ptr" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + + "go.goms.io/fleet/pkg/utils/controller" + + fleetnetv1alpha1 "go.goms.io/fleet-networking/api/v1alpha1" + "go.goms.io/fleet-networking/pkg/common/azureerrors" + "go.goms.io/fleet-networking/pkg/common/objectmeta" +) + +// Reconciler reconciles a TrafficManagerProfile object. +type Reconciler struct { + client.Client + + ProfilesClient *armtrafficmanager.ProfilesClient + ResourceGroupName string // default resource group name to create azure traffic manager profiles +} + +//+kubebuilder:rbac:groups=networking.fleet.azure.com,resources=trafficmanagerprofiles,verbs=get;list;watch;create;update;patch;delete +//+kubebuilder:rbac:groups=networking.fleet.azure.com,resources=trafficmanagerprofiles/status,verbs=get;update;patch +//+kubebuilder:rbac:groups=networking.fleet.azure.com,resources=trafficmanagerprofiles/finalizers,verbs=get;update +//+kubebuilder:rbac:groups="",resources=events,verbs=create;patch + +// Reconcile triggers a single reconcile round. +func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + name := req.NamespacedName + profileKRef := klog.KRef(name.Namespace, name.Name) + + startTime := time.Now() + klog.V(2).InfoS("Reconciliation starts", "trafficManagerProfile", profileKRef) + defer func() { + latency := time.Since(startTime).Milliseconds() + klog.V(2).InfoS("Reconciliation ends", "trafficManagerProfile", profileKRef, "latency", latency) + }() + + profile := &fleetnetv1alpha1.TrafficManagerProfile{} + if err := r.Client.Get(ctx, name, profile); err != nil { + if apierrors.IsNotFound(err) { + klog.V(4).InfoS("Ignoring NotFound trafficManagerProfile", "trafficManagerProfile", profileKRef) + return ctrl.Result{}, nil + } + klog.ErrorS(err, "Failed to get trafficManagerProfile", "trafficManagerProfile", profileKRef) + return ctrl.Result{}, controller.NewAPIServerError(true, err) + } + + if !profile.ObjectMeta.DeletionTimestamp.IsZero() { + return r.handleDelete(ctx, profile) + } + + // register finalizer + if !controllerutil.ContainsFinalizer(profile, objectmeta.TrafficManagerProfileFinalizer) { + controllerutil.AddFinalizer(profile, objectmeta.TrafficManagerProfileFinalizer) + if err := r.Update(ctx, profile); err != nil { + klog.ErrorS(err, "Failed to add finalizer to trafficManagerProfile", "trafficManagerProfile", profileKRef) + return ctrl.Result{}, controller.NewUpdateIgnoreConflictError(err) + } + } + + return r.handleUpdate(ctx, profile) +} + +func (r *Reconciler) handleDelete(ctx context.Context, profile *fleetnetv1alpha1.TrafficManagerProfile) (ctrl.Result, error) { + profileKObj := klog.KObj(profile) + // The profile is being deleted + if !controllerutil.ContainsFinalizer(profile, objectmeta.TrafficManagerProfileFinalizer) { + klog.V(4).InfoS("TrafficManagerProfile is being deleted", "trafficManagerProfile", profileKObj) + return ctrl.Result{}, nil + } + + klog.V(2).InfoS("Deleting Azure Traffic Manager profile", "trafficManagerProfile", profileKObj) + if _, err := r.ProfilesClient.Delete(ctx, r.ResourceGroupName, profile.Name, nil); err != nil { + if !azureerrors.IsNotFound(err) { + klog.ErrorS(err, "Failed to delete Azure Traffic Manager profile", "trafficManagerProfile", profileKObj) + return ctrl.Result{}, err + } + } + + controllerutil.RemoveFinalizer(profile, objectmeta.TrafficManagerProfileFinalizer) + if err := r.Client.Update(ctx, profile); err != nil { + klog.ErrorS(err, "Failed to remove trafficManagerProfile finalizer", "trafficManagerProfile", profileKObj) + return ctrl.Result{}, err + } + return ctrl.Result{}, nil +} + +func (r *Reconciler) handleUpdate(ctx context.Context, profile *fleetnetv1alpha1.TrafficManagerProfile) (ctrl.Result, error) { + profileKObj := klog.KObj(profile) + res, updateErr := r.ProfilesClient.CreateOrUpdate(ctx, r.ResourceGroupName, profile.Name, generateAzureTrafficManagerProfile(profile), nil) + var responseError *azcore.ResponseError + if updateErr != nil { + if !errors.As(updateErr, &responseError) { + klog.ErrorS(updateErr, "Failed to send the createOrUpdate request", "trafficManagerProfile", profileKObj) + return ctrl.Result{}, updateErr + } + klog.ErrorS(updateErr, "Failed to create or update a profile", "trafficManagerProfile", profileKObj, "errorCode", responseError.ErrorCode, "statusCode", responseError.StatusCode) + } else { + // res.Profile.Properties.DNSConfig.Fqdn should not be nil + if res.Profile.Properties != nil && res.Profile.Properties.DNSConfig != nil { + profile.Status.DNSName = res.Profile.Properties.DNSConfig.Fqdn + } else { + err := fmt.Errorf("got nil DNSConfig for Azure Traffic Manager profile") + klog.ErrorS(controller.NewUnexpectedBehaviorError(err), "Unexpected value returned by the Azure Traffic Manager", "trafficManagerProfile", profileKObj) + } + } + cond := metav1.Condition{ + Type: string(fleetnetv1alpha1.TrafficManagerProfileConditionProgrammed), + Status: metav1.ConditionTrue, + ObservedGeneration: profile.Generation, + Reason: string(fleetnetv1alpha1.TrafficManagerProfileReasonProgrammed), + Message: "Successfully configured the Azure Traffic Manager profile", + } + if azureerrors.IsConflict(updateErr) { + cond = metav1.Condition{ + Type: string(fleetnetv1alpha1.TrafficManagerProfileConditionProgrammed), + Status: metav1.ConditionFalse, + ObservedGeneration: profile.Generation, + Reason: string(fleetnetv1alpha1.TrafficManagerProfileReasonDNSNameNotAvailable), + Message: "Domain name is not available. Please choose a different profile name", + } + } else if azureerrors.IsClientError(updateErr) && !azureerrors.IsThrottled(updateErr) { + cond = metav1.Condition{ + Type: string(fleetnetv1alpha1.TrafficManagerProfileConditionProgrammed), + Status: metav1.ConditionFalse, + ObservedGeneration: profile.Generation, + Reason: string(fleetnetv1alpha1.TrafficManagerProfileReasonInvalid), + Message: fmt.Sprintf("Invalid profile: %v", updateErr), + } + } else if updateErr != nil { + cond = metav1.Condition{ + Type: string(fleetnetv1alpha1.TrafficManagerProfileConditionProgrammed), + Status: metav1.ConditionFalse, + ObservedGeneration: profile.Generation, + Reason: string(fleetnetv1alpha1.TrafficManagerProfileReasonPending), + Message: fmt.Sprintf("Failed to configure profile and retyring: %v", updateErr), + } + } + meta.SetStatusCondition(&profile.Status.Conditions, cond) + if err := r.Client.Status().Update(ctx, profile); err != nil { + klog.ErrorS(err, "Failed to update trafficManagerProfile status", "trafficManagerProfile", profileKObj) + return ctrl.Result{}, controller.NewUpdateIgnoreConflictError(err) + } + klog.V(2).InfoS("Updated the trafficProfile status", "trafficManagerProfile", profileKObj, "status", profile.Status) + return ctrl.Result{}, updateErr +} + +func generateAzureTrafficManagerProfile(profile *fleetnetv1alpha1.TrafficManagerProfile) armtrafficmanager.Profile { + mc := profile.Spec.MonitorConfig + return armtrafficmanager.Profile{ + Location: ptr.To("global"), + Properties: &armtrafficmanager.ProfileProperties{ + DNSConfig: &armtrafficmanager.DNSConfig{ + RelativeName: ptr.To(profile.Name), + }, + MonitorConfig: &armtrafficmanager.MonitorConfig{ + IntervalInSeconds: mc.IntervalInSeconds, + Path: mc.Path, + Port: mc.Port, + Protocol: ptr.To(armtrafficmanager.MonitorProtocol(*mc.Protocol)), + TimeoutInSeconds: mc.TimeoutInSeconds, + ToleratedNumberOfFailures: mc.ToleratedNumberOfFailures, + }, + ProfileStatus: ptr.To(armtrafficmanager.ProfileStatusEnabled), + // By default, the routing method is set to Weighted. + TrafficRoutingMethod: ptr.To(armtrafficmanager.TrafficRoutingMethodWeighted), + }, + } +} + +// SetupWithManager sets up the controller with the Manager. +func (r *Reconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&fleetnetv1alpha1.TrafficManagerProfile{}). + Complete(r) +} diff --git a/pkg/controllers/hub/trafficmanagerprofile/controller_integration_test.go b/pkg/controllers/hub/trafficmanagerprofile/controller_integration_test.go new file mode 100644 index 00000000..9c32bd0e --- /dev/null +++ b/pkg/controllers/hub/trafficmanagerprofile/controller_integration_test.go @@ -0,0 +1,271 @@ +/* +Copyright (c) Microsoft Corporation. +Licensed under the MIT license. +*/ + +package trafficmanagerprofile + +import ( + "fmt" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/utils/ptr" + + fleetnetv1alpha1 "go.goms.io/fleet-networking/api/v1alpha1" + "go.goms.io/fleet-networking/pkg/common/objectmeta" + "go.goms.io/fleet-networking/test/common/trafficmanager/fakeprovider" + "go.goms.io/fleet-networking/test/common/trafficmanager/validator" +) + +func trafficManagerProfileForTest(name string) *fleetnetv1alpha1.TrafficManagerProfile { + return &fleetnetv1alpha1.TrafficManagerProfile{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: testNamespace, + }, + Spec: fleetnetv1alpha1.TrafficManagerProfileSpec{ + MonitorConfig: &fleetnetv1alpha1.MonitorConfig{ + IntervalInSeconds: ptr.To[int64](30), + Path: ptr.To("/healthz"), + Port: ptr.To[int64](8080), + Protocol: ptr.To(fleetnetv1alpha1.TrafficManagerMonitorProtocolHTTPS), + TimeoutInSeconds: ptr.To[int64](10), + ToleratedNumberOfFailures: ptr.To[int64](5), + }, + }, + } +} + +var _ = Describe("Test TrafficManagerProfile Controller", func() { + Context("When creating valid trafficManagerProfile", Ordered, func() { + name := fakeprovider.ValidProfileName + var profile *fleetnetv1alpha1.TrafficManagerProfile + + It("AzureTrafficManager should be configured", func() { + By("By creating a new TrafficManagerProfile") + profile = trafficManagerProfileForTest(name) + Expect(k8sClient.Create(ctx, profile)).Should(Succeed()) + + By("By checking profile") + want := fleetnetv1alpha1.TrafficManagerProfile{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: testNamespace, + Finalizers: []string{objectmeta.TrafficManagerProfileFinalizer}, + }, + Spec: profile.Spec, + Status: fleetnetv1alpha1.TrafficManagerProfileStatus{ + DNSName: ptr.To(fmt.Sprintf(fakeprovider.ProfileDNSNameFormat, name)), + Conditions: []metav1.Condition{ + { + Status: metav1.ConditionTrue, + Type: string(fleetnetv1alpha1.TrafficManagerProfileConditionProgrammed), + Reason: string(fleetnetv1alpha1.TrafficManagerProfileReasonProgrammed), + }, + }, + }, + } + validator.ValidateTrafficManagerProfile(ctx, k8sClient, &want) + }) + + It("Update the trafficManagerProfile spec", func() { + Expect(k8sClient.Get(ctx, types.NamespacedName{Namespace: testNamespace, Name: name}, profile)).Should(Succeed(), "failed to get the trafficManagerProfile") + profile.Spec.MonitorConfig.IntervalInSeconds = ptr.To[int64](0) + Expect(k8sClient.Update(ctx, profile)).Should(Succeed(), "failed to update the trafficManagerProfile") + }) + + It("Validating trafficManagerProfile status and update should fail", func() { + want := fleetnetv1alpha1.TrafficManagerProfile{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: testNamespace, + Finalizers: []string{objectmeta.TrafficManagerProfileFinalizer}, + }, + Spec: profile.Spec, + Status: fleetnetv1alpha1.TrafficManagerProfileStatus{ + DNSName: ptr.To(fmt.Sprintf(fakeprovider.ProfileDNSNameFormat, name)), + Conditions: []metav1.Condition{ + { + Status: metav1.ConditionFalse, + Type: string(fleetnetv1alpha1.TrafficManagerProfileConditionProgrammed), + Reason: string(fleetnetv1alpha1.TrafficManagerProfileReasonInvalid), + }, + }, + }, + } + validator.ValidateTrafficManagerProfile(ctx, k8sClient, &want) + }) + + It("Deleting trafficManagerProfile", func() { + err := k8sClient.Delete(ctx, profile) + Expect(err).Should(Succeed(), "failed to delete trafficManagerProfile") + }) + + It("Validating trafficManagerProfile is deleted", func() { + validator.IsTrafficManagerProfileDeleted(ctx, k8sClient, types.NamespacedName{Namespace: testNamespace, Name: name}) + }) + }) + + Context("When creating trafficManagerProfile and DNS name is not available", Ordered, func() { + name := fakeprovider.ConflictErrProfileName + var profile *fleetnetv1alpha1.TrafficManagerProfile + + It("AzureTrafficManager should not be configured", func() { + By("By creating a new TrafficManagerProfile") + profile = trafficManagerProfileForTest(name) + Expect(k8sClient.Create(ctx, profile)).Should(Succeed()) + + By("By checking profile") + want := fleetnetv1alpha1.TrafficManagerProfile{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: testNamespace, + Finalizers: []string{objectmeta.TrafficManagerProfileFinalizer}, + }, + Spec: profile.Spec, + Status: fleetnetv1alpha1.TrafficManagerProfileStatus{ + Conditions: []metav1.Condition{ + { + Status: metav1.ConditionFalse, + Type: string(fleetnetv1alpha1.TrafficManagerProfileConditionProgrammed), + Reason: string(fleetnetv1alpha1.TrafficManagerProfileReasonDNSNameNotAvailable), + }, + }, + }, + } + validator.ValidateTrafficManagerProfile(ctx, k8sClient, &want) + }) + + It("Deleting trafficManagerProfile", func() { + err := k8sClient.Delete(ctx, profile) + Expect(err).Should(Succeed(), "failed to delete trafficManagerProfile") + }) + + It("Validating trafficManagerProfile is deleted", func() { + validator.IsTrafficManagerProfileDeleted(ctx, k8sClient, types.NamespacedName{Namespace: testNamespace, Name: name}) + }) + }) + + Context("When creating trafficManagerProfile and azure request failed because of too many requests", Ordered, func() { + name := fakeprovider.ThrottledErrProfileName + var profile *fleetnetv1alpha1.TrafficManagerProfile + + It("AzureTrafficManager should not be configured", func() { + By("By creating a new TrafficManagerProfile") + profile = trafficManagerProfileForTest(name) + Expect(k8sClient.Create(ctx, profile)).Should(Succeed()) + + By("By checking profile") + want := fleetnetv1alpha1.TrafficManagerProfile{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: testNamespace, + Finalizers: []string{objectmeta.TrafficManagerProfileFinalizer}, + }, + Spec: profile.Spec, + Status: fleetnetv1alpha1.TrafficManagerProfileStatus{ + Conditions: []metav1.Condition{ + { + Status: metav1.ConditionFalse, + Type: string(fleetnetv1alpha1.TrafficManagerProfileConditionProgrammed), + Reason: string(fleetnetv1alpha1.TrafficManagerProfileReasonPending), + }, + }, + }, + } + validator.ValidateTrafficManagerProfile(ctx, k8sClient, &want) + }) + + It("Deleting trafficManagerProfile", func() { + err := k8sClient.Delete(ctx, profile) + Expect(err).Should(Succeed(), "failed to delete trafficManagerProfile") + }) + + It("Validating trafficManagerProfile is deleted", func() { + validator.IsTrafficManagerProfileDeleted(ctx, k8sClient, types.NamespacedName{Namespace: testNamespace, Name: name}) + }) + }) + + Context("When creating trafficManagerProfile and azure request failed because of client side error", Ordered, func() { + name := "bad-request" + var profile *fleetnetv1alpha1.TrafficManagerProfile + + It("AzureTrafficManager should not be configured", func() { + By("By creating a new TrafficManagerProfile") + profile = trafficManagerProfileForTest(name) + Expect(k8sClient.Create(ctx, profile)).Should(Succeed()) + + By("By checking profile") + want := fleetnetv1alpha1.TrafficManagerProfile{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: testNamespace, + Finalizers: []string{objectmeta.TrafficManagerProfileFinalizer}, + }, + Spec: profile.Spec, + Status: fleetnetv1alpha1.TrafficManagerProfileStatus{ + Conditions: []metav1.Condition{ + { + Status: metav1.ConditionFalse, + Type: string(fleetnetv1alpha1.TrafficManagerProfileConditionProgrammed), + Reason: string(fleetnetv1alpha1.TrafficManagerProfileReasonInvalid), + }, + }, + }, + } + validator.ValidateTrafficManagerProfile(ctx, k8sClient, &want) + }) + + It("Deleting trafficManagerProfile", func() { + err := k8sClient.Delete(ctx, profile) + Expect(err).Should(Succeed(), "failed to delete trafficManagerProfile") + }) + + It("Validating trafficManagerProfile is deleted", func() { + validator.IsTrafficManagerProfileDeleted(ctx, k8sClient, types.NamespacedName{Namespace: testNamespace, Name: name}) + }) + }) + + Context("When creating trafficManagerProfile and azure request failed because of internal server error", Ordered, func() { + name := fakeprovider.InternalServerErrProfileName + var profile *fleetnetv1alpha1.TrafficManagerProfile + + It("AzureTrafficManager should not be configured", func() { + By("By creating a new TrafficManagerProfile") + profile = trafficManagerProfileForTest(name) + Expect(k8sClient.Create(ctx, profile)).Should(Succeed()) + + By("By checking profile") + want := fleetnetv1alpha1.TrafficManagerProfile{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: testNamespace, + Finalizers: []string{objectmeta.TrafficManagerProfileFinalizer}, + }, + Spec: profile.Spec, + Status: fleetnetv1alpha1.TrafficManagerProfileStatus{ + Conditions: []metav1.Condition{ + { + Status: metav1.ConditionFalse, + Type: string(fleetnetv1alpha1.TrafficManagerProfileConditionProgrammed), + Reason: string(fleetnetv1alpha1.TrafficManagerProfileReasonPending), + }, + }, + }, + } + validator.ValidateTrafficManagerProfile(ctx, k8sClient, &want) + }) + + It("Deleting trafficManagerProfile", func() { + err := k8sClient.Delete(ctx, profile) + Expect(err).Should(Succeed(), "failed to delete trafficManagerProfile") + }) + + It("Validating trafficManagerProfile is deleted", func() { + validator.IsTrafficManagerProfileDeleted(ctx, k8sClient, types.NamespacedName{Namespace: testNamespace, Name: name}) + }) + }) +}) diff --git a/pkg/controllers/hub/trafficmanagerprofile/suite_test.go b/pkg/controllers/hub/trafficmanagerprofile/suite_test.go new file mode 100644 index 00000000..5853d357 --- /dev/null +++ b/pkg/controllers/hub/trafficmanagerprofile/suite_test.go @@ -0,0 +1,130 @@ +/* +Copyright (c) Microsoft Corporation. +Licensed under the MIT license. +*/ + +package trafficmanagerprofile + +import ( + "context" + "flag" + "path/filepath" + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/rest" + "k8s.io/klog/v2" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/envtest" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + "sigs.k8s.io/controller-runtime/pkg/manager" + metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" + + fleetnetv1alpha1 "go.goms.io/fleet-networking/api/v1alpha1" + "go.goms.io/fleet-networking/test/common/trafficmanager/fakeprovider" +) + +var ( + cfg *rest.Config + mgr manager.Manager + k8sClient client.Client + testEnv *envtest.Environment + ctx context.Context + cancel context.CancelFunc +) + +const ( + testNamespace = "profile-ns" +) + +func TestAPIs(t *testing.T) { + RegisterFailHandler(Fail) + + RunSpecs(t, "TrafficManagerProfile Controller Suite") +} + +var _ = BeforeSuite(func() { + klog.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true))) + + ctx, cancel = context.WithCancel(context.TODO()) + + By("bootstrapping test environment") + testEnv = &envtest.Environment{ + CRDDirectoryPaths: []string{filepath.Join("../../../../", "config", "crd", "bases")}, + ErrorIfCRDPathMissing: true, + } + + var err error + cfg, err = testEnv.Start() + Expect(err).NotTo(HaveOccurred()) + Expect(cfg).NotTo(BeNil()) + + err = fleetnetv1alpha1.AddToScheme(scheme.Scheme) + Expect(err).NotTo(HaveOccurred()) + + //+kubebuilder:scaffold:scheme + By("construct the k8s client") + k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme}) + Expect(err).NotTo(HaveOccurred()) + Expect(k8sClient).NotTo(BeNil()) + + By("starting the controller manager") + klog.InitFlags(flag.CommandLine) + flag.Parse() + + mgr, err = ctrl.NewManager(cfg, ctrl.Options{ + Scheme: scheme.Scheme, + Metrics: metricsserver.Options{ + BindAddress: "0", + }, + }) + Expect(err).NotTo(HaveOccurred()) + + profileClient, err := fakeprovider.NewProfileClient(ctx, "default-sub") + Expect(err).Should(Succeed(), "failed to create the fake profile client") + + err = (&Reconciler{ + Client: mgr.GetClient(), + ProfilesClient: profileClient, + ResourceGroupName: fakeprovider.DefaultResourceGroupName, + }).SetupWithManager(mgr) + Expect(err).ToNot(HaveOccurred()) + + ctx, cancel = context.WithCancel(context.TODO()) + + By("Create profile namespace") + ns := corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: testNamespace, + }, + } + Expect(k8sClient.Create(ctx, &ns)).Should(Succeed()) + + go func() { + defer GinkgoRecover() + err = mgr.Start(ctx) + Expect(err).ToNot(HaveOccurred(), "failed to run manager") + }() +}) + +var _ = AfterSuite(func() { + defer klog.Flush() + + By("delete profile namespace") + ns := corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: testNamespace, + }, + } + Expect(k8sClient.Delete(ctx, &ns)).Should(Succeed()) + + cancel() + By("tearing down the test environment") + err := testEnv.Stop() + Expect(err).NotTo(HaveOccurred()) +}) diff --git a/test/common/trafficmanager/fakeprovider/profile.go b/test/common/trafficmanager/fakeprovider/profile.go new file mode 100644 index 00000000..1a60a178 --- /dev/null +++ b/test/common/trafficmanager/fakeprovider/profile.go @@ -0,0 +1,110 @@ +/* +Copyright (c) Microsoft Corporation. +Licensed under the MIT license. +*/ + +// Package fakeprovider provides a fake azure implementation of traffic manager resources. +package fakeprovider + +import ( + "context" + "fmt" + "net/http" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/trafficmanager/armtrafficmanager/fake" + "k8s.io/utils/ptr" + + azcorefake "github.com/Azure/azure-sdk-for-go/sdk/azcore/fake" + azfake "github.com/Azure/azure-sdk-for-go/sdk/azcore/fake" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/trafficmanager/armtrafficmanager" +) + +const ( + DefaultResourceGroupName = "default-resource-group-name" + + ValidProfileName = "valid-profile" + ConflictErrProfileName = "conflict-err-profile" + InternalServerErrProfileName = "internal-server-err-profile" + ThrottledErrProfileName = "throttled-err-profile" + + ProfileDNSNameFormat = "%s.trafficmanager.net" +) + +// NewProfileClient creates a client which talks to a fake profile server. +func NewProfileClient(ctx context.Context, subscriptionID string) (*armtrafficmanager.ProfilesClient, error) { + fakeServer := fake.ProfilesServer{ + CreateOrUpdate: CreateOrUpdate, + Delete: Delete, + } + clientFactory, err := armtrafficmanager.NewClientFactory(subscriptionID, &azcorefake.TokenCredential{}, + &arm.ClientOptions{ + ClientOptions: azcore.ClientOptions{ + Transport: fake.NewProfilesServerTransport(&fakeServer), + }, + }) + if err != nil { + return nil, err + } + return clientFactory.NewProfilesClient(), nil +} + +// CreateOrUpdate returns the http status code based on the profileName. +func CreateOrUpdate(ctx context.Context, resourceGroupName string, profileName string, parameters armtrafficmanager.Profile, options *armtrafficmanager.ProfilesClientCreateOrUpdateOptions) (resp azfake.Responder[armtrafficmanager.ProfilesClientCreateOrUpdateResponse], errResp azfake.ErrorResponder) { + if resourceGroupName != DefaultResourceGroupName { + errResp.SetResponseError(http.StatusNotFound, "ResourceGroupNotFound") + return resp, errResp + } + switch profileName { + case ConflictErrProfileName: + errResp.SetResponseError(http.StatusConflict, "Conflict") + case InternalServerErrProfileName: + errResp.SetResponseError(http.StatusInternalServerError, "InternalServerError") + case ThrottledErrProfileName: + errResp.SetResponseError(http.StatusTooManyRequests, "ThrottledError") + case ValidProfileName: + if parameters.Properties.MonitorConfig.IntervalInSeconds != nil && + *parameters.Properties.MonitorConfig.IntervalInSeconds == 0 { + errResp.SetResponseError(http.StatusBadRequest, "BadRequestError") + return + } + profileResp := armtrafficmanager.ProfilesClientCreateOrUpdateResponse{ + Profile: armtrafficmanager.Profile{ + Name: ptr.To(profileName), + Location: ptr.To("global"), + Properties: &armtrafficmanager.ProfileProperties{ + DNSConfig: &armtrafficmanager.DNSConfig{ + Fqdn: ptr.To(fmt.Sprintf(ProfileDNSNameFormat, profileName)), + RelativeName: ptr.To(profileName), + TTL: ptr.To[int64](30), + }, + Endpoints: []*armtrafficmanager.Endpoint{}, + MonitorConfig: parameters.Properties.MonitorConfig, + ProfileStatus: ptr.To(armtrafficmanager.ProfileStatusEnabled), + TrafficRoutingMethod: ptr.To(armtrafficmanager.TrafficRoutingMethodWeighted), + TrafficViewEnrollmentStatus: ptr.To(armtrafficmanager.TrafficViewEnrollmentStatusDisabled), + }, + }} + resp.SetResponse(http.StatusOK, profileResp, nil) + default: + errResp.SetResponseError(http.StatusBadRequest, "BadRequestError") + } + return resp, errResp +} + +// Delete returns the http status code based on the profileName. +func Delete(ctx context.Context, resourceGroupName string, profileName string, options *armtrafficmanager.ProfilesClientDeleteOptions) (resp azfake.Responder[armtrafficmanager.ProfilesClientDeleteResponse], errResp azfake.ErrorResponder) { + if resourceGroupName != DefaultResourceGroupName { + errResp.SetResponseError(http.StatusNotFound, "ResourceGroupNotFound") + return resp, errResp + } + switch profileName { + case ValidProfileName: + profileResp := armtrafficmanager.ProfilesClientDeleteResponse{} + resp.SetResponse(http.StatusOK, profileResp, nil) + default: + errResp.SetResponseError(http.StatusNotFound, "NotFound") + } + return resp, errResp +} diff --git a/test/common/trafficmanager/validator/profile.go b/test/common/trafficmanager/validator/profile.go new file mode 100644 index 00000000..a3d2a8ca --- /dev/null +++ b/test/common/trafficmanager/validator/profile.go @@ -0,0 +1,69 @@ +/* +Copyright (c) Microsoft Corporation. +Licensed under the MIT license. +*/ + +package validator + +import ( + "context" + "fmt" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + . "github.com/onsi/gomega" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + + fleetnetv1alpha1 "go.goms.io/fleet-networking/api/v1alpha1" +) + +const ( + timeout = time.Second * 10 + interval = time.Millisecond * 250 +) + +var ( + commonCmpOptions = cmp.Options{ + cmpopts.IgnoreFields(metav1.ObjectMeta{}, "ResourceVersion", "UID", "CreationTimestamp", "ManagedFields", "Generation"), + cmpopts.IgnoreFields(metav1.OwnerReference{}, "UID"), + } + cmpTrafficManagerProfileOptions = cmp.Options{ + commonCmpOptions, + cmpopts.IgnoreFields(fleetnetv1alpha1.TrafficManagerProfile{}, "TypeMeta"), + cmpopts.IgnoreFields(metav1.Condition{}, "Message", "LastTransitionTime", "ObservedGeneration"), + cmpopts.SortSlices(func(c1, c2 metav1.Condition) bool { + return c1.Type < c2.Type + }), + } +) + +// ValidateTrafficManagerProfile validates the trafficManagerProfile object. +func ValidateTrafficManagerProfile(ctx context.Context, k8sClient client.Client, want *fleetnetv1alpha1.TrafficManagerProfile) { + key := types.NamespacedName{Name: want.Name, Namespace: want.Namespace} + profile := &fleetnetv1alpha1.TrafficManagerProfile{} + Eventually(func() error { + if err := k8sClient.Get(ctx, key, profile); err != nil { + return err + } + if diff := cmp.Diff(want, profile, cmpTrafficManagerProfileOptions); diff != "" { + return fmt.Errorf("trafficManagerProfile mismatch (-want, +got) :\n%s", diff) + } + return nil + }, timeout, interval).Should(Succeed(), "Get() trafficManagerProfile mismatch") +} + +// IsTrafficManagerProfileDeleted validates whether the profile is deleted or not. +func IsTrafficManagerProfileDeleted(ctx context.Context, k8sClient client.Client, name types.NamespacedName) { + Eventually(func() error { + profile := &fleetnetv1alpha1.TrafficManagerProfile{} + if err := k8sClient.Get(ctx, name, profile); !errors.IsNotFound(err) { + return fmt.Errorf("trafficManagerProfile %s still exists or an unexpected error occurred: %w", name, err) + } + return nil + }, timeout, interval).Should(Succeed(), "Failed to remove trafficManagerProfile %s ", name) + +}