Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions internal/controller/gateway_certificate_service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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"},
Expand Down Expand Up @@ -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},
Expand Down Expand Up @@ -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{
Expand Down
62 changes: 50 additions & 12 deletions internal/controller/gateway_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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,
Expand All @@ -1438,21 +1440,42 @@ func (r *GatewayReconciler) ensureHostnamesClaimed(

objectKey := client.ObjectKey{
Namespace: r.Config.Gateway.DownstreamHostnameAccountingNamespace,
Name: hostname,
Name: hostnameClaimName(hostname),
}

var hostnameConfigMap corev1.ConfigMap
if err := downstreamClient.Get(ctx, objectKey, &hostnameConfigMap); client.IgnoreNotFound(err) != nil {
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,
},
Expand All @@ -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)
Expand All @@ -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
}
Expand All @@ -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, "-", "")
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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{}),
Expand Down
4 changes: 2 additions & 2 deletions internal/controller/gateway_controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{}
Expand Down Expand Up @@ -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))
})
}
}
Expand Down
162 changes: 162 additions & 0 deletions internal/controller/hostname_claims.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading
Loading