From cabf8c72df664509cc3c495fbe53c621711f45c3 Mon Sep 17 00:00:00 2001 From: Ramkumar Chinchani Date: Wed, 22 Jul 2026 15:04:33 -0700 Subject: [PATCH] [PATCH 07/08] feat(hub/frontdoorcustomdomain): reconciler + DNS validation token status + envtest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part 07 of an 8-patch split of #373. Second AFD reconciler: FrontDoorCustomDomain. - pkg/controllers/hub/frontdoorcustomdomain/controller.go — reconciles FrontDoorCustomDomain CRs into AFDCustomDomain resources on the parent FrontDoorProfile, surfacing the DNS validation token in Status.DNSValidationToken so operators can add the TXT record. - pkg/controllers/hub/frontdoorcustomdomain/suite_test.go + controller_integration_test.go — envtest suite covering happy path, parent-profile-not-programmed, and validation token surfacing. - test/common/azurefrontdoor/fakeprovider/{endpoint,customdomain}.go — in-memory armcdn fakes for endpoints and custom domains. Depends on API types from PATCH 03, SDK bundle from PATCH 04, and binary from PATCH 05. Sibling to PATCH 06 (frontdoorprofile). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a8fabc68-e113-49ef-b82d-d99a8502da11 --- .../hub/frontdoorcustomdomain/controller.go | 404 ++++++++++++++++++ .../controller_integration_test.go | 195 +++++++++ .../hub/frontdoorcustomdomain/suite_test.go | 119 ++++++ .../fakeprovider/customdomain.go | 134 ++++++ .../azurefrontdoor/fakeprovider/endpoint.go | 124 ++++++ 5 files changed, 976 insertions(+) create mode 100644 pkg/controllers/hub/frontdoorcustomdomain/controller.go create mode 100644 pkg/controllers/hub/frontdoorcustomdomain/controller_integration_test.go create mode 100644 pkg/controllers/hub/frontdoorcustomdomain/suite_test.go create mode 100644 test/common/azurefrontdoor/fakeprovider/customdomain.go create mode 100644 test/common/azurefrontdoor/fakeprovider/endpoint.go diff --git a/pkg/controllers/hub/frontdoorcustomdomain/controller.go b/pkg/controllers/hub/frontdoorcustomdomain/controller.go new file mode 100644 index 00000000..6a74183a --- /dev/null +++ b/pkg/controllers/hub/frontdoorcustomdomain/controller.go @@ -0,0 +1,404 @@ +/* +Copyright (c) Microsoft Corporation. +Licensed under the MIT license. +*/ + +// Package frontdoorcustomdomain features the FrontDoorCustomDomain controller (POC) that +// reconciles FrontDoorCustomDomain CRs to Azure Front Door custom domain resources. +// +// Scope note (POC, see breadcrumb 2026-07-18 and Addendum 2 of 2026-07-20-1108): +// - Only the Managed TLS path is implemented. BYOC (Key Vault) is intentionally rejected +// with an Invalid condition — the spec field is reserved but the reconciliation path is +// deferred (breadcrumb D3). Tracked in docs/first-party/002-afd-implementation-plan.md +// §9 risks. +// - The DNS validation token is surfaced in .status only; Kubernetes Event emission for +// the token is deferred (breadcrumb D5). Tenants read the token via +// `kubectl get frontdoorcustomdomain -o jsonpath='{.status.dnsValidationToken}'` +// and publish a TXT record at `_dnsauth.` to trigger AFD-side validation. +// - A validated FrontDoorCustomDomain only owns the AFD custom-domain resource; it does +// NOT front any traffic until a FrontDoorBackend route attaches it. Route attachment is +// Phase 4 work. +// +// Identity note (SFI-NS253): same caveat as the frontdoorprofile controller — this +// reconciler shares its Workload-Identity subject with the ATM controller under the POC +// wiring. See docs/first-party/003 §2.4 for the sibling binary+chart migration plan. +package frontdoorcustomdomain + +import ( + "context" + "fmt" + "time" + + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cdn/armcdn/v2" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/tools/record" + "k8s.io/klog/v2" + "k8s.io/utils/ptr" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/predicate" + + fleetnetv1alpha1 "go.goms.io/fleet-networking/api/v1alpha1" + "go.goms.io/fleet-networking/pkg/common/azureerrors" + "go.goms.io/fleet-networking/pkg/common/objectmeta" + frontdoorprofilectrl "go.goms.io/fleet-networking/pkg/controllers/hub/frontdoorprofile" +) + +const ( + // ControllerName is the name of the FrontDoorCustomDomain controller. + ControllerName = "frontdoorcustomdomain-controller" + + // AzureResourceCustomDomainNameFormat names the underlying Azure custom domain resource. + // AFD requires DNS-safe characters and forbids '.', so we use the CR UID. + AzureResourceCustomDomainNameFormat = "fleet-%s" + + // requeueWhileValidating is how often the controller re-polls Azure for validation state + // while the domain is Pending/Submitting. + requeueWhileValidating = 30 * time.Second + + // requeueOnProfileNotReady is how often the controller re-polls when the parent profile is + // not yet Programmed. + requeueOnProfileNotReady = 10 * time.Second + + eventReasonAzureAPIError = "AzureAPIError" + eventReasonProgrammed = "Programmed" + eventReasonDeleted = "Deleted" + eventReasonProfileNotReady = "ProfileNotReady" + eventReasonAwaitingDNSAuth = "AwaitingDNSValidation" + eventReasonUnsupportedTLS = "UnsupportedTLSMode" + eventReasonValidationFailed = "ValidationFailed" +) + +// Reconciler reconciles a FrontDoorCustomDomain object. +type Reconciler struct { + client.Client + + CustomDomainsClient *armcdn.AFDCustomDomainsClient + Recorder record.EventRecorder +} + +// AzureCustomDomainName returns the underlying Azure custom domain resource name. +func AzureCustomDomainName(d *fleetnetv1alpha1.FrontDoorCustomDomain) string { + return fmt.Sprintf(AzureResourceCustomDomainNameFormat, d.UID) +} + +//+kubebuilder:rbac:groups=networking.fleet.azure.com,resources=frontdoorcustomdomains,verbs=get;list;watch;create;update;patch;delete +//+kubebuilder:rbac:groups=networking.fleet.azure.com,resources=frontdoorcustomdomains/status,verbs=get;update;patch +//+kubebuilder:rbac:groups=networking.fleet.azure.com,resources=frontdoorcustomdomains/finalizers,verbs=get;update +//+kubebuilder:rbac:groups=networking.fleet.azure.com,resources=frontdoorprofiles,verbs=get;list;watch +//+kubebuilder:rbac:groups="",resources=events,verbs=create;patch + +// Reconcile drives one reconciliation for a FrontDoorCustomDomain. +// +// Flow: +// - Object not found → no-op. +// - DeletionTimestamp set → handleDelete (idempotent Azure delete + finalizer removal). +// - Otherwise → handleUpdate (BYOC guard → resolve parent profile → ensure Azure resource → +// reflect validation state into status; requeue while validation is pending). +func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + name := req.NamespacedName + kref := klog.KRef(name.Namespace, name.Name) + + startTime := time.Now() + klog.V(2).InfoS("Reconciliation starts", "frontDoorCustomDomain", kref) + defer func() { + klog.V(2).InfoS("Reconciliation ends", "frontDoorCustomDomain", kref, "latencyMs", time.Since(startTime).Milliseconds()) + }() + + cd := &fleetnetv1alpha1.FrontDoorCustomDomain{} + if err := r.Client.Get(ctx, name, cd); err != nil { + if apierrors.IsNotFound(err) { + klog.V(2).InfoS("Ignoring NotFound frontDoorCustomDomain", "frontDoorCustomDomain", kref) + return ctrl.Result{}, nil + } + return ctrl.Result{}, err + } + + if !cd.DeletionTimestamp.IsZero() { + return r.handleDelete(ctx, cd) + } + return r.handleUpdate(ctx, cd) +} + +// handleDelete cleans up the underlying Azure custom domain (if the parent profile still +// exists) and removes our finalizer. Not-found errors from Azure and Kubernetes are treated +// as success so the flow is idempotent. +func (r *Reconciler) handleDelete(ctx context.Context, cd *fleetnetv1alpha1.FrontDoorCustomDomain) (ctrl.Result, error) { + cdKObj := klog.KObj(cd) + + if !controllerutil.ContainsFinalizer(cd, objectmeta.FrontDoorCustomDomainFinalizer) { + return ctrl.Result{}, nil + } + + // We need the parent profile to know the Azure profile name. It may already be gone; if so + // there is nothing to delete on Azure's side. + profile, err := r.getProfile(ctx, cd) + if err != nil && !apierrors.IsNotFound(err) { + return ctrl.Result{}, err + } + + if profile != nil { + azProfileName := frontdoorprofilectrl.AzureProfileName(profile) + azDomainName := AzureCustomDomainName(cd) + klog.V(2).InfoS("Deleting Azure Front Door custom domain", + "frontDoorCustomDomain", cdKObj, "azureProfile", azProfileName, "azureCustomDomain", azDomainName) + + poller, dErr := r.CustomDomainsClient.BeginDelete(ctx, profile.Spec.ResourceGroup, azProfileName, azDomainName, nil) + if dErr != nil { + if !azureerrors.IsNotFound(dErr) { + r.Recorder.Eventf(cd, corev1.EventTypeWarning, eventReasonAzureAPIError, + "Failed to begin delete of AFD custom domain %s: %v", azDomainName, dErr) + return ctrl.Result{}, dErr + } + } else { + if _, pErr := poller.PollUntilDone(ctx, nil); pErr != nil { + if !azureerrors.IsNotFound(pErr) { + r.Recorder.Eventf(cd, corev1.EventTypeWarning, eventReasonAzureAPIError, + "Failed to delete AFD custom domain %s: %v", azDomainName, pErr) + return ctrl.Result{}, pErr + } + } + } + + r.Recorder.Eventf(cd, corev1.EventTypeNormal, eventReasonDeleted, + "Deleted Azure Front Door custom domain %s", azDomainName) + } + + controllerutil.RemoveFinalizer(cd, objectmeta.FrontDoorCustomDomainFinalizer) + if err := r.Client.Update(ctx, cd); err != nil { + klog.ErrorS(err, "Failed to remove frontDoorCustomDomain finalizer", "frontDoorCustomDomain", cdKObj) + return ctrl.Result{}, err + } + return ctrl.Result{}, nil +} + +// handleUpdate is the reconcile path for a live FrontDoorCustomDomain. Ordering is +// important: reject unsupported TLS modes BEFORE resolving the parent (so that a bad-spec +// object never gets a finalizer that only the AFD delete path can remove) and BEFORE +// touching Azure at all. +func (r *Reconciler) handleUpdate(ctx context.Context, cd *fleetnetv1alpha1.FrontDoorCustomDomain) (ctrl.Result, error) { + cdKObj := klog.KObj(cd) + + // POC guard (breadcrumb D3): reject BYOC with a permanent Invalid condition. + if cd.Spec.TLS.Mode == fleetnetv1alpha1.FrontDoorTLSModeBYOC { + r.Recorder.Eventf(cd, corev1.EventTypeWarning, eventReasonUnsupportedTLS, + "BYOC TLS mode is not implemented in the POC; only Managed is supported") + return r.writeStatus(ctx, cd, metav1.Condition{ + Type: string(fleetnetv1alpha1.FrontDoorCustomDomainConditionProgrammed), + Status: metav1.ConditionFalse, + ObservedGeneration: cd.Generation, + Reason: string(fleetnetv1alpha1.FrontDoorCustomDomainReasonInvalid), + Message: "BYOC TLS mode is not implemented in the POC; only Managed is supported", + }, ctrl.Result{}) + } + + // Resolve parent profile (same namespace; breadcrumb D4). + profile, err := r.getProfile(ctx, cd) + if err != nil { + if apierrors.IsNotFound(err) { + return r.writeStatus(ctx, cd, metav1.Condition{ + Type: string(fleetnetv1alpha1.FrontDoorCustomDomainConditionProgrammed), + Status: metav1.ConditionFalse, + ObservedGeneration: cd.Generation, + Reason: string(fleetnetv1alpha1.FrontDoorCustomDomainReasonProfileNotReady), + Message: fmt.Sprintf("Referenced FrontDoorProfile %q not found in namespace %q", cd.Spec.ProfileRef.Name, cd.Namespace), + }, ctrl.Result{RequeueAfter: requeueOnProfileNotReady}) + } + return ctrl.Result{}, err + } + + if !isProfileProgrammed(profile) { + r.Recorder.Eventf(cd, corev1.EventTypeWarning, eventReasonProfileNotReady, + "Waiting for FrontDoorProfile %s to become Programmed", profile.Name) + return r.writeStatus(ctx, cd, metav1.Condition{ + Type: string(fleetnetv1alpha1.FrontDoorCustomDomainConditionProgrammed), + Status: metav1.ConditionUnknown, + ObservedGeneration: cd.Generation, + Reason: string(fleetnetv1alpha1.FrontDoorCustomDomainReasonProfileNotReady), + Message: fmt.Sprintf("Referenced FrontDoorProfile %q is not yet Programmed", profile.Name), + }, ctrl.Result{RequeueAfter: requeueOnProfileNotReady}) + } + + // Register finalizer only just before we contact Azure (avoid stuck-delete on 403). + if !controllerutil.ContainsFinalizer(cd, objectmeta.FrontDoorCustomDomainFinalizer) { + controllerutil.AddFinalizer(cd, objectmeta.FrontDoorCustomDomainFinalizer) + if err := r.Update(ctx, cd); err != nil { + klog.ErrorS(err, "Failed to add finalizer to frontDoorCustomDomain", "frontDoorCustomDomain", cdKObj) + return ctrl.Result{}, err + } + } + + azProfileName := frontdoorprofilectrl.AzureProfileName(profile) + azDomainName := AzureCustomDomainName(cd) + rg := profile.Spec.ResourceGroup + + // Ensure the custom domain resource exists in AFD. + getRes, getErr := r.CustomDomainsClient.Get(ctx, rg, azProfileName, azDomainName, nil) + if getErr != nil { + if !azureerrors.IsNotFound(getErr) { + return r.reportAzureError(ctx, cd, "get custom domain", getErr) + } + desired := desiredAzureCustomDomain(cd) + poller, err := r.CustomDomainsClient.BeginCreate(ctx, rg, azProfileName, azDomainName, desired, nil) + if err != nil { + return r.reportAzureError(ctx, cd, "begin create custom domain", err) + } + res, err := poller.PollUntilDone(ctx, nil) + if err != nil { + return r.reportAzureError(ctx, cd, "create custom domain", err) + } + getRes.AFDDomain = res.AFDDomain + klog.V(2).InfoS("Created Azure Front Door custom domain", + "frontDoorCustomDomain", cdKObj, "azureCustomDomain", azDomainName) + } + + return r.reflectAzureStateToStatus(ctx, cd, getRes.AFDDomain, azDomainName) +} + +// reflectAzureStateToStatus copies validation + provisioning state from the AFD resource into +// the CR status. Returns Programmed=True only when validation is Approved. +func (r *Reconciler) reflectAzureStateToStatus(ctx context.Context, cd *fleetnetv1alpha1.FrontDoorCustomDomain, azDomain armcdn.AFDDomain, azDomainName string) (ctrl.Result, error) { + cd.Status.ResourceID = derefString(azDomain.ID) + + if azDomain.Properties != nil { + if azDomain.Properties.DomainValidationState != nil { + cd.Status.ValidationState = fleetnetv1alpha1.FrontDoorDomainValidationState(*azDomain.Properties.DomainValidationState) + } + if azDomain.Properties.ValidationProperties != nil { + cd.Status.DNSValidationToken = azDomain.Properties.ValidationProperties.ValidationToken + cd.Status.DNSValidationExpiry = parseExpiry(azDomain.Properties.ValidationProperties.ExpirationDate) + } + } + + cond, requeue := conditionFromValidationState(cd) + return r.writeStatus(ctx, cd, cond, ctrl.Result{RequeueAfter: requeue}) +} + +// conditionFromValidationState maps AFD validation state to the Programmed condition and a +// suggested requeue interval. +func conditionFromValidationState(cd *fleetnetv1alpha1.FrontDoorCustomDomain) (metav1.Condition, time.Duration) { + base := metav1.Condition{ + Type: string(fleetnetv1alpha1.FrontDoorCustomDomainConditionProgrammed), + ObservedGeneration: cd.Generation, + } + switch cd.Status.ValidationState { + case fleetnetv1alpha1.FrontDoorDomainValidationStateApproved: + base.Status = metav1.ConditionTrue + base.Reason = string(fleetnetv1alpha1.FrontDoorCustomDomainReasonProgrammed) + base.Message = "Custom domain validated and TLS bound (Managed)" + return base, 0 + case fleetnetv1alpha1.FrontDoorDomainValidationStateRejected, + fleetnetv1alpha1.FrontDoorDomainValidationStateTimedOut, + fleetnetv1alpha1.FrontDoorDomainValidationStateInternalError: + base.Status = metav1.ConditionFalse + base.Reason = string(fleetnetv1alpha1.FrontDoorCustomDomainReasonValidationFailed) + base.Message = fmt.Sprintf("Domain validation state: %s", cd.Status.ValidationState) + return base, 0 + default: + base.Status = metav1.ConditionUnknown + base.Reason = string(fleetnetv1alpha1.FrontDoorCustomDomainReasonAwaitingDNSValidation) + base.Message = fmt.Sprintf("Awaiting DNS validation (current state: %s). Create a TXT record at _dnsauth.%s with the value in status.dnsValidationToken.", cd.Status.ValidationState, cd.Spec.Hostname) + return base, requeueWhileValidating + } +} + +// writeStatus persists the condition and returns the caller-supplied ctrl.Result. +func (r *Reconciler) writeStatus(ctx context.Context, cd *fleetnetv1alpha1.FrontDoorCustomDomain, cond metav1.Condition, res ctrl.Result) (ctrl.Result, error) { + meta.SetStatusCondition(&cd.Status.Conditions, cond) + if err := r.Client.Status().Update(ctx, cd); err != nil { + klog.ErrorS(err, "Failed to update frontDoorCustomDomain status", "frontDoorCustomDomain", klog.KObj(cd)) + return ctrl.Result{}, err + } + return res, nil +} + +func (r *Reconciler) reportAzureError(ctx context.Context, cd *fleetnetv1alpha1.FrontDoorCustomDomain, op string, azErr error) (ctrl.Result, error) { + klog.ErrorS(azErr, "Azure Front Door custom domain operation failed", + "frontDoorCustomDomain", klog.KObj(cd), "operation", op) + r.Recorder.Eventf(cd, corev1.EventTypeWarning, eventReasonAzureAPIError, + "AFD custom domain %s failed: %v", op, azErr) + + status := metav1.ConditionUnknown + reason := fleetnetv1alpha1.FrontDoorCustomDomainReasonPending + if azureerrors.IsClientError(azErr) && !azureerrors.IsThrottled(azErr) { + status = metav1.ConditionFalse + if azureerrors.IsForbidden(azErr) { + reason = fleetnetv1alpha1.FrontDoorCustomDomainReasonInvalid + } else { + reason = fleetnetv1alpha1.FrontDoorCustomDomainReasonAzureError + } + } + cond := metav1.Condition{ + Type: string(fleetnetv1alpha1.FrontDoorCustomDomainConditionProgrammed), + Status: status, + ObservedGeneration: cd.Generation, + Reason: string(reason), + Message: fmt.Sprintf("AFD %s: %v", op, azErr), + } + if _, wErr := r.writeStatus(ctx, cd, cond, ctrl.Result{}); wErr != nil { + return ctrl.Result{}, wErr + } + return ctrl.Result{RequeueAfter: requeueWhileValidating}, azErr +} + +func (r *Reconciler) getProfile(ctx context.Context, cd *fleetnetv1alpha1.FrontDoorCustomDomain) (*fleetnetv1alpha1.FrontDoorProfile, error) { + profile := &fleetnetv1alpha1.FrontDoorProfile{} + err := r.Client.Get(ctx, types.NamespacedName{Namespace: cd.Namespace, Name: cd.Spec.ProfileRef.Name}, profile) + if err != nil { + return nil, err + } + return profile, nil +} + +func isProfileProgrammed(profile *fleetnetv1alpha1.FrontDoorProfile) bool { + cond := meta.FindStatusCondition(profile.Status.Conditions, string(fleetnetv1alpha1.FrontDoorProfileConditionProgrammed)) + return cond != nil && cond.Status == metav1.ConditionTrue +} + +// desiredAzureCustomDomain builds the armcdn payload for a create. TLS is pinned to +// AFD-managed certificate; BYOC is filtered out earlier in handleUpdate (breadcrumb D3). +func desiredAzureCustomDomain(cd *fleetnetv1alpha1.FrontDoorCustomDomain) armcdn.AFDDomain { + return armcdn.AFDDomain{ + Properties: &armcdn.AFDDomainProperties{ + HostName: ptr.To(cd.Spec.Hostname), + TLSSettings: &armcdn.AFDDomainHTTPSParameters{ + CertificateType: ptr.To(armcdn.AfdCertificateTypeManagedCertificate), + }, + }, + } +} + +func derefString(s *string) string { + if s == nil { + return "" + } + return *s +} + +// parseExpiry converts the Azure-supplied ExpirationDate (RFC3339) into a *metav1.Time. On +// parse failure it returns nil rather than surfacing an error, since expiry is advisory. +func parseExpiry(s *string) *metav1.Time { + if s == nil || *s == "" { + return nil + } + t, err := time.Parse(time.RFC3339, *s) + if err != nil { + return nil + } + mt := metav1.NewTime(t) + return &mt +} + +// SetupWithManager registers the controller with the manager. +func (r *Reconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + Named(ControllerName). + For(&fleetnetv1alpha1.FrontDoorCustomDomain{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})). + Complete(r) +} diff --git a/pkg/controllers/hub/frontdoorcustomdomain/controller_integration_test.go b/pkg/controllers/hub/frontdoorcustomdomain/controller_integration_test.go new file mode 100644 index 00000000..d5b4f37c --- /dev/null +++ b/pkg/controllers/hub/frontdoorcustomdomain/controller_integration_test.go @@ -0,0 +1,195 @@ +/* +Copyright (c) Microsoft Corporation. +Licensed under the MIT license. +*/ + +package frontdoorcustomdomain + +import ( + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + + fleetnetv1alpha1 "go.goms.io/fleet-networking/api/v1alpha1" + "go.goms.io/fleet-networking/pkg/common/objectmeta" + "go.goms.io/fleet-networking/test/common/azurefrontdoor/fakeprovider" +) + +// Minimal integration coverage for the FrontDoorCustomDomain controller. +// Covers the two most important POC-scope paths: +// - Happy path with a Programmed parent profile: create -> Approved -> +// Programmed=True with DNSValidationToken populated. (Requires the fake +// custom-domain client to return Approved validation state on create, +// which it does; see fakeprovider/customdomain.go for the rationale.) +// - BYOC guard: verify the POC's hard-reject of BYOC surfaces +// Programmed=False, Reason=Invalid WITHOUT calling Azure or adding a +// finalizer (D3 semantics). + +const ( + eventuallyTimeout = 30 * time.Second + eventuallyInterval = 250 * time.Millisecond +) + +// stampProfileProgrammed marks a FrontDoorProfile CR as Programmed=True in its +// status subresource. The customdomain controller waits for this condition +// before contacting Azure (see isProfileProgrammed), and the frontdoorprofile +// controller is NOT wired into this suite (that's covered by +// pkg/controllers/hub/frontdoorprofile). So we synthesize it here. +func stampProfileProgrammed(profile *fleetnetv1alpha1.FrontDoorProfile) { + profile.Status.EndpointHostname = nil + meta.SetStatusCondition(&profile.Status.Conditions, metav1.Condition{ + Type: string(fleetnetv1alpha1.FrontDoorProfileConditionProgrammed), + Status: metav1.ConditionTrue, + ObservedGeneration: profile.Generation, + Reason: string(fleetnetv1alpha1.FrontDoorProfileReasonProgrammed), + Message: "stamped by test", + }) + Expect(k8sClient.Status().Update(ctx, profile)).To(Succeed()) +} + +var _ = Describe("FrontDoorCustomDomain Controller Integration", func() { + Context("Happy path — parent Programmed, Managed TLS", func() { + const ( + profileName = "test-cd-happy-parent" + domainName = "test-cd-happy" + ) + + AfterEach(func() { + // Delete children first so their finalizers can run against the + // still-present parent profile (the customdomain reconciler + // resolves the parent during delete). + cd := &fleetnetv1alpha1.FrontDoorCustomDomain{} + cdKey := types.NamespacedName{Namespace: testNamespace, Name: domainName} + if err := k8sClient.Get(ctx, cdKey, cd); err == nil { + Expect(k8sClient.Delete(ctx, cd)).To(Succeed()) + } + Eventually(func() bool { + return k8sClient.Get(ctx, cdKey, &fleetnetv1alpha1.FrontDoorCustomDomain{}) != nil + }, eventuallyTimeout, eventuallyInterval).Should(BeTrue()) + + profile := &fleetnetv1alpha1.FrontDoorProfile{} + pKey := types.NamespacedName{Namespace: testNamespace, Name: profileName} + if err := k8sClient.Get(ctx, pKey, profile); err == nil { + // The profile has no reconciler in this suite, so it has no + // finalizer; a plain Delete removes it immediately. + Expect(k8sClient.Delete(ctx, profile)).To(Succeed()) + } + }) + + It("should program the AFD custom domain and expose the validation token", func() { + By("creating a Programmed parent FrontDoorProfile") + profile := &fleetnetv1alpha1.FrontDoorProfile{ + ObjectMeta: metav1.ObjectMeta{Name: profileName, Namespace: testNamespace}, + Spec: fleetnetv1alpha1.FrontDoorProfileSpec{ + ResourceGroup: fakeprovider.DefaultResourceGroupName, + Sku: fleetnetv1alpha1.FrontDoorProfileSkuPremium, + }, + } + Expect(k8sClient.Create(ctx, profile)).To(Succeed()) + stampProfileProgrammed(profile) + + By("creating a FrontDoorCustomDomain CR") + cd := &fleetnetv1alpha1.FrontDoorCustomDomain{ + ObjectMeta: metav1.ObjectMeta{Name: domainName, Namespace: testNamespace}, + Spec: fleetnetv1alpha1.FrontDoorCustomDomainSpec{ + ProfileRef: fleetnetv1alpha1.FrontDoorProfileReference{Name: profileName}, + Hostname: "www.contoso.com", + TLS: fleetnetv1alpha1.FrontDoorTLSConfig{Mode: fleetnetv1alpha1.FrontDoorTLSModeManaged}, + }, + } + Expect(k8sClient.Create(ctx, cd)).To(Succeed()) + + By("waiting for Programmed=True with populated status") + Eventually(func(g Gomega) { + got := &fleetnetv1alpha1.FrontDoorCustomDomain{} + g.Expect(k8sClient.Get(ctx, types.NamespacedName{Namespace: testNamespace, Name: domainName}, got)).To(Succeed()) + + cond := meta.FindStatusCondition(got.Status.Conditions, + string(fleetnetv1alpha1.FrontDoorCustomDomainConditionProgrammed)) + g.Expect(cond).NotTo(BeNil()) + g.Expect(cond.Status).To(Equal(metav1.ConditionTrue)) + g.Expect(cond.Reason).To(Equal(string(fleetnetv1alpha1.FrontDoorCustomDomainReasonProgrammed))) + g.Expect(cond.ObservedGeneration).To(Equal(got.Generation)) + + g.Expect(got.Status.ValidationState).To(Equal(fleetnetv1alpha1.FrontDoorDomainValidationStateApproved)) + g.Expect(got.Status.DNSValidationToken).NotTo(BeNil()) + g.Expect(*got.Status.DNSValidationToken).To(Equal(fakeprovider.FakeValidationToken)) + g.Expect(got.Status.ResourceID).NotTo(BeEmpty()) + g.Expect(got.Finalizers).To(ContainElement(objectmeta.FrontDoorCustomDomainFinalizer)) + }, eventuallyTimeout, eventuallyInterval).Should(Succeed()) + }) + }) + + Context("BYOC guard — POC hard-rejects BYOC mode", func() { + const ( + profileName = "test-cd-byoc-parent" + domainName = "test-cd-byoc" + ) + + AfterEach(func() { + cd := &fleetnetv1alpha1.FrontDoorCustomDomain{} + cdKey := types.NamespacedName{Namespace: testNamespace, Name: domainName} + if err := k8sClient.Get(ctx, cdKey, cd); err == nil { + Expect(k8sClient.Delete(ctx, cd)).To(Succeed()) + } + profile := &fleetnetv1alpha1.FrontDoorProfile{} + pKey := types.NamespacedName{Namespace: testNamespace, Name: profileName} + if err := k8sClient.Get(ctx, pKey, profile); err == nil { + Expect(k8sClient.Delete(ctx, profile)).To(Succeed()) + } + }) + + It("should surface Programmed=False, Reason=Invalid without adding a finalizer", func() { + // Parent still needs to exist because the CR is namespaced; the + // reconciler returns early on BYOC BEFORE resolving the parent + // (see handleUpdate ordering), so a Programmed parent is NOT + // required for this branch — a bare CR is enough to trigger it. + // We still create a parent so the spec matches the shape of the + // happy-path spec above. + Expect(k8sClient.Create(ctx, &fleetnetv1alpha1.FrontDoorProfile{ + ObjectMeta: metav1.ObjectMeta{Name: profileName, Namespace: testNamespace}, + Spec: fleetnetv1alpha1.FrontDoorProfileSpec{ + ResourceGroup: fakeprovider.DefaultResourceGroupName, + Sku: fleetnetv1alpha1.FrontDoorProfileSkuPremium, + }, + })).To(Succeed()) + + cd := &fleetnetv1alpha1.FrontDoorCustomDomain{ + ObjectMeta: metav1.ObjectMeta{Name: domainName, Namespace: testNamespace}, + Spec: fleetnetv1alpha1.FrontDoorCustomDomainSpec{ + ProfileRef: fleetnetv1alpha1.FrontDoorProfileReference{Name: profileName}, + Hostname: "www.contoso.com", + TLS: fleetnetv1alpha1.FrontDoorTLSConfig{ + Mode: fleetnetv1alpha1.FrontDoorTLSModeBYOC, + KeyVaultCertificate: &fleetnetv1alpha1.FrontDoorKeyVaultCertificate{ + VaultURI: "https://example.vault.azure.net", + CertificateName: "example-cert", + }, + }, + }, + } + Expect(k8sClient.Create(ctx, cd)).To(Succeed()) + + By("observing Programmed=False, Reason=Invalid with NO finalizer") + Eventually(func(g Gomega) { + got := &fleetnetv1alpha1.FrontDoorCustomDomain{} + g.Expect(k8sClient.Get(ctx, types.NamespacedName{Namespace: testNamespace, Name: domainName}, got)).To(Succeed()) + + cond := meta.FindStatusCondition(got.Status.Conditions, + string(fleetnetv1alpha1.FrontDoorCustomDomainConditionProgrammed)) + g.Expect(cond).NotTo(BeNil()) + g.Expect(cond.Status).To(Equal(metav1.ConditionFalse)) + g.Expect(cond.Reason).To(Equal(string(fleetnetv1alpha1.FrontDoorCustomDomainReasonInvalid))) + + // D3 semantics: BYOC rejection must NOT install a finalizer + // (otherwise the CR would be undeletable, since the delete + // path has no Azure resource to clean up). + g.Expect(got.Finalizers).NotTo(ContainElement(objectmeta.FrontDoorCustomDomainFinalizer)) + }, eventuallyTimeout, eventuallyInterval).Should(Succeed()) + }) + }) +}) diff --git a/pkg/controllers/hub/frontdoorcustomdomain/suite_test.go b/pkg/controllers/hub/frontdoorcustomdomain/suite_test.go new file mode 100644 index 00000000..950f0380 --- /dev/null +++ b/pkg/controllers/hub/frontdoorcustomdomain/suite_test.go @@ -0,0 +1,119 @@ +/* +Copyright (c) Microsoft Corporation. +Licensed under the MIT license. +*/ + +package frontdoorcustomdomain + +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" + "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/azurefrontdoor/fakeprovider" +) + +// Test-suite bootstrap for the FrontDoorCustomDomain controller. Same shape +// as the FrontDoorProfile suite: envtest for CRD-backed CRUD, real +// controller-runtime cache, fake armcdn CustomDomains client. Note the +// customdomain reconciler ALSO reads FrontDoorProfile (its parent), so the +// suite installs both CRDs and specs create a parent profile first. + +var ( + cfg *rest.Config + mgr manager.Manager + k8sClient client.Client + testEnv *envtest.Environment + ctx context.Context + cancel context.CancelFunc +) + +var testNamespace = fakeprovider.ProfileNamespace + +func TestAPIs(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "FrontDoorCustomDomain Controller Suite") +} + +var _ = BeforeSuite(func() { + logger := zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true)) + klog.SetLogger(logger) + log.SetLogger(logger) + + 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()) + + Expect(fleetnetv1alpha1.AddToScheme(scheme.Scheme)).To(Succeed()) + + By("constructing 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()) + + customDomainsClient, err := fakeprovider.NewCustomDomainClient() + Expect(err).To(Succeed(), "failed to create fake AFD custom domains client") + + Expect((&Reconciler{ + Client: mgr.GetClient(), + CustomDomainsClient: customDomainsClient, + Recorder: mgr.GetEventRecorderFor(ControllerName), + }).SetupWithManager(mgr)).To(Succeed()) + + By("creating the profile namespace") + ns := corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: testNamespace}} + Expect(k8sClient.Create(ctx, &ns)).To(Succeed()) + + go func() { + defer GinkgoRecover() + Expect(mgr.Start(ctx)).To(Succeed(), "failed to run manager") + }() +}) + +var _ = AfterSuite(func() { + defer klog.Flush() + + By("deleting the profile namespace") + ns := corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: testNamespace}} + Expect(k8sClient.Delete(ctx, &ns)).To(Succeed()) + + cancel() + By("tearing down the test environment") + Expect(testEnv.Stop()).To(Succeed()) +}) diff --git a/test/common/azurefrontdoor/fakeprovider/customdomain.go b/test/common/azurefrontdoor/fakeprovider/customdomain.go new file mode 100644 index 00000000..3c3133f4 --- /dev/null +++ b/test/common/azurefrontdoor/fakeprovider/customdomain.go @@ -0,0 +1,134 @@ +/* +Copyright (c) Microsoft Corporation. +Licensed under the MIT license. +*/ + +package fakeprovider + +import ( + "context" + "fmt" + "net/http" + "sync" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" + azcorefake "github.com/Azure/azure-sdk-for-go/sdk/azcore/fake" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cdn/armcdn/v2" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cdn/armcdn/v2/fake" + "k8s.io/utils/ptr" +) + +// CustomDomainResourceIDFormat is the ARM ID format for an AFD custom domain. +const CustomDomainResourceIDFormat = "/subscriptions/%s/resourceGroups/%s/providers/Microsoft.Cdn/profiles/%s/customDomains/%s" + +// FakeValidationToken is the DNS validation token returned on every create. +// It's a synthetic value — real AFD-issued tokens are longer opaque strings — +// but is sufficient for asserting that +// FrontDoorCustomDomainStatus.DNSValidationToken is populated from +// AFDDomainProperties.ValidationProperties. +const FakeValidationToken = "fleet-fake-dns-validation-token" //nolint:gosec // G101: synthetic value used by the fake AFD server to populate AFDDomainProperties.ValidationProperties.ValidationToken; not a real credential. + +// customDomainKey scopes state by (profile, domain) pair. +type customDomainKey struct { + profile, domain string +} + +type customDomainStore struct { + mu sync.Mutex + domains map[customDomainKey]armcdn.AFDDomain +} + +func newCustomDomainStore() *customDomainStore { + return &customDomainStore{domains: map[customDomainKey]armcdn.AFDDomain{}} +} + +// NewCustomDomainClient returns an armcdn.AFDCustomDomainsClient backed by an +// in-memory fake with its own independent state. +// +// Design note: this fake returns Approved validation state IMMEDIATELY on +// create. Real AFD returns Pending and only transitions to Approved after the +// customer publishes the DNS TXT record and AFD's asynchronous validator +// re-checks it. Testing the multi-step transition would require either +// non-terminal responses on the fake or explicit state-mutation hooks; the +// minimal scaffold here trades that fidelity for coverage of the happy-path +// status-population code in reflectAzureStateToStatus. A future test can +// override the beginCreate handler via a functional option to return Pending +// for one Get then Approved for the next, if pending-state coverage is +// needed. +func NewCustomDomainClient() (*armcdn.AFDCustomDomainsClient, error) { + store := newCustomDomainStore() + + srv := fake.AFDCustomDomainsServer{ + Get: store.get, + BeginCreate: store.beginCreate, + BeginDelete: store.beginDelete, + } + factory, err := armcdn.NewClientFactory(DefaultSubscriptionID, &azcorefake.TokenCredential{}, + &arm.ClientOptions{ + ClientOptions: azcore.ClientOptions{ + Transport: fake.NewAFDCustomDomainsServerTransport(&srv), + }, + }) + if err != nil { + return nil, err + } + return factory.NewAFDCustomDomainsClient(), nil +} + +func (s *customDomainStore) get(_ context.Context, resourceGroupName string, profileName string, customDomainName string, _ *armcdn.AFDCustomDomainsClientGetOptions) (resp azcorefake.Responder[armcdn.AFDCustomDomainsClientGetResponse], errResp azcorefake.ErrorResponder) { + if resourceGroupName != DefaultResourceGroupName { + errResp.SetResponseError(http.StatusForbidden, "AuthorizationFailed") + return resp, errResp + } + s.mu.Lock() + defer s.mu.Unlock() + + d, ok := s.domains[customDomainKey{profileName, customDomainName}] + if !ok { + errResp.SetResponseError(http.StatusNotFound, "NotFound") + return resp, errResp + } + resp.SetResponse(http.StatusOK, armcdn.AFDCustomDomainsClientGetResponse{AFDDomain: d}, nil) + return resp, errResp +} + +func (s *customDomainStore) beginCreate(_ context.Context, resourceGroupName string, profileName string, customDomainName string, parameters armcdn.AFDDomain, _ *armcdn.AFDCustomDomainsClientBeginCreateOptions) (resp azcorefake.PollerResponder[armcdn.AFDCustomDomainsClientCreateResponse], errResp azcorefake.ErrorResponder) { + if resourceGroupName != DefaultResourceGroupName { + errResp.SetResponseError(http.StatusForbidden, "AuthorizationFailed") + return resp, errResp + } + created := parameters + created.Name = ptr.To(customDomainName) + created.ID = ptr.To(fmt.Sprintf(CustomDomainResourceIDFormat, DefaultSubscriptionID, DefaultResourceGroupName, profileName, customDomainName)) + if created.Properties == nil { + created.Properties = &armcdn.AFDDomainProperties{} + } + // Force Approved so the reconciler's happy-path Programmed=True branch is + // exercised without needing a multi-step DNS-validation dance. + approved := armcdn.DomainValidationStateApproved + created.Properties.DomainValidationState = &approved + created.Properties.ValidationProperties = &armcdn.DomainValidationProperties{ + ValidationToken: ptr.To(FakeValidationToken), + } + + s.mu.Lock() + s.domains[customDomainKey{profileName, customDomainName}] = created + s.mu.Unlock() + + resp.SetTerminalResponse(http.StatusOK, armcdn.AFDCustomDomainsClientCreateResponse{AFDDomain: created}, nil) + return resp, errResp +} + +func (s *customDomainStore) beginDelete(_ context.Context, resourceGroupName string, profileName string, customDomainName string, _ *armcdn.AFDCustomDomainsClientBeginDeleteOptions) (resp azcorefake.PollerResponder[armcdn.AFDCustomDomainsClientDeleteResponse], errResp azcorefake.ErrorResponder) { + if resourceGroupName != DefaultResourceGroupName { + errResp.SetResponseError(http.StatusForbidden, "AuthorizationFailed") + return resp, errResp + } + s.mu.Lock() + delete(s.domains, customDomainKey{profileName, customDomainName}) + s.mu.Unlock() + + resp.SetTerminalResponse(http.StatusOK, armcdn.AFDCustomDomainsClientDeleteResponse{}, nil) + return resp, errResp +} diff --git a/test/common/azurefrontdoor/fakeprovider/endpoint.go b/test/common/azurefrontdoor/fakeprovider/endpoint.go new file mode 100644 index 00000000..ae6b21fd --- /dev/null +++ b/test/common/azurefrontdoor/fakeprovider/endpoint.go @@ -0,0 +1,124 @@ +/* +Copyright (c) Microsoft Corporation. +Licensed under the MIT license. +*/ + +package fakeprovider + +import ( + "context" + "fmt" + "net/http" + "sync" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" + azcorefake "github.com/Azure/azure-sdk-for-go/sdk/azcore/fake" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cdn/armcdn/v2" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cdn/armcdn/v2/fake" + "k8s.io/utils/ptr" +) + +// EndpointHostnameFormat is the *.azurefd.net hostname format the fake assigns +// on create. The reconciler copies this into +// FrontDoorProfileStatus.EndpointHostname, so specs can assert against it. +const EndpointHostnameFormat = "%s.z01.azurefd.net" + +// EndpointResourceIDFormat is the ARM ID format for an AFD endpoint under a +// profile. The fake populates .ID on create so the reconciler's WAF-attach +// path (which references the endpoint by ARM ID in a SecurityPolicy +// Association) sees a non-nil ID; a nil ID would silently produce an empty +// Association and mask bugs. Format matches what real AFD returns. +const EndpointResourceIDFormat = "/subscriptions/%s/resourceGroups/%s/providers/Microsoft.Cdn/profiles/%s/afdEndpoints/%s" + +// endpointKey scopes state by (profile, endpoint) pair so a single fake client +// can host endpoints under multiple profiles simultaneously (some tests may +// create sibling profiles). +type endpointKey struct { + profile, endpoint string +} + +type endpointStore struct { + mu sync.Mutex + endpoints map[endpointKey]armcdn.AFDEndpoint +} + +func newEndpointStore() *endpointStore { + return &endpointStore{endpoints: map[endpointKey]armcdn.AFDEndpoint{}} +} + +// NewAFDEndpointClient returns an armcdn.AFDEndpointsClient backed by an +// in-memory fake with its own independent state. +func NewAFDEndpointClient() (*armcdn.AFDEndpointsClient, error) { + store := newEndpointStore() + + srv := fake.AFDEndpointsServer{ + Get: store.get, + BeginCreate: store.beginCreate, + BeginDelete: store.beginDelete, + } + factory, err := armcdn.NewClientFactory(DefaultSubscriptionID, &azcorefake.TokenCredential{}, + &arm.ClientOptions{ + ClientOptions: azcore.ClientOptions{ + Transport: fake.NewAFDEndpointsServerTransport(&srv), + }, + }) + if err != nil { + return nil, err + } + return factory.NewAFDEndpointsClient(), nil +} + +func (s *endpointStore) get(_ context.Context, resourceGroupName string, profileName string, endpointName string, _ *armcdn.AFDEndpointsClientGetOptions) (resp azcorefake.Responder[armcdn.AFDEndpointsClientGetResponse], errResp azcorefake.ErrorResponder) { + if resourceGroupName != DefaultResourceGroupName { + errResp.SetResponseError(http.StatusForbidden, "AuthorizationFailed") + return resp, errResp + } + s.mu.Lock() + defer s.mu.Unlock() + + ep, ok := s.endpoints[endpointKey{profileName, endpointName}] + if !ok { + errResp.SetResponseError(http.StatusNotFound, "NotFound") + return resp, errResp + } + resp.SetResponse(http.StatusOK, armcdn.AFDEndpointsClientGetResponse{AFDEndpoint: ep}, nil) + return resp, errResp +} + +func (s *endpointStore) beginCreate(_ context.Context, resourceGroupName string, profileName string, endpointName string, parameters armcdn.AFDEndpoint, _ *armcdn.AFDEndpointsClientBeginCreateOptions) (resp azcorefake.PollerResponder[armcdn.AFDEndpointsClientCreateResponse], errResp azcorefake.ErrorResponder) { + if resourceGroupName != DefaultResourceGroupName { + errResp.SetResponseError(http.StatusForbidden, "AuthorizationFailed") + return resp, errResp + } + // Assign a stable synthetic hostname. Reconciler copies HostName into + // status.endpointHostname; a nil HostName here would leave that field + // empty and mask bugs in the status-population path. + created := parameters + created.Name = ptr.To(endpointName) + created.ID = ptr.To(fmt.Sprintf(EndpointResourceIDFormat, DefaultSubscriptionID, DefaultResourceGroupName, profileName, endpointName)) + if created.Properties == nil { + created.Properties = &armcdn.AFDEndpointProperties{} + } + created.Properties.HostName = ptr.To(fmt.Sprintf(EndpointHostnameFormat, endpointName)) + + s.mu.Lock() + s.endpoints[endpointKey{profileName, endpointName}] = created + s.mu.Unlock() + + resp.SetTerminalResponse(http.StatusOK, armcdn.AFDEndpointsClientCreateResponse{AFDEndpoint: created}, nil) + return resp, errResp +} + +func (s *endpointStore) beginDelete(_ context.Context, resourceGroupName string, profileName string, endpointName string, _ *armcdn.AFDEndpointsClientBeginDeleteOptions) (resp azcorefake.PollerResponder[armcdn.AFDEndpointsClientDeleteResponse], errResp azcorefake.ErrorResponder) { + if resourceGroupName != DefaultResourceGroupName { + errResp.SetResponseError(http.StatusForbidden, "AuthorizationFailed") + return resp, errResp + } + s.mu.Lock() + delete(s.endpoints, endpointKey{profileName, endpointName}) + s.mu.Unlock() + + resp.SetTerminalResponse(http.StatusOK, armcdn.AFDEndpointsClientDeleteResponse{}, nil) + return resp, errResp +}