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 +}