From 66f7c49c0c0a3fba2dcd203605f64822643c2dcd Mon Sep 17 00:00:00 2001 From: Scot Wells Date: Fri, 2 Oct 2026 16:44:47 -0500 Subject: [PATCH] feat: reserve the subtree under a wildcard claim The edge prefers an exact hostname over a wildcard, so if another project could claim bucket.s3.example.com under someone's *.s3.example.com it would take that traffic. Hostname claims now cover subtrees behind the certificate service flag: a wildcard reserves every name beneath it, at any depth, for its project. Key changes: - refuse a claim beneath another project's wildcard, and a wildcard while another project holds a name beneath it; the refusal names the hostnames, never the project, and says reclaiming is not supported yet - name wildcard claims from a hash of their base, with the hostname in their data, since a ConfigMap name cannot hold "*"; the name has no dot so it never meets a custom hostname's claim - find wildcards above a name with one cached GET per parent domain, and names beneath a wildcard with a cache index on every parent domain, so no lookup scans the claim namespace - re-check held claims every reconcile and keep the older of two that raced, so a cache-lag race settles the same way on both sides - carry the refusal onto the hostname's Available condition --- .../gateway_certificate_service_test.go | 8 +- internal/controller/gateway_controller.go | 62 +++- .../controller/gateway_controller_test.go | 4 +- internal/controller/hostname_claims.go | 162 ++++++++++ internal/controller/hostname_claims_test.go | 282 ++++++++++++++++++ internal/controller/httpproxy_controller.go | 23 ++ 6 files changed, 524 insertions(+), 17 deletions(-) create mode 100644 internal/controller/hostname_claims.go create mode 100644 internal/controller/hostname_claims_test.go diff --git a/internal/controller/gateway_certificate_service_test.go b/internal/controller/gateway_certificate_service_test.go index 9eaba195..ca77147c 100644 --- a/internal/controller/gateway_certificate_service_test.go +++ b/internal/controller/gateway_certificate_service_test.go @@ -638,6 +638,7 @@ func TestEnsureDownstreamGatewayCertificateService(t *testing.T) { fakeDownstreamClient := fake.NewClientBuilder(). WithScheme(testScheme). + WithIndex(&corev1.ConfigMap{}, hostnameClaimAncestorIndex, hostnameClaimAncestorIndexFunc("default")). WithObjects(downstreamObjects...). WithStatusSubresource(&gatewayv1.Gateway{}, &cmv1.Certificate{}). Build() @@ -872,6 +873,7 @@ func TestCertificateServiceLeavesExactHostnamesOnCertManager(t *testing.T) { } } downstream := fake.NewClientBuilder().WithScheme(testScheme). + WithIndex(&corev1.ConfigMap{}, hostnameClaimAncestorIndex, hostnameClaimAncestorIndexFunc("default")). WithStatusSubresource(&gatewayv1.Gateway{}, &cmv1.Certificate{}). WithInterceptorFuncs(interceptor.Funcs{ Create: func(ctx context.Context, cl client.WithWatch, obj client.Object, opts ...client.CreateOption) error { @@ -990,7 +992,7 @@ func TestCertificateServiceOneFailingListenerDoesNotDelayOthers(t *testing.T) { return cl.Create(ctx, obj, opts...) }, }).Build() - downstream := fake.NewClientBuilder().WithScheme(testScheme).WithStatusSubresource(&gatewayv1.Gateway{}).Build() + downstream := fake.NewClientBuilder().WithScheme(testScheme).WithIndex(&corev1.ConfigMap{}, hostnameClaimAncestorIndex, hostnameClaimAncestorIndexFunc("default")).WithStatusSubresource(&gatewayv1.Gateway{}).Build() issuedCrt, issuedKey := ca.issue(t, healthy, now.Add(-time.Hour), now.Add(60*24*time.Hour)) service := fake.NewClientBuilder().WithScheme(testScheme).WithObjects(&corev1.Secret{ ObjectMeta: metav1.ObjectMeta{Namespace: serviceNS, Name: "issued"}, @@ -1188,7 +1190,7 @@ func TestCertificateServiceCRDAbsentDoesNotBlockGateway(t *testing.T) { fakeUpstreamClient := fake.NewClientBuilder().WithScheme(withoutCertificates). WithObjects(upstreamGateway, upstreamNamespace, domain, gatewayClass, route). WithStatusSubresource(upstreamGateway, route).Build() - fakeDownstreamClient := fake.NewClientBuilder().WithScheme(downstreamScheme).WithStatusSubresource(&gatewayv1.Gateway{}).Build() + fakeDownstreamClient := fake.NewClientBuilder().WithScheme(downstreamScheme).WithIndex(&corev1.ConfigMap{}, hostnameClaimAncestorIndex, hostnameClaimAncestorIndexFunc("default")).WithStatusSubresource(&gatewayv1.Gateway{}).Build() reconciler := &GatewayReconciler{ mgr: &fakeMockManager{cl: fakeUpstreamClient}, @@ -1266,7 +1268,7 @@ func TestCertificateServiceRenewalBlockedClearsOnRecovery(t *testing.T) { fakeUpstreamClient := fake.NewClientBuilder().WithScheme(testScheme). WithObjects(upstreamGateway, upstreamNamespace, domain, gatewayClass, cert). WithStatusSubresource(upstreamGateway, cert).Build() - fakeDownstreamClient := fake.NewClientBuilder().WithScheme(testScheme).WithObjects(serving).WithStatusSubresource(&gatewayv1.Gateway{}).Build() + fakeDownstreamClient := fake.NewClientBuilder().WithScheme(testScheme).WithObjects(serving).WithIndex(&corev1.ConfigMap{}, hostnameClaimAncestorIndex, hostnameClaimAncestorIndexFunc("default")).WithStatusSubresource(&gatewayv1.Gateway{}).Build() forbidden := true serviceClient := fake.NewClientBuilder().WithScheme(testScheme).WithObjects(issued).WithInterceptorFuncs(interceptor.Funcs{ diff --git a/internal/controller/gateway_controller.go b/internal/controller/gateway_controller.go index 1d92fc2d..98cc0d90 100644 --- a/internal/controller/gateway_controller.go +++ b/internal/controller/gateway_controller.go @@ -1414,7 +1414,7 @@ func (r *GatewayReconciler) ensureHostnamesClaimed( upstreamClient client.Client, upstreamGateway *gatewayv1.Gateway, downstreamGateway *gatewayv1.Gateway, -) (verifiedHostnames, claimedHostnames, notClaimedHostnames []string, err error) { +) (verifiedHostnames, claimedHostnames []string, notClaimedHostnames map[string]string, err error) { verifiedHostnames, err = r.ensureHostnameVerification(ctx, upstreamClient, upstreamGateway, downstreamGateway) if err != nil { @@ -1424,6 +1424,8 @@ func (r *GatewayReconciler) ensureHostnamesClaimed( downstreamClient := r.DownstreamCluster.GetClient() upstreamGatewayReferenceName := fmt.Sprintf("%s/%s/%s", upstreamClusterName, upstreamGateway.Namespace, upstreamGateway.Name) + project := hostnameClaimProject(upstreamClusterName) + notClaimedHostnames = map[string]string{} // Track each hostname in a ConfigMap in the downstream control plane. // This will need to be adjusted as the number of hostnames grows to be large, @@ -1438,7 +1440,7 @@ func (r *GatewayReconciler) ensureHostnamesClaimed( objectKey := client.ObjectKey{ Namespace: r.Config.Gateway.DownstreamHostnameAccountingNamespace, - Name: hostname, + Name: hostnameClaimName(hostname), } var hostnameConfigMap corev1.ConfigMap @@ -1446,13 +1448,34 @@ func (r *GatewayReconciler) ensureHostnamesClaimed( return nil, nil, nil, err } - if hostnameConfigMap.CreationTimestamp.IsZero() { + claimExists := !hostnameConfigMap.CreationTimestamp.IsZero() + if claimExists && hostnameConfigMap.Data[jsonKeyOwner] != upstreamGatewayReferenceName { + notClaimedHostnames[hostname] = hostnameInUseMessage(hostname) + continue + } + + if r.Config.Gateway.CertificateService.Enabled { + var existing *corev1.ConfigMap + if claimExists { + existing = &hostnameConfigMap + } + conflict, err := subtreeClaimConflicts(ctx, downstreamClient, objectKey.Namespace, project, hostname, existing) + if err != nil { + return nil, nil, nil, err + } + if conflict.found() { + notClaimedHostnames[hostname] = subtreeConflictMessage(hostname, conflict) + continue + } + } + + if !claimExists { hostnameConfigMap = corev1.ConfigMap{ ObjectMeta: metav1.ObjectMeta{ Namespace: objectKey.Namespace, Name: objectKey.Name, Labels: map[string]string{ - downstreamclient.UpstreamOwnerClusterNameLabel: fmt.Sprintf("cluster-%s", strings.ReplaceAll(upstreamClusterName, "/", "_")), + downstreamclient.UpstreamOwnerClusterNameLabel: project, downstreamclient.UpstreamOwnerNamespaceLabel: upstreamGateway.Namespace, downstreamclient.UpstreamOwnerNameLabel: upstreamGateway.Name, }, @@ -1461,17 +1484,17 @@ func (r *GatewayReconciler) ensureHostnamesClaimed( jsonKeyOwner: upstreamGatewayReferenceName, }, } + if objectKey.Name != hostname { + hostnameConfigMap.Data[jsonKeyHostname] = hostname + } if err := downstreamClient.Create(ctx, &hostnameConfigMap); err != nil { if apierrors.IsConflict(err) { - notClaimedHostnames = append(notClaimedHostnames, hostname) + notClaimedHostnames[hostname] = hostnameInUseMessage(hostname) continue } return nil, nil, nil, err } - } else if hostnameConfigMap.Data[jsonKeyOwner] != upstreamGatewayReferenceName { - notClaimedHostnames = append(notClaimedHostnames, hostname) - continue } claimedHostnames = append(claimedHostnames, hostname) @@ -1496,7 +1519,7 @@ func (r *GatewayReconciler) ensureHostnamesClaimed( if len(hostnameConfigMapList.Items) > 0 { for _, configMap := range hostnameConfigMapList.Items { - if slices.Contains(claimedHostnames, configMap.Name) { + if slices.Contains(claimedHostnames, claimedHostname(&configMap)) { // Still in use continue } @@ -1511,6 +1534,10 @@ func (r *GatewayReconciler) ensureHostnamesClaimed( return verifiedHostnames, claimedHostnames, notClaimedHostnames, nil } +func hostnameInUseMessage(hostname string) string { + return fmt.Sprintf("The hostname %q is already attached to a resource.", hostname) +} + func (r *GatewayReconciler) isDatumManagedGatewayHostname(upstreamGateway *gatewayv1.Gateway, hostname string) bool { gatewayUID := string(upstreamGateway.UID) legacyUIDWithoutDashes := strings.ReplaceAll(gatewayUID, "-", "") @@ -2025,7 +2052,7 @@ func (r *GatewayReconciler) ensureDownstreamGatewayHTTPRoutes( downstreamGateway *gatewayv1.Gateway, downstreamStrategy downstreamclient.ResourceStrategy, verifiedHostnames []string, - notClaimedHostnames []string, + notClaimedHostnames map[string]string, listenerCertHealth map[gatewayv1.SectionName]listenerCertStatus, ) (result Result) { logger := log.FromContext(ctx) @@ -2184,11 +2211,11 @@ func (r *GatewayReconciler) ensureDownstreamGatewayHTTPRoutes( programmedCondition.Status = metav1.ConditionFalse programmedCondition.Reason = acceptedCondition.Reason programmedCondition.Message = acceptedCondition.Message - } else if slices.Contains(notClaimedHostnames, string(*listener.Hostname)) { + } else if message, refused := notClaimedHostnames[string(*listener.Hostname)]; refused { hostnameProblem = true acceptedCondition.Status = metav1.ConditionFalse acceptedCondition.Reason = networkingv1alpha.HostnameInUseReason - acceptedCondition.Message = fmt.Sprintf("The hostname %q is already attached to a resource.", *listener.Hostname) + acceptedCondition.Message = message programmedCondition.Status = metav1.ConditionFalse programmedCondition.Reason = acceptedCondition.Reason @@ -3047,6 +3074,17 @@ func (r *GatewayReconciler) passThroughVPCPodBackendRef( func (r *GatewayReconciler) SetupWithManager(mgr mcmanager.Manager) error { r.mgr = mgr + if r.Config.Gateway.CertificateService.Enabled { + if err := r.DownstreamCluster.GetFieldIndexer().IndexField( + context.Background(), + &corev1.ConfigMap{}, + hostnameClaimAncestorIndex, + hostnameClaimAncestorIndexFunc(r.Config.Gateway.DownstreamHostnameAccountingNamespace), + ); err != nil { + return fmt.Errorf("failed to index hostname claims: %w", err) + } + } + downstreamGatewaySource := mcsource.TypedKind( &gatewayv1.Gateway{}, downstreamclient.TypedEnqueueRequestForUpstreamOwner[*gatewayv1.Gateway](&gatewayv1.Gateway{}), diff --git a/internal/controller/gateway_controller_test.go b/internal/controller/gateway_controller_test.go index c418ebb7..f75bcafc 100644 --- a/internal/controller/gateway_controller_test.go +++ b/internal/controller/gateway_controller_test.go @@ -1767,7 +1767,7 @@ func TestEnsureHostnamesClaimed(t *testing.T) { slices.Sort(expectedClaimedHostnames) assert.EqualValues(t, expectedVerifiedHostnames, verifiedHostnames, "expected verified hostnames mismatch") assert.EqualValues(t, expectedClaimedHostnames, claimedHostnames, "expected claimed hostnames mistmatch") - assert.EqualValues(t, tt.expectedNotClaimedHostnames, notClaimedHostnames, "expected not claimed hostnames mismatch") + assert.EqualValues(t, tt.expectedNotClaimedHostnames, refusedHostnames(notClaimedHostnames), "expected not claimed hostnames mismatch") } updatedUpstreamGateway := &gatewayv1.Gateway{} @@ -2962,7 +2962,7 @@ func TestEnsureHostnamesClaimed_LegacyTargetDomain(t *testing.T) { for _, hostname := range tt.expectedClaimedHostnames { assert.Contains(t, claimedHostnames, hostname) } - assert.EqualValues(t, tt.expectedNotClaimedHostnames, notClaimedHostnames) + assert.EqualValues(t, tt.expectedNotClaimedHostnames, refusedHostnames(notClaimedHostnames)) }) } } diff --git a/internal/controller/hostname_claims.go b/internal/controller/hostname_claims.go new file mode 100644 index 00000000..c72b152b --- /dev/null +++ b/internal/controller/hostname_claims.go @@ -0,0 +1,162 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package controller + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "slices" + "strings" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "sigs.k8s.io/controller-runtime/pkg/client" + + downstreamclient "go.datum.net/network-services-operator/internal/downstreamclient" +) + +// hostnameClaimAncestorIndex indexes each hostname claim under every domain it +// sits beneath, so the claims a wildcard would cover are one indexed lookup +// rather than a scan of every claim. +const hostnameClaimAncestorIndex = "hostnameClaim.ancestors" + +const jsonKeyHostname = "hostname" + +const wildcardClaimPrefix = "wildcard-" + +const maxNamedClaimConflicts = 5 + +// hostnameClaimName names the ConfigMap that claims a hostname. A wildcard +// cannot be a ConfigMap name, so its claim is named from a hash of its base; +// the name has no dot, so no custom hostname's claim can ever take it. +func hostnameClaimName(hostname string) string { + base, ok := strings.CutPrefix(hostname, "*.") + if !ok { + return hostname + } + sum := sha256.Sum256([]byte(base)) + return wildcardClaimPrefix + hex.EncodeToString(sum[:])[:40] +} + +func claimedHostname(claim *corev1.ConfigMap) string { + if hostname := claim.Data[jsonKeyHostname]; hostname != "" { + return hostname + } + return claim.Name +} + +func hostnameClaimProject(upstreamClusterName string) string { + return fmt.Sprintf("cluster-%s", strings.ReplaceAll(upstreamClusterName, "/", "_")) +} + +// hostnameAncestors returns every domain strictly above a hostname, nearest +// first: "a.b.example.com" gives "b.example.com", "example.com" and "com". +func hostnameAncestors(hostname string) []string { + var ancestors []string + for rest := hostname; ; { + _, parent, found := strings.Cut(rest, ".") + if !found || parent == "" { + return ancestors + } + ancestors = append(ancestors, parent) + rest = parent + } +} + +func hostnameClaimAncestorIndexFunc(namespace string) client.IndexerFunc { + return func(obj client.Object) []string { + claim, ok := obj.(*corev1.ConfigMap) + if !ok || claim.Namespace != namespace || claim.Data[jsonKeyOwner] == "" { + return nil + } + return hostnameAncestors(claimedHostname(claim)) + } +} + +// claimPrecedes reports whether claim a was made before claim b. Two claims +// made in the same second are ordered by name, so both sides agree. +func claimPrecedes(a, b *corev1.ConfigMap) bool { + if !a.CreationTimestamp.Equal(&b.CreationTimestamp) { + return a.CreationTimestamp.Before(&b.CreationTimestamp) + } + return a.Name < b.Name +} + +// subtreeClaimConflict lists the hostnames other projects hold that a claim +// would overlap: wildcards above it, and for a wildcard, names beneath it. +type subtreeClaimConflict struct { + above []string + beneath []string +} + +func (c subtreeClaimConflict) found() bool { + return len(c.above) > 0 || len(c.beneath) > 0 +} + +// subtreeClaimConflicts finds what a claim on hostname would overlap. The edge +// prefers an exact match over a wildcard, so a wildcard reserves every name +// beneath it at any depth. When the claim already exists, only claims made +// before it count, so two claims that raced settle on the older one. +func subtreeClaimConflicts( + ctx context.Context, + reader client.Reader, + namespace, project, hostname string, + existing *corev1.ConfigMap, +) (subtreeClaimConflict, error) { + var conflict subtreeClaimConflict + counts := func(other *corev1.ConfigMap) bool { + if other.Labels[downstreamclient.UpstreamOwnerClusterNameLabel] == project { + return false + } + return existing == nil || claimPrecedes(other, existing) + } + + base, wildcard := strings.CutPrefix(hostname, "*.") + for _, ancestor := range hostnameAncestors(base) { + var above corev1.ConfigMap + err := reader.Get(ctx, client.ObjectKey{Namespace: namespace, Name: hostnameClaimName("*." + ancestor)}, &above) + if apierrors.IsNotFound(err) { + continue + } + if err != nil { + return conflict, fmt.Errorf("failed to look up wildcard claim above %s: %w", hostname, err) + } + if counts(&above) { + conflict.above = append(conflict.above, claimedHostname(&above)) + } + } + + if wildcard { + var beneath corev1.ConfigMapList + if err := reader.List(ctx, &beneath, client.InNamespace(namespace), client.MatchingFields{hostnameClaimAncestorIndex: base}); err != nil { + return conflict, fmt.Errorf("failed to list claims beneath %s: %w", hostname, err) + } + for i := range beneath.Items { + if counts(&beneath.Items[i]) { + conflict.beneath = append(conflict.beneath, claimedHostname(&beneath.Items[i])) + } + } + slices.Sort(conflict.beneath) + } + + return conflict, nil +} + +// subtreeConflictMessage explains a refused claim. It names hostnames under a +// domain the requester has proven it owns, and never the project holding them. +func subtreeConflictMessage(hostname string, conflict subtreeClaimConflict) string { + const noOverride = "Taking names back from another project is not supported yet" + if len(conflict.above) > 0 { + return fmt.Sprintf("The hostname %q is beneath the wildcard %q, which another project has claimed. %s; that project must remove its wildcard first.", + hostname, conflict.above[0], noOverride) + } + named, more := conflict.beneath, "" + if len(named) > maxNamedClaimConflicts { + more = fmt.Sprintf(" and %d more", len(named)-maxNamedClaimConflicts) + named = named[:maxNamedClaimConflicts] + } + return fmt.Sprintf("The wildcard %q cannot be claimed while another project serves names beneath it: %s%s. %s; that project must remove them first.", + hostname, strings.Join(named, ", "), more, noOverride) +} diff --git a/internal/controller/hostname_claims_test.go b/internal/controller/hostname_claims_test.go new file mode 100644 index 00000000..0c77f28b --- /dev/null +++ b/internal/controller/hostname_claims_test.go @@ -0,0 +1,282 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package controller + +import ( + "context" + "fmt" + "slices" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/util/sets" + "k8s.io/client-go/kubernetes/scheme" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" + + networkingv1alpha "go.datum.net/network-services-operator/api/v1alpha" + "go.datum.net/network-services-operator/internal/config" + downstreamclient "go.datum.net/network-services-operator/internal/downstreamclient" +) + +const claimsNamespace = "hostname-claims" + +func TestHostnameClaimName(t *testing.T) { + t.Parallel() + + assert.Equal(t, "app.example.com", hostnameClaimName("app.example.com")) + + wildcard := hostnameClaimName("*.s3.example.com") + assert.Regexp(t, `^wildcard-[0-9a-f]{40}$`, wildcard) + assert.NotContains(t, wildcard, ".", "a wildcard claim name must never equal a custom hostname") + assert.NotEqual(t, wildcard, hostnameClaimName("*.s4.example.com")) +} + +func TestHostnameAncestors(t *testing.T) { + t.Parallel() + + assert.Equal(t, []string{"b.example.com", "example.com", "com"}, hostnameAncestors("a.b.example.com")) + assert.Equal(t, []string{"s3.example.com", "example.com", "com"}, hostnameAncestors("*.s3.example.com")) + assert.Empty(t, hostnameAncestors("com")) +} + +func claimFor(project, hostname string, created time.Time) *corev1.ConfigMap { + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: claimsNamespace, + Name: hostnameClaimName(hostname), + CreationTimestamp: metav1.NewTime(created), + Labels: map[string]string{ + downstreamclient.UpstreamOwnerClusterNameLabel: hostnameClaimProject(project), + downstreamclient.UpstreamOwnerNamespaceLabel: "default", + downstreamclient.UpstreamOwnerNameLabel: "gw", + }, + }, + Data: map[string]string{jsonKeyOwner: project + "/default/gw"}, + } + if cm.Name != hostname { + cm.Data[jsonKeyHostname] = hostname + } + return cm +} + +func claimsTestScheme(t *testing.T) *runtime.Scheme { + t.Helper() + s := runtime.NewScheme() + require.NoError(t, scheme.AddToScheme(s)) + require.NoError(t, gatewayv1.Install(s)) + require.NoError(t, networkingv1alpha.AddToScheme(s)) + return s +} + +func claimsDownstream(t *testing.T, objects ...client.Object) client.Client { + t.Helper() + return fake.NewClientBuilder(). + WithScheme(claimsTestScheme(t)). + WithObjects(objects...). + WithIndex(&corev1.ConfigMap{}, hostnameClaimAncestorIndex, hostnameClaimAncestorIndexFunc(claimsNamespace)). + Build() +} + +func claimingGateway(hostnames ...string) *gatewayv1.Gateway { + gw := &gatewayv1.Gateway{ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: "gw", UID: "gw-uid"}} + for i, h := range hostnames { + gw.Spec.Listeners = append(gw.Spec.Listeners, gatewayv1.Listener{ + Name: gatewayv1.SectionName(fmt.Sprintf("http-hostname-%d", i)), + Protocol: gatewayv1.HTTPProtocolType, + Hostname: ptr.To(gatewayv1.Hostname(h)), + }) + } + return gw +} + +func claimHostnames(t *testing.T, enabled bool, project string, downstream client.Client, hostnames ...string) (claimed []string, refused map[string]string) { + t.Helper() + + upstream := fake.NewClientBuilder().WithScheme(claimsTestScheme(t)).WithObjects( + &networkingv1alpha.Domain{ + ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: "example.com"}, + Spec: networkingv1alpha.DomainSpec{DomainName: "example.com"}, + Status: networkingv1alpha.DomainStatus{Conditions: []metav1.Condition{{ + Type: networkingv1alpha.DomainConditionVerified, Status: metav1.ConditionTrue, Reason: networkingv1alpha.DomainReasonVerified, + }}}, + }, + ).Build() + + r := &GatewayReconciler{ + Config: config.NetworkServicesOperator{Gateway: config.GatewayConfig{ + DownstreamHostnameAccountingNamespace: claimsNamespace, + TargetDomain: "datumproxy.net", + CertificateService: config.CertificateServiceConfig{Enabled: enabled}, + }}, + DownstreamCluster: &fakeCluster{cl: downstream}, + } + + gw := claimingGateway(hostnames...) + _, all, refused, err := r.ensureHostnamesClaimed(context.Background(), project, upstream, gw, &gatewayv1.Gateway{}) + require.NoError(t, err) + for _, h := range all { + if !strings.HasSuffix(h, ".datumproxy.net") { + claimed = append(claimed, h) + } + } + return claimed, refused +} + +func TestSubtreeAwareHostnameClaims(t *testing.T) { + t.Parallel() + + earlier := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC) + + t.Run("a later claim under another project's wildcard is refused", func(t *testing.T) { + t.Parallel() + downstream := claimsDownstream(t, claimFor("project-a", "*.s3.example.com", earlier)) + + claimed, refused := claimHostnames(t, true, "project-b", downstream, "bucket.s3.example.com", "deep.bucket.s3.example.com") + + assert.NotContains(t, claimed, "bucket.s3.example.com") + assert.NotContains(t, claimed, "deep.bucket.s3.example.com") + assert.Contains(t, refused["bucket.s3.example.com"], `beneath the wildcard "*.s3.example.com"`) + assert.Contains(t, refused["deep.bucket.s3.example.com"], `beneath the wildcard "*.s3.example.com"`) + assert.NotContains(t, refused["bucket.s3.example.com"], "project-a") + assert.Contains(t, refused["bucket.s3.example.com"], "not supported yet") + + var claim corev1.ConfigMap + err := downstream.Get(context.Background(), client.ObjectKey{Namespace: claimsNamespace, Name: "bucket.s3.example.com"}, &claim) + assert.True(t, client.IgnoreNotFound(err) == nil && err != nil, "a refused hostname must not be claimed") + }) + + t.Run("a wildcard is refused while other projects hold names beneath it, and names them", func(t *testing.T) { + t.Parallel() + downstream := claimsDownstream(t, + claimFor("project-a", "photos.s3.example.com", earlier), + claimFor("project-a", "a.b.s3.example.com", earlier), + claimFor("project-a", "s3.example.com", earlier), + claimFor("project-a", "other.example.com", earlier), + ) + + claimed, refused := claimHostnames(t, true, "project-b", downstream, "*.s3.example.com") + + assert.NotContains(t, claimed, "*.s3.example.com") + message := refused["*.s3.example.com"] + assert.Contains(t, message, "a.b.s3.example.com, photos.s3.example.com.") + assert.NotContains(t, message, "other.example.com") + assert.NotContains(t, message, "project-a") + }) + + t.Run("a wildcard under another project's wildcard is refused", func(t *testing.T) { + t.Parallel() + downstream := claimsDownstream(t, claimFor("project-a", "*.example.com", earlier)) + + _, refused := claimHostnames(t, true, "project-b", downstream, "*.s3.example.com") + + assert.Contains(t, refused["*.s3.example.com"], `beneath the wildcard "*.example.com"`) + }) + + t.Run("a wildcard over another project's narrower wildcard is refused", func(t *testing.T) { + t.Parallel() + downstream := claimsDownstream(t, claimFor("project-a", "*.b.s3.example.com", earlier)) + + _, refused := claimHostnames(t, true, "project-b", downstream, "*.s3.example.com") + + assert.Contains(t, refused["*.s3.example.com"], "*.b.s3.example.com") + }) + + t.Run("a long list of names beneath a wildcard is cut short", func(t *testing.T) { + t.Parallel() + held := make([]client.Object, 0, 8) + for i := range 8 { + held = append(held, claimFor("project-a", fmt.Sprintf("b%d.s3.example.com", i), earlier)) + } + downstream := claimsDownstream(t, held...) + + _, refused := claimHostnames(t, true, "project-b", downstream, "*.s3.example.com") + + assert.Contains(t, refused["*.s3.example.com"], "b4.s3.example.com and 3 more.") + }) + + t.Run("one project may hold a wildcard and names beneath it", func(t *testing.T) { + t.Parallel() + downstream := claimsDownstream(t, claimFor("project-a", "photos.s3.example.com", earlier)) + + claimed, refused := claimHostnames(t, true, "project-a", downstream, "*.s3.example.com", "videos.s3.example.com") + + assert.Empty(t, refused) + assert.ElementsMatch(t, []string{"*.s3.example.com", "videos.s3.example.com"}, claimed) + + var claim corev1.ConfigMap + require.NoError(t, downstream.Get(context.Background(), client.ObjectKey{Namespace: claimsNamespace, Name: hostnameClaimName("*.s3.example.com")}, &claim)) + assert.Equal(t, "*.s3.example.com", claim.Data[jsonKeyHostname]) + }) + + t.Run("claims that raced settle on the older one", func(t *testing.T) { + t.Parallel() + later := earlier.Add(time.Minute) + ours := claimFor("project-b", "bucket.s3.example.com", later) + downstream := claimsDownstream(t, claimFor("project-a", "*.s3.example.com", earlier), ours) + + claimed, refused := claimHostnames(t, true, "project-b", downstream, "bucket.s3.example.com") + assert.NotContains(t, claimed, "bucket.s3.example.com") + assert.Contains(t, refused, "bucket.s3.example.com") + + var claim corev1.ConfigMap + err := downstream.Get(context.Background(), client.ObjectKeyFromObject(ours), &claim) + assert.True(t, err != nil && client.IgnoreNotFound(err) == nil, "the newer claim must be released") + + claimed, refused = claimHostnames(t, true, "project-a", claimsDownstream(t, claimFor("project-a", "*.s3.example.com", earlier), claimFor("project-b", "bucket.s3.example.com", later)), "*.s3.example.com") + assert.Empty(t, refused, "the older wildcard keeps its claim") + assert.Contains(t, claimed, "*.s3.example.com") + }) + + t.Run("a removed wildcard releases its claim", func(t *testing.T) { + t.Parallel() + downstream := claimsDownstream(t, claimFor("project-a", "*.s3.example.com", earlier)) + + claimed, _ := claimHostnames(t, true, "project-a", downstream, "www.example.com") + assert.Equal(t, []string{"www.example.com"}, claimed) + + var claim corev1.ConfigMap + err := downstream.Get(context.Background(), client.ObjectKey{Namespace: claimsNamespace, Name: hostnameClaimName("*.s3.example.com")}, &claim) + assert.True(t, err != nil && client.IgnoreNotFound(err) == nil) + }) + + t.Run("with the certificate service off claims stay exact-name only", func(t *testing.T) { + t.Parallel() + downstream := claimsDownstream(t, claimFor("project-a", "*.s3.example.com", earlier)) + + claimed, refused := claimHostnames(t, false, "project-b", downstream, "bucket.s3.example.com") + + assert.Empty(t, refused) + assert.Contains(t, claimed, "bucket.s3.example.com") + }) +} + +func refusedHostnames(refusals map[string]string) []string { + if len(refusals) == 0 { + return nil + } + hostnames := make([]string, 0, len(refusals)) + for h := range refusals { + hostnames = append(hostnames, h) + } + slices.Sort(hostnames) + return hostnames +} + +func TestExplainInUseHostnames(t *testing.T) { + t.Parallel() + + statuses := buildAvailabilityStatuses(nil, sets.New("bucket.s3.example.com"), 1) + explainInUseHostnames(statuses, map[string]string{"bucket.s3.example.com": "beneath a wildcard"}) + + require.Len(t, statuses, 1) + assert.Equal(t, "beneath a wildcard", statuses[0].Conditions[0].Message) +} diff --git a/internal/controller/httpproxy_controller.go b/internal/controller/httpproxy_controller.go index a8169b87..0e19566f 100644 --- a/internal/controller/httpproxy_controller.go +++ b/internal/controller/httpproxy_controller.go @@ -604,6 +604,7 @@ func (r *HTTPProxyReconciler) reconcileHTTPProxyHostnameStatus( acceptedHostnames := sets.New[gatewayv1.Hostname]() nonAcceptedHostnames := sets.New[string]() inUseHostnames := sets.New[string]() + inUseMessages := map[string]string{} for _, listener := range gateway.Spec.Listeners { if listener.Hostname == nil { // Should only happen shortly after creation, before the default hostnames @@ -623,6 +624,7 @@ func (r *HTTPProxyReconciler) reconcileHTTPProxyHostnameStatus( acceptedHostnames.Insert(*listener.Hostname) } else if listenerAcceptedCondition.Reason == networkingv1alpha.HostnameInUseReason { inUseHostnames.Insert(string(*listener.Hostname)) + inUseMessages[string(*listener.Hostname)] = listenerAcceptedCondition.Message } else { nonAcceptedHostnames.Insert(string(*listener.Hostname)) } @@ -684,6 +686,9 @@ func (r *HTTPProxyReconciler) reconcileHTTPProxyHostnameStatus( // Build per-hostname statuses availabilityStatuses := buildAvailabilityStatuses(acceptedHostnames, inUseHostnames, httpProxyCopy.Generation) + if r.Config.Gateway.CertificateService.Enabled { + explainInUseHostnames(availabilityStatuses, inUseMessages) + } dnsStatuses := r.buildDNSStatuses(ctx, cl, gateway, httpProxyCopy.Generation) certificateStatuses := r.buildCertificateStatuses(ctx, cl, clusterName, gateway, httpProxyCopy) dnsRecordStatuses, recheckRouting := r.buildDNSRecordStatuses(ctx, cl, gateway, httpProxyCopy) @@ -1395,6 +1400,24 @@ func buildAvailabilityStatuses( return statuses } +// explainInUseHostnames carries the gateway's reason a hostname could not be +// claimed onto its Available condition, so a hostname refused for sitting +// under another project's wildcard says so. +func explainInUseHostnames(statuses []networkingv1alpha.HostnameStatus, messages map[string]string) { + for i := range statuses { + message := messages[statuses[i].Hostname] + if message == "" { + continue + } + for j := range statuses[i].Conditions { + c := &statuses[i].Conditions[j] + if c.Type == networkingv1alpha.HostnameConditionAvailable && c.Reason == networkingv1alpha.HostnameAvailableReasonInUse { + c.Message = message + } + } + } +} + // buildDNSStatuses queries DNSRecordSets owned by the Gateway and builds // HostnameStatus entries with the DNSRecordProgrammed condition. func (r *HTTPProxyReconciler) buildDNSStatuses(