diff --git a/docs/design/gateway-api-integration.md b/docs/design/gateway-api-integration.md index c7a5f81..5c49f54 100644 --- a/docs/design/gateway-api-integration.md +++ b/docs/design/gateway-api-integration.md @@ -24,11 +24,12 @@ The Gateway controller is a source controller. It reads Gateway API attachment s The Gateway controller: - watches `HTTPRoute`, `Gateway`, and generated `EndpointRecordSet` lifecycle and desired-state changes; -- waits until all `HTTPRoute.spec.parentRefs` are accepted; +- requires every `HTTPRoute.spec.parentRefs` entry to have a current `Accepted=True` condition before it creates a new hostname intent; - resolves hostnames from the route/listener relationship; -- reads targets from `Gateway.status.addresses`; -- creates one `EndpointRecordSet` for each Gateway with the union of hostnames from accepted routes and targets from `Gateway.status.addresses`; -- updates or deletes generated `EndpointRecordSet` resources when routes or Gateways no longer produce them. +- reads current targets from `Gateway.status.addresses`; +- creates one `EndpointRecordSet` for each Gateway with the union of currently admitted route hostnames and Gateway targets; +- preserves an already admitted hostname and its last targets only during transient status observation loss, using an internal ownership receipt on the managed `EndpointRecordSet`; +- updates or deletes generated `EndpointRecordSet` resources when routes, listeners, Gateway identity, admission, or hostname ownership no longer produce them. The Gateway controller does not: @@ -133,7 +134,14 @@ Wildcard intersection chooses the concrete hostname when one side is concrete an ## Target Resolution -Targets come from `Gateway.status.addresses`. +Targets normally come from the current `Gateway.status.addresses`. + +The controller does not create a new hostname intent without nonempty current +addresses. A managed intent that was previously admitted may retain its +last-known nonempty targets while addresses are temporarily absent, but only +for hostnames whose internal receipt still matches the current route UID and +generation, Gateway UID and class, and exact parent/listener binding. A +Gateway deletion or replacement never reuses the former object's targets. Supported target types: @@ -144,9 +152,41 @@ The Gateway controller preserves target type and value. It does not turn hostnam ## Parent Acceptance -The initial behavior is conservative: DNS intent is generated only after every `HTTPRoute.spec.parentRefs[]` entry has an `Accepted=True` parent status. - -This avoids partial DNS publication for a route whose complete Gateway attachment is not settled. +The controller distinguishes current admission from unavailable observation for +each parent reference. A current `Accepted=True` condition has +`observedGeneration` equal to the route generation and is the only state that +can admit a hostname for a first publication. A current `Accepted=False` +explicitly withdraws the affected binding. Missing, `Unknown`, and stale +conditions are observation-unknown rather than denials: they may retain only +an exact receipt from an earlier admitted reconcile, never authorize a new +hostname. + +An explicit current Gateway `Accepted=False` condition, whose +`observedGeneration` equals the Gateway generation, withdraws every hostname +for that Gateway. Missing, `Unknown`, and stale Gateway conditions do not +fabricate this revocation. + +A later normal reconcile may create a fresh intent from current route admission +and current Gateway addresses after that withdrawal. It never carries the former +Gateway's targets or receipt forward. + + +An older managed `EndpointRecordSet` without a valid receipt fails closed during +unknown observation. It receives a receipt only from a normal reconcile with +complete current acceptance and nonempty current targets. + + +The receipt also records the specific listener. Hostname or parent removal, +listener removal or ownership change, route or Gateway identity replacement, +and route/Gateway deletion remove the affected hostname immediately. A +dynamic `allowedRoutes.namespaces.from: Selector` policy cannot be re-proved +without a namespace read, so unknown observation under that policy is not +retained. Independent sibling listener bindings are evaluated separately. + +The receipt records route namespace and name only so a parent-reference removal, +change, deletion, or final not-found reconciliation can requeue the previously +bound Gateway. That discovery only schedules recomputation; it does not grant +authority or relax UID, generation, Gateway, listener, or hostname matching. ## Deletion and Recovery diff --git a/internal/go/apps/gatewayendpoint/controllers/gateway/controller.go b/internal/go/apps/gatewayendpoint/controllers/gateway/controller.go index 200f25a..541809f 100644 --- a/internal/go/apps/gatewayendpoint/controllers/gateway/controller.go +++ b/internal/go/apps/gatewayendpoint/controllers/gateway/controller.go @@ -55,6 +55,9 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu var route gatewayv1.HTTPRoute if err := r.Get(ctx, req.NamespacedName, &route); err != nil { if apierrors.IsNotFound(err) { + if err := r.syncGatewaysForMissingRoute(ctx, namespace, req.Namespace, req.Name); err != nil { + return ctrl.Result{}, err + } return ctrl.Result{}, r.cleanupForRoute(ctx, namespace, req.Namespace, req.Name) } return ctrl.Result{}, err @@ -151,7 +154,36 @@ func (r *Reconciler) routesForGateway(ctx context.Context, key client.ObjectKey) } func (r *Reconciler) syncGatewaysForRoute(ctx context.Context, endpointRecordSetNamespace string, route *gatewayv1.HTTPRoute) error { - keys := gatewayKeysForRoute(route) + receiptKeys, err := r.gatewayKeysFromAdmissionReceipts(ctx, endpointRecordSetNamespace, route.Namespace, route.Name) + if err != nil { + return err + } + return r.syncGatewayKeys(ctx, endpointRecordSetNamespace, append(gatewayKeysForRoute(route), receiptKeys...)) +} + +func (r *Reconciler) syncGatewaysForMissingRoute(ctx context.Context, endpointRecordSetNamespace, routeNamespace, routeName string) error { + keys, err := r.gatewayKeysFromAdmissionReceipts(ctx, endpointRecordSetNamespace, routeNamespace, routeName) + if err != nil { + return err + } + return r.syncGatewayKeys(ctx, endpointRecordSetNamespace, keys) +} + +func (r *Reconciler) syncGatewayKeys(ctx context.Context, endpointRecordSetNamespace string, keys []client.ObjectKey) error { + seen := map[client.ObjectKey]struct{}{} + for _, key := range keys { + seen[key] = struct{}{} + } + keys = keys[:0] + for key := range seen { + keys = append(keys, key) + } + sort.Slice(keys, func(i, j int) bool { + if keys[i].Namespace == keys[j].Namespace { + return keys[i].Name < keys[j].Name + } + return keys[i].Namespace < keys[j].Namespace + }) for _, key := range keys { if err := r.syncGatewayEndpointRecordSet(ctx, endpointRecordSetNamespace, key); err != nil { return err @@ -192,52 +224,108 @@ func (r *Reconciler) syncGatewayEndpointRecordSet(ctx context.Context, endpointR } return err } - desired, err := r.endpointRecordSetsForGateway(ctx, &gateway, endpointRecordSetNamespace) + if !gateway.DeletionTimestamp.IsZero() { + return r.applyGatewayEndpointRecordSets(ctx, endpointRecordSetNamespace, gatewayKey, nil) + } + existing, err := r.existingGatewayEndpointRecordSet(ctx, endpointRecordSetNamespace, &gateway) + if err != nil { + return err + } + desired, err := r.endpointRecordSetsForGateway(ctx, &gateway, endpointRecordSetNamespace, existing) if err != nil { return err } return r.applyGatewayEndpointRecordSets(ctx, endpointRecordSetNamespace, gatewayKey, desired) } -func (r *Reconciler) endpointRecordSetsForGateway(ctx context.Context, gateway *gatewayv1.Gateway, namespace string) ([]endpointv1alpha1.EndpointRecordSet, error) { - targets := endpointTargets(gateway.Status.Addresses) - if len(targets) == 0 { +func (r *Reconciler) endpointRecordSetsForGateway(ctx context.Context, gateway *gatewayv1.Gateway, namespace string, existing *endpointv1alpha1.EndpointRecordSet) ([]endpointv1alpha1.EndpointRecordSet, error) { + if gatewayAcceptance(gateway) == parentAcceptanceRejected { return nil, nil } + targets := endpointTargets(gateway.Status.Addresses) var routes gatewayv1.HTTPRouteList if err := r.List(ctx, &routes); err != nil { return nil, err } + + receipt := existingAdmissionReceipt(existing) + publishedHostnames := map[string]struct{}{} + if existing != nil { + for _, hostname := range existing.Spec.Hostnames { + publishedHostnames[hostname] = struct{}{} + } + } hostnames := map[string]struct{}{} - for _, route := range routes.Items { - if !route.DeletionTimestamp.IsZero() || !allRouteParentRefsAccepted(&route) { + retainedHostnames := map[string]struct{}{} + receiptBindings := map[string]admissionReceiptBinding{} + for i := range routes.Items { + route := &routes.Items[i] + if !route.DeletionTimestamp.IsZero() { continue } - parents, err := r.acceptedParents(ctx, &route) - if err != nil { - return nil, err - } - for _, parent := range parents { - if parent.gateway.Namespace != gateway.Namespace || parent.gateway.Name != gateway.Name { - continue - } - for _, hostname := range effectiveHostnamesForParent(&route, parent) { + fullyAccepted := allRouteParentRefsAccepted(route) + for _, binding := range routeGatewayBindings(route, gateway) { + acceptance := routeParentAcceptance(route, binding.ref) + for _, hostname := range effectiveHostnamesForListener(route, binding.listener) { + match, hasReceipt := admissionReceiptBinding{}, false + conflictingReceipt := false + if _, published := publishedHostnames[hostname]; published { + match, hasReceipt = receipt.matchingBinding(route, gateway, binding, hostname) + conflictingReceipt = receipt.conflictsCurrentBinding(route, gateway, binding, hostname) + } + if fullyAccepted && acceptance == parentAcceptanceAccepted { + if conflictingReceipt { + continue + } + hostnames[hostname] = struct{}{} + if len(targets) != 0 { + if current, ok := newAdmissionReceiptBinding(route, gateway, binding); ok { + addReceiptHostname(receiptBindings, current, hostname) + } + } else if hasReceipt { + addReceiptHostname(receiptBindings, match, hostname) + } + if hasReceipt { + retainedHostnames[hostname] = struct{}{} + } + continue + } + if acceptance == parentAcceptanceRejected || + (acceptance == parentAcceptanceUnknown && !listenerAllowsUnknownRetention(route, gateway, binding.listener)) || + !hasReceipt { + continue + } hostnames[hostname] = struct{}{} + retainedHostnames[hostname] = struct{}{} + addReceiptHostname(receiptBindings, match, hostname) } } } - if len(hostnames) == 0 { + if len(targets) == 0 { + hostnames = retainedHostnames + } + if len(hostnames) == 0 || len(targets) == 0 && (existing == nil || len(existing.Spec.Targets) == 0) { return nil, nil } + if len(targets) == 0 { + targets = existing.Spec.Targets + } + hostnameList := make([]string, 0, len(hostnames)) for hostname := range hostnames { hostnameList = append(hostnameList, hostname) } sort.Strings(hostnameList) + retainReceiptHostnames(receiptBindings, hostnames) + annotations := map[string]string(nil) + if encoded := encodeAdmissionReceipt(receiptBindings); encoded != "" { + annotations = map[string]string{admissionReceiptAnnotation: encoded} + } return []endpointv1alpha1.EndpointRecordSet{{ ObjectMeta: metav1.ObjectMeta{ - Namespace: namespace, - Name: generatedGatewayEndpointRecordSetName(gateway.Namespace, gateway.Name), + Namespace: namespace, + Name: generatedGatewayEndpointRecordSetName(gateway.Namespace, gateway.Name), + Annotations: annotations, Labels: map[string]string{ managedByLabel: managedByValue, generatedLabelGatewayNamespace: gateway.Namespace, @@ -251,50 +339,6 @@ func (r *Reconciler) endpointRecordSetsForGateway(ctx context.Context, gateway * }}, nil } -func (r *Reconciler) endpointRecordSetsForRoute(ctx context.Context, route *gatewayv1.HTTPRoute, namespace string) ([]endpointv1alpha1.EndpointRecordSet, error) { - parents, err := r.acceptedParents(ctx, route) - if err != nil { - return nil, err - } - if len(parents) == 0 || !allRouteParentRefsAccepted(route) { - return nil, nil - } - desired := make([]endpointv1alpha1.EndpointRecordSet, 0, len(parents)) - for _, parent := range parents { - hostnames := effectiveHostnamesForParent(route, parent) - targets := endpointTargets(parent.addresses) - if len(hostnames) == 0 || len(targets) == 0 { - continue - } - listenerName := string(parent.listener.Name) - gatewayNamespace := parent.gateway.Namespace - gatewayName := parent.gateway.Name - item := endpointv1alpha1.EndpointRecordSet{ - ObjectMeta: metav1.ObjectMeta{ - Namespace: namespace, - Name: generatedEndpointRecordSetName(route.Namespace, route.Name, gatewayNamespace, gatewayName, listenerName), - Labels: map[string]string{ - managedByLabel: managedByValue, - generatedLabelRouteNamespace: route.Namespace, - generatedLabelRouteName: route.Name, - generatedLabelGatewayNamespace: gatewayNamespace, - generatedLabelGatewayName: gatewayName, - generatedLabelListenerName: listenerName, - }, - }, - Spec: endpointv1alpha1.EndpointRecordSetSpec{ - Hostnames: hostnames, - Targets: targets, - }, - } - desired = append(desired, item) - } - sort.Slice(desired, func(i, j int) bool { - return desired[i].Name < desired[j].Name - }) - return desired, nil -} - func (r *Reconciler) applyGatewayEndpointRecordSets(ctx context.Context, namespace string, gatewayKey client.ObjectKey, desired []endpointv1alpha1.EndpointRecordSet) error { desiredNames := map[string]struct{}{} for _, item := range desired { @@ -316,12 +360,22 @@ func (r *Reconciler) applyGatewayEndpointRecordSets(ctx context.Context, namespa // provider cleanup; the EndpointRecordSet delete watch retries creation. continue } + desiredReceipt := desired.Annotations[admissionReceiptAnnotation] if apiequality.Semantic.DeepEqual(existing.Labels, desired.Labels) && - apiequality.Semantic.DeepEqual(existing.Spec, desired.Spec) { + apiequality.Semantic.DeepEqual(existing.Spec, desired.Spec) && + existing.Annotations[admissionReceiptAnnotation] == desiredReceipt { continue } existing.Labels = desired.Labels existing.Spec = desired.Spec + if desiredReceipt == "" { + delete(existing.Annotations, admissionReceiptAnnotation) + } else { + if existing.Annotations == nil { + existing.Annotations = map[string]string{} + } + existing.Annotations[admissionReceiptAnnotation] = desiredReceipt + } if err := r.Update(ctx, &existing, client.FieldOwner(fieldOwner)); err != nil { return err } @@ -346,48 +400,6 @@ func (r *Reconciler) applyGatewayEndpointRecordSets(ctx context.Context, namespa return nil } -func (r *Reconciler) applyEndpointRecordSets(ctx context.Context, route *gatewayv1.HTTPRoute, namespace string, desired []endpointv1alpha1.EndpointRecordSet) error { - desiredNames := map[string]struct{}{} - for _, item := range desired { - desired := item - desiredNames[desired.Name] = struct{}{} - var existing endpointv1alpha1.EndpointRecordSet - err := r.Get(ctx, client.ObjectKey{Namespace: desired.Namespace, Name: desired.Name}, &existing) - if apierrors.IsNotFound(err) { - if err := r.Create(ctx, &desired, client.FieldOwner(fieldOwner)); err != nil { - return err - } - continue - } - if err != nil { - return err - } - existing.Labels = desired.Labels - existing.Spec = desired.Spec - if err := r.Update(ctx, &existing, client.FieldOwner(fieldOwner)); err != nil { - return err - } - } - var existing endpointv1alpha1.EndpointRecordSetList - if err := r.List(ctx, &existing, client.InNamespace(namespace), client.MatchingLabels{ - managedByLabel: managedByValue, - generatedLabelRouteNamespace: route.Namespace, - generatedLabelRouteName: route.Name, - }); err != nil { - return err - } - for _, item := range existing.Items { - if _, ok := desiredNames[item.Name]; ok { - continue - } - item := item - if err := r.Delete(ctx, &item); err != nil && !apierrors.IsNotFound(err) { - return err - } - } - return nil -} - func (r *Reconciler) cleanupForRoute(ctx context.Context, endpointRecordSetNamespace, routeNamespace, routeName string) error { var existing endpointv1alpha1.EndpointRecordSetList if err := r.List(ctx, &existing, client.InNamespace(endpointRecordSetNamespace), client.MatchingLabels{ @@ -418,16 +430,3 @@ func generatedGatewayEndpointRecordSetName(gatewayNamespace, gatewayName string) } return strings.Trim(candidate[:52], "-") + "-" + hash } - -func generatedEndpointRecordSetName(routeNamespace, routeName, gatewayNamespace, gatewayName, listenerName string) string { - hashInput := routeNamespace + "/" + routeName + "/" + gatewayNamespace + "/" + gatewayName + "/" + listenerName - sum := sha256.Sum256([]byte(hashInput)) - hash := hex.EncodeToString(sum[:])[:10] - candidate := fmt.Sprintf("%s-%s-%s", routeName, gatewayName, listenerName) - candidate = strings.NewReplacer(".", "-", "_", "-", "*", "wildcard").Replace(candidate) - candidate = strings.ToLower(candidate) - if len(candidate) <= 52 { - return candidate + "-" + hash - } - return strings.Trim(candidate[:52], "-") + "-" + hash -} diff --git a/internal/go/apps/gatewayendpoint/controllers/gateway/httproute.go b/internal/go/apps/gatewayendpoint/controllers/gateway/httproute.go index 322104d..bd21669 100644 --- a/internal/go/apps/gatewayendpoint/controllers/gateway/httproute.go +++ b/internal/go/apps/gatewayendpoint/controllers/gateway/httproute.go @@ -1,76 +1,122 @@ package gateway import ( - "context" "net" "sort" + "strconv" "strings" endpointv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/endpoint/v1alpha1" - "sigs.k8s.io/controller-runtime/pkg/client" gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" ) -type parentInfo struct { - ref gatewayv1.ParentReference - gateway gatewayv1.Gateway - listener gatewayv1.Listener - addresses []gatewayv1.GatewayStatusAddress +type routeGatewayBinding struct { + ref gatewayv1.ParentReference + listener gatewayv1.Listener } -func (r *Reconciler) acceptedParents(ctx context.Context, route *gatewayv1.HTTPRoute) ([]parentInfo, error) { - acceptedKeys := acceptedParentRefKeys(route) - parents := make([]parentInfo, 0, len(route.Spec.ParentRefs)) +type parentAcceptance uint8 + +const ( + parentAcceptanceUnknown parentAcceptance = iota + parentAcceptanceAccepted + parentAcceptanceRejected +) + +func routeGatewayBindings(route *gatewayv1.HTTPRoute, gateway *gatewayv1.Gateway) []routeGatewayBinding { + bindings := make([]routeGatewayBinding, 0, len(route.Spec.ParentRefs)) for _, ref := range route.Spec.ParentRefs { - if _, ok := acceptedKeys[parentRefKey(route.Namespace, ref)]; !ok { + if !parentRefTargetsGateway(route.Namespace, ref, gateway) { continue } - namespace := route.Namespace - if ref.Namespace != nil { - namespace = string(*ref.Namespace) - } - var gateway gatewayv1.Gateway - if err := r.Get(ctx, client.ObjectKey{Namespace: namespace, Name: string(ref.Name)}, &gateway); err != nil { - return nil, err - } for _, listener := range gateway.Spec.Listeners { if ref.SectionName != nil && listener.Name != *ref.SectionName { continue } - parents = append(parents, parentInfo{ - ref: ref, - gateway: gateway, - listener: listener, - addresses: gateway.Status.Addresses, - }) + if ref.Port != nil && listener.Port != *ref.Port { + continue + } + bindings = append(bindings, routeGatewayBinding{ref: ref, listener: listener}) } } - return parents, nil + return bindings +} + +func parentRefTargetsGateway(routeNamespace string, ref gatewayv1.ParentReference, gateway *gatewayv1.Gateway) bool { + if ref.Group != nil && string(*ref.Group) != "gateway.networking.k8s.io" { + return false + } + if ref.Kind != nil && string(*ref.Kind) != "Gateway" { + return false + } + namespace := routeNamespace + if ref.Namespace != nil { + namespace = string(*ref.Namespace) + } + return namespace == gateway.Namespace && string(ref.Name) == gateway.Name } func allRouteParentRefsAccepted(route *gatewayv1.HTTPRoute) bool { if len(route.Spec.ParentRefs) == 0 { return false } - acceptedKeys := acceptedParentRefKeys(route) for _, ref := range route.Spec.ParentRefs { - if _, ok := acceptedKeys[parentRefKey(route.Namespace, ref)]; !ok { + if routeParentAcceptance(route, ref) != parentAcceptanceAccepted { return false } } return true } -func acceptedParentRefKeys(route *gatewayv1.HTTPRoute) map[string]struct{} { - acceptedKeys := map[string]struct{}{} +func routeParentAcceptance(route *gatewayv1.HTTPRoute, ref gatewayv1.ParentReference) parentAcceptance { + acceptance := parentAcceptanceUnknown for _, parent := range route.Status.Parents { + if parentRefKey(route.Namespace, parent.ParentRef) != parentRefKey(route.Namespace, ref) { + continue + } for _, condition := range parent.Conditions { - if condition.Type == string(gatewayv1.RouteConditionAccepted) && condition.Status == "True" { - acceptedKeys[parentRefKey(route.Namespace, parent.ParentRef)] = struct{}{} + if condition.Type != string(gatewayv1.RouteConditionAccepted) || condition.ObservedGeneration != route.Generation { + continue + } + switch condition.Status { + case "False": + return parentAcceptanceRejected + case "True": + acceptance = parentAcceptanceAccepted } } } - return acceptedKeys + return acceptance +} +func gatewayAcceptance(gateway *gatewayv1.Gateway) parentAcceptance { + acceptance := parentAcceptanceUnknown + for _, condition := range gateway.Status.Conditions { + if condition.Type != string(gatewayv1.GatewayConditionAccepted) || condition.ObservedGeneration != gateway.Generation { + continue + } + switch condition.Status { + case "False": + return parentAcceptanceRejected + case "True": + acceptance = parentAcceptanceAccepted + } + } + return acceptance +} + +func listenerAllowsUnknownRetention(route *gatewayv1.HTTPRoute, gateway *gatewayv1.Gateway, listener gatewayv1.Listener) bool { + if listener.AllowedRoutes == nil || listener.AllowedRoutes.Namespaces == nil || listener.AllowedRoutes.Namespaces.From == nil { + return route.Namespace == gateway.Namespace + } + switch *listener.AllowedRoutes.Namespaces.From { + case gatewayv1.NamespacesFromAll: + return true + case gatewayv1.NamespacesFromSame: + return route.Namespace == gateway.Namespace + default: + // Selector policy would require a namespace read to re-prove attachment. + return false + } } func parentRefKey(routeNamespace string, ref gatewayv1.ParentReference) string { @@ -78,49 +124,29 @@ func parentRefKey(routeNamespace string, ref gatewayv1.ParentReference) string { if ref.Namespace != nil { namespace = string(*ref.Namespace) } + group := "gateway.networking.k8s.io" + if ref.Group != nil { + group = string(*ref.Group) + } + kind := "Gateway" + if ref.Kind != nil { + kind = string(*ref.Kind) + } section := "" if ref.SectionName != nil { section = string(*ref.SectionName) } - return namespace + "/" + string(ref.Name) + "/" + section -} - -func effectiveHostnames(route *gatewayv1.HTTPRoute, parents []parentInfo) []string { - hostnames := map[string]struct{}{} - for _, parent := range parents { - listenerHostname := "" - if parent.listener.Hostname != nil { - listenerHostname = string(*parent.listener.Hostname) - } - if len(route.Spec.Hostnames) == 0 { - if listenerHostname != "" { - hostnames[listenerHostname] = struct{}{} - } - continue - } - for _, routeHostname := range route.Spec.Hostnames { - hostname := string(routeHostname) - if listenerHostname == "" { - hostnames[hostname] = struct{}{} - continue - } - if matched, ok := hostnameIntersection(hostname, listenerHostname); ok { - hostnames[matched] = struct{}{} - } - } - } - result := make([]string, 0, len(hostnames)) - for hostname := range hostnames { - result = append(result, hostname) + port := "" + if ref.Port != nil { + port = strconv.Itoa(int(*ref.Port)) } - sort.Strings(result) - return result + return strings.Join([]string{group, kind, namespace, string(ref.Name), section, port}, "\x00") } -func effectiveHostnamesForParent(route *gatewayv1.HTTPRoute, parent parentInfo) []string { +func effectiveHostnamesForListener(route *gatewayv1.HTTPRoute, listener gatewayv1.Listener) []string { listenerHostname := "" - if parent.listener.Hostname != nil { - listenerHostname = string(*parent.listener.Hostname) + if listener.Hostname != nil { + listenerHostname = string(*listener.Hostname) } hostnames := map[string]struct{}{} if len(route.Spec.Hostnames) == 0 { @@ -187,12 +213,3 @@ func endpointTargets(addresses []gatewayv1.GatewayStatusAddress) []endpointv1alp }) return targets } - -func appendUnique(items []string, item string) []string { - for _, existing := range items { - if existing == item { - return items - } - } - return append(items, item) -} diff --git a/internal/go/apps/gatewayendpoint/controllers/gateway/receipt.go b/internal/go/apps/gatewayendpoint/controllers/gateway/receipt.go new file mode 100644 index 0000000..09395d2 --- /dev/null +++ b/internal/go/apps/gatewayendpoint/controllers/gateway/receipt.go @@ -0,0 +1,277 @@ +package gateway + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "sort" + "strconv" + "strings" + + endpointv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/endpoint/v1alpha1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "sigs.k8s.io/controller-runtime/pkg/client" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" +) + +const admissionReceiptAnnotation = "gateway.endpoint.dns.appthrust.io/admission-receipt" + +type admissionReceipt struct { + Bindings []admissionReceiptBinding `json:"bindings"` +} + +type admissionReceiptBinding struct { + Hostnames []string `json:"hostnames"` + RouteNamespace string `json:"routeNamespace"` + RouteName string `json:"routeName"` + RouteUID string `json:"routeUID"` + RouteGeneration int64 `json:"routeGeneration"` + GatewayUID string `json:"gatewayUID"` + GatewayClass string `json:"gatewayClass"` + ParentRef string `json:"parentRef"` + Listener string `json:"listener"` + ListenerDigest string `json:"listenerDigest"` +} + +func existingAdmissionReceipt(record *endpointv1alpha1.EndpointRecordSet) admissionReceipt { + if record == nil || record.Annotations == nil { + return admissionReceipt{} + } + encoded := record.Annotations[admissionReceiptAnnotation] + if encoded == "" { + return admissionReceipt{} + } + var receipt admissionReceipt + if err := json.Unmarshal([]byte(encoded), &receipt); err != nil { + return admissionReceipt{} + } + valid := receipt.Bindings[:0] + for _, binding := range receipt.Bindings { + if binding.valid() { + valid = append(valid, binding) + } + } + receipt.Bindings = valid + return receipt +} + +func (binding admissionReceiptBinding) valid() bool { + if binding.RouteNamespace == "" || binding.RouteName == "" || binding.RouteUID == "" || + binding.RouteGeneration <= 0 || binding.GatewayUID == "" || binding.GatewayClass == "" || + binding.ParentRef == "" || binding.Listener == "" || binding.ListenerDigest == "" || len(binding.Hostnames) == 0 { + return false + } + for _, hostname := range binding.Hostnames { + if hostname == "" { + return false + } + } + return true +} + +func newAdmissionReceiptBinding(route *gatewayv1.HTTPRoute, gateway *gatewayv1.Gateway, binding routeGatewayBinding) (admissionReceiptBinding, bool) { + if route.UID == "" || route.Generation <= 0 || gateway.UID == "" || gateway.Spec.GatewayClassName == "" { + return admissionReceiptBinding{}, false + } + listenerDigest := gatewayListenerDigest(binding.listener) + if listenerDigest == "" { + return admissionReceiptBinding{}, false + } + return admissionReceiptBinding{ + RouteNamespace: route.Namespace, + RouteName: route.Name, + RouteUID: string(route.UID), + RouteGeneration: route.Generation, + GatewayUID: string(gateway.UID), + GatewayClass: string(gateway.Spec.GatewayClassName), + ParentRef: parentRefKey(route.Namespace, binding.ref), + Listener: string(binding.listener.Name), + ListenerDigest: listenerDigest, + }, true +} + +func (receipt admissionReceipt) matchingBinding(route *gatewayv1.HTTPRoute, gateway *gatewayv1.Gateway, binding routeGatewayBinding, hostname string) (admissionReceiptBinding, bool) { + listenerDigest := gatewayListenerDigest(binding.listener) + if route.UID == "" || route.Generation <= 0 || gateway.UID == "" || listenerDigest == "" { + return admissionReceiptBinding{}, false + } + parentRef := parentRefKey(route.Namespace, binding.ref) + for _, candidate := range receipt.Bindings { + if candidate.RouteNamespace != route.Namespace || + candidate.RouteName != route.Name || + candidate.RouteUID != string(route.UID) || + candidate.RouteGeneration != route.Generation || + candidate.GatewayUID != string(gateway.UID) || + candidate.GatewayClass != string(gateway.Spec.GatewayClassName) || + candidate.ParentRef != parentRef || + candidate.Listener != string(binding.listener.Name) || + candidate.ListenerDigest != listenerDigest || + !receiptHasHostname(candidate, hostname) { + continue + } + return candidate, true + } + return admissionReceiptBinding{}, false +} + +func (receipt admissionReceipt) conflictsCurrentBinding(route *gatewayv1.HTTPRoute, gateway *gatewayv1.Gateway, binding routeGatewayBinding, hostname string) bool { + listenerDigest := gatewayListenerDigest(binding.listener) + if route.UID == "" || route.Generation <= 0 || gateway.UID == "" || listenerDigest == "" { + return false + } + parentRef := parentRefKey(route.Namespace, binding.ref) + conflict := false + for _, candidate := range receipt.Bindings { + if candidate.RouteNamespace != route.Namespace || + candidate.RouteName != route.Name || + candidate.RouteUID != string(route.UID) || + candidate.RouteGeneration != route.Generation || + candidate.ParentRef != parentRef || + candidate.Listener != string(binding.listener.Name) || + !receiptHasHostname(candidate, hostname) { + continue + } + if candidate.GatewayUID == string(gateway.UID) && + candidate.GatewayClass == string(gateway.Spec.GatewayClassName) && + candidate.ListenerDigest == listenerDigest { + return false + } + conflict = true + } + return conflict +} + +func receiptHasHostname(binding admissionReceiptBinding, hostname string) bool { + for _, candidate := range binding.Hostnames { + if candidate == hostname { + return true + } + } + return false +} + +func (receipt admissionReceipt) referencesRoute(namespace, name string) bool { + for _, binding := range receipt.Bindings { + if binding.RouteNamespace == namespace && binding.RouteName == name { + return true + } + } + return false +} + +func addReceiptHostname(bindings map[string]admissionReceiptBinding, binding admissionReceiptBinding, hostname string) { + key := admissionReceiptBindingKey(binding) + current, found := bindings[key] + if !found { + current = binding + current.Hostnames = nil + } + if !receiptHasHostname(current, hostname) { + current.Hostnames = append(current.Hostnames, hostname) + } + bindings[key] = current +} + +func retainReceiptHostnames(bindings map[string]admissionReceiptBinding, hostnames map[string]struct{}) { + for key, binding := range bindings { + retained := binding.Hostnames[:0] + for _, hostname := range binding.Hostnames { + if _, ok := hostnames[hostname]; ok { + retained = append(retained, hostname) + } + } + if len(retained) == 0 { + delete(bindings, key) + continue + } + binding.Hostnames = retained + bindings[key] = binding + } +} + +func encodeAdmissionReceipt(bindings map[string]admissionReceiptBinding) string { + if len(bindings) == 0 { + return "" + } + receipt := admissionReceipt{Bindings: make([]admissionReceiptBinding, 0, len(bindings))} + for _, binding := range bindings { + sort.Strings(binding.Hostnames) + receipt.Bindings = append(receipt.Bindings, binding) + } + sort.Slice(receipt.Bindings, func(i, j int) bool { + return admissionReceiptBindingKey(receipt.Bindings[i]) < admissionReceiptBindingKey(receipt.Bindings[j]) + }) + encoded, err := json.Marshal(receipt) + if err != nil { + return "" + } + return string(encoded) +} + +func admissionReceiptBindingKey(binding admissionReceiptBinding) string { + return strings.Join([]string{ + binding.RouteNamespace, + binding.RouteName, + binding.RouteUID, + strconv.FormatInt(binding.RouteGeneration, 10), + binding.GatewayUID, + binding.GatewayClass, + binding.ParentRef, + binding.Listener, + binding.ListenerDigest, + }, "\x00") +} + +func gatewayListenerDigest(listener gatewayv1.Listener) string { + encoded, err := json.Marshal(listener) + if err != nil { + return "" + } + sum := sha256.Sum256(encoded) + return hex.EncodeToString(sum[:]) +} + +func (r *Reconciler) existingGatewayEndpointRecordSet(ctx context.Context, namespace string, gateway *gatewayv1.Gateway) (*endpointv1alpha1.EndpointRecordSet, error) { + var record endpointv1alpha1.EndpointRecordSet + if err := r.Get(ctx, client.ObjectKey{Namespace: namespace, Name: generatedGatewayEndpointRecordSetName(gateway.Namespace, gateway.Name)}, &record); err != nil { + if apierrors.IsNotFound(err) { + return nil, nil + } + return nil, err + } + if record.Labels[managedByLabel] != managedByValue || + record.Labels[generatedLabelGatewayNamespace] != gateway.Namespace || + record.Labels[generatedLabelGatewayName] != gateway.Name { + return nil, nil + } + return &record, nil +} + +func (r *Reconciler) gatewayKeysFromAdmissionReceipts(ctx context.Context, namespace, routeNamespace, routeName string) ([]client.ObjectKey, error) { + var records endpointv1alpha1.EndpointRecordSetList + if err := r.List(ctx, &records, client.InNamespace(namespace), client.MatchingLabels{managedByLabel: managedByValue}); err != nil { + return nil, err + } + keys := map[client.ObjectKey]struct{}{} + for i := range records.Items { + record := &records.Items[i] + labels := record.Labels + key := client.ObjectKey{Namespace: labels[generatedLabelGatewayNamespace], Name: labels[generatedLabelGatewayName]} + if key.Namespace == "" || key.Name == "" || !existingAdmissionReceipt(record).referencesRoute(routeNamespace, routeName) { + continue + } + keys[key] = struct{}{} + } + result := make([]client.ObjectKey, 0, len(keys)) + for key := range keys { + result = append(result, key) + } + sort.Slice(result, func(i, j int) bool { + if result[i].Namespace == result[j].Namespace { + return result[i].Name < result[j].Name + } + return result[i].Namespace < result[j].Namespace + }) + return result, nil +} diff --git a/internal/go/apps/gatewayendpoint/controllers/gateway/receipt_sync_test.go b/internal/go/apps/gatewayendpoint/controllers/gateway/receipt_sync_test.go new file mode 100644 index 0000000..357ebf4 --- /dev/null +++ b/internal/go/apps/gatewayendpoint/controllers/gateway/receipt_sync_test.go @@ -0,0 +1,157 @@ +package gateway + +import ( + "context" + "testing" + + endpointv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/endpoint/v1alpha1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" +) + +func TestGatewayEndpointRecordSetReconcilesReceiptGatewaysAfterParentChange(t *testing.T) { + t.Run("parent removal preserves the remaining Gateway", func(t *testing.T) { + ctx := context.Background() + r, routeKey, gatewayARecordKey, gatewayBRecordKey := receiptSyncFixture(t) + detachReceiptSyncGatewayA(t, r, routeKey) + reconcileRetentionRoute(t, r, routeKey) + + var removed endpointv1alpha1.EndpointRecordSet + if err := r.Get(ctx, gatewayARecordKey, &removed); !apierrors.IsNotFound(err) { + t.Fatalf("detached Gateway retained DNS intent: %v", err) + } + assertRetentionRecord(t, r, gatewayBRecordKey, []string{"shared.example.com"}, "192.0.2.20") + }) + t.Run("route deletion cleans a detached Gateway not yet reconciled", func(t *testing.T) { + ctx := context.Background() + r, routeKey, gatewayARecordKey, gatewayBRecordKey := receiptSyncFixture(t) + detachReceiptSyncGatewayA(t, r, routeKey) + deleteRoute(t, r, routeKey) + + for _, key := range []client.ObjectKey{gatewayARecordKey, gatewayBRecordKey} { + var record endpointv1alpha1.EndpointRecordSet + if err := r.Get(ctx, key, &record); !apierrors.IsNotFound(err) { + t.Fatalf("deleted route left prior Gateway DNS intent %s: %v", key, err) + } + } + }) +} + +func TestGatewayEndpointRecordSetDoesNotMintReceiptWithoutCurrentAddresses(t *testing.T) { + ctx := context.Background() + r, _, haKey, gatewayKey, recordKey := retentionFixture(t, false) + setGatewayAddresses(t, r, gatewayKey, nil) + + fresh := retentionRoute("app", "fresh", "fresh-route", "ha.example.com", metav1.ConditionTrue) + if err := r.Create(ctx, fresh); err != nil { + t.Fatal(err) + } + freshKey := client.ObjectKeyFromObject(fresh) + reconcileRetentionRoute(t, r, freshKey) + setRouteAcceptance(t, r, freshKey, metav1.ConditionUnknown) + deleteRoute(t, r, haKey) + + var record endpointv1alpha1.EndpointRecordSet + if err := r.Get(ctx, recordKey, &record); !apierrors.IsNotFound(err) { + t.Fatalf("fresh route inherited a receipt while addresses were absent: %v", err) + } +} + +func TestGatewayEndpointRecordSetKeepsReceiptAcrossRepeatedAddressLoss(t *testing.T) { + r, _, haKey, gatewayKey, recordKey := retentionFixture(t, false) + setGatewayAddresses(t, r, gatewayKey, nil) + + for range 2 { + reconcileRetentionRoute(t, r, haKey) + assertRetentionRecord(t, r, recordKey, []string{"ha.example.com"}, "192.0.2.1") + } + + setRouteAcceptance(t, r, haKey, metav1.ConditionUnknown) + reconcileRetentionRoute(t, r, haKey) + assertRetentionRecord(t, r, recordKey, []string{"ha.example.com"}, "192.0.2.1") +} + +func receiptSyncFixture(t *testing.T) (*Reconciler, client.ObjectKey, client.ObjectKey, client.ObjectKey) { + t.Helper() + gatewayNamespace := gatewayv1.Namespace("platform") + section := gatewayv1.SectionName("https") + parentA := gatewayv1.ParentReference{Namespace: &gatewayNamespace, Name: "gateway-a", SectionName: §ion} + parentB := gatewayv1.ParentReference{Namespace: &gatewayNamespace, Name: "gateway-b", SectionName: §ion} + route := &gatewayv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{Namespace: "app", Name: "web", UID: types.UID("shared-route"), Generation: 1}, + Spec: gatewayv1.HTTPRouteSpec{ + CommonRouteSpec: gatewayv1.CommonRouteSpec{ParentRefs: []gatewayv1.ParentReference{parentA, parentB}}, + Hostnames: []gatewayv1.Hostname{"shared.example.com"}, + }, + Status: gatewayv1.HTTPRouteStatus{RouteStatus: gatewayv1.RouteStatus{Parents: []gatewayv1.RouteParentStatus{ + receiptSyncAcceptedParent(parentA, 1), + receiptSyncAcceptedParent(parentB, 1), + }}}, + } + gatewayA := receiptSyncGateway("gateway-a", "gateway-a-uid", "192.0.2.10", section) + gatewayB := receiptSyncGateway("gateway-b", "gateway-b-uid", "192.0.2.20", section) + k8sClient := fake.NewClientBuilder().WithScheme(testScheme(t)). + WithStatusSubresource(route, gatewayA, gatewayB). + WithObjects(route, gatewayA, gatewayB).Build() + r := &Reconciler{Client: k8sClient, EndpointRecordSetNamespace: "dns-system"} + routeKey := client.ObjectKeyFromObject(route) + reconcileRetentionRoute(t, r, routeKey) + gatewayARecordKey := client.ObjectKey{Namespace: "dns-system", Name: generatedGatewayEndpointRecordSetName(gatewayA.Namespace, gatewayA.Name)} + gatewayBRecordKey := client.ObjectKey{Namespace: "dns-system", Name: generatedGatewayEndpointRecordSetName(gatewayB.Namespace, gatewayB.Name)} + assertRetentionRecord(t, r, gatewayARecordKey, []string{"shared.example.com"}, "192.0.2.10") + assertRetentionRecord(t, r, gatewayBRecordKey, []string{"shared.example.com"}, "192.0.2.20") + return r, routeKey, gatewayARecordKey, gatewayBRecordKey +} + +func receiptSyncGateway(name, uid, address string, section gatewayv1.SectionName) *gatewayv1.Gateway { + allNamespaces := gatewayv1.NamespacesFromAll + return &gatewayv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{Namespace: "platform", Name: name, UID: types.UID(uid), Generation: 1}, + Spec: gatewayv1.GatewaySpec{ + GatewayClassName: "test-gateway", + Listeners: []gatewayv1.Listener{{ + Name: section, + AllowedRoutes: &gatewayv1.AllowedRoutes{Namespaces: &gatewayv1.RouteNamespaces{ + From: &allNamespaces, + }}, + }}, + }, + Status: gatewayv1.GatewayStatus{Addresses: []gatewayv1.GatewayStatusAddress{{Value: address}}}, + } +} + +func receiptSyncAcceptedParent(parent gatewayv1.ParentReference, generation int64) gatewayv1.RouteParentStatus { + return gatewayv1.RouteParentStatus{ + ParentRef: parent, + Conditions: []metav1.Condition{{ + Type: string(gatewayv1.RouteConditionAccepted), + Status: metav1.ConditionTrue, + ObservedGeneration: generation, + }}, + } +} + +func detachReceiptSyncGatewayA(t *testing.T, r *Reconciler, routeKey client.ObjectKey) { + t.Helper() + ctx := context.Background() + var route gatewayv1.HTTPRoute + if err := r.Get(ctx, routeKey, &route); err != nil { + t.Fatal(err) + } + route.Spec.ParentRefs = route.Spec.ParentRefs[1:] + route.Generation++ + if err := r.Update(ctx, &route); err != nil { + t.Fatal(err) + } + if err := r.Get(ctx, routeKey, &route); err != nil { + t.Fatal(err) + } + route.Status.Parents = []gatewayv1.RouteParentStatus{receiptSyncAcceptedParent(route.Spec.ParentRefs[0], route.Generation)} + if err := r.Status().Update(ctx, &route); err != nil { + t.Fatal(err) + } +} diff --git a/internal/go/apps/gatewayendpoint/controllers/gateway/retention_test.go b/internal/go/apps/gatewayendpoint/controllers/gateway/retention_test.go new file mode 100644 index 0000000..adb7027 --- /dev/null +++ b/internal/go/apps/gatewayendpoint/controllers/gateway/retention_test.go @@ -0,0 +1,314 @@ +package gateway + +import ( + "context" + "testing" + + endpointv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/endpoint/v1alpha1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" +) + +const testAdmissionReceiptAnnotation = "gateway.endpoint.dns.appthrust.io/admission-receipt" + +func TestGatewayEndpointRecordSetRetainsAdmittedSiblingDuringUnknownAndAddressLoss(t *testing.T) { + r, paymentKey, haKey, gatewayKey, recordKey := retentionFixture(t, true) + assertRetentionRecord(t, r, recordKey, []string{"ha.example.com", "payment.example.com"}, "192.0.2.1") + + setRouteAcceptance(t, r, haKey, metav1.ConditionUnknown) + setGatewayAddresses(t, r, gatewayKey, nil) + deleteRoute(t, r, paymentKey) + + assertRetentionRecord(t, r, recordKey, []string{"ha.example.com"}, "192.0.2.1") + + setRouteAcceptance(t, r, haKey, metav1.ConditionTrue) + setGatewayAddresses(t, r, gatewayKey, []gatewayv1.GatewayStatusAddress{{Value: "192.0.2.2"}}) + reconcileRetentionRoute(t, r, haKey) + assertRetentionRecord(t, r, recordKey, []string{"ha.example.com"}, "192.0.2.2") +} + +func TestGatewayEndpointRecordSetRevokesExplicitFalseAndForeignGateway(t *testing.T) { + t.Run("explicit false", func(t *testing.T) { + ctx := context.Background() + r, _, haKey, _, recordKey := retentionFixture(t, false) + setRouteAcceptance(t, r, haKey, metav1.ConditionFalse) + reconcileRetentionRoute(t, r, haKey) + + var record endpointv1alpha1.EndpointRecordSet + if err := r.Get(ctx, recordKey, &record); !apierrors.IsNotFound(err) { + t.Fatalf("explicitly rejected route retained DNS intent: %v", err) + } + }) + t.Run("gateway accepted false", func(t *testing.T) { + ctx := context.Background() + r, _, haKey, gatewayKey, recordKey := retentionFixture(t, false) + setGatewayAcceptance(t, r, gatewayKey, metav1.ConditionFalse) + setGatewayAddresses(t, r, gatewayKey, nil) + reconcileRetentionRoute(t, r, haKey) + + var record endpointv1alpha1.EndpointRecordSet + if err := r.Get(ctx, recordKey, &record); !apierrors.IsNotFound(err) { + t.Fatalf("explicitly rejected Gateway retained DNS intent: %v", err) + } + }) + t.Run("foreign gateway incarnation", func(t *testing.T) { + ctx := context.Background() + r, _, haKey, gatewayKey, recordKey := retentionFixture(t, false) + var gateway gatewayv1.Gateway + if err := r.Get(ctx, gatewayKey, &gateway); err != nil { + t.Fatal(err) + } + if err := r.Delete(ctx, &gateway); err != nil { + t.Fatal(err) + } + replacement := &gatewayv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{Namespace: gateway.Namespace, Name: gateway.Name, UID: types.UID("gateway-recreated"), Generation: 1}, + Spec: gateway.Spec, + Status: gatewayv1.GatewayStatus{Addresses: []gatewayv1.GatewayStatusAddress{{Value: "192.0.2.2"}}}, + } + if err := r.Create(ctx, replacement); err != nil { + t.Fatal(err) + } + reconcileRetentionRoute(t, r, haKey) + + var record endpointv1alpha1.EndpointRecordSet + if err := r.Get(ctx, recordKey, &record); !apierrors.IsNotFound(err) { + t.Fatalf("replacement Gateway inherited old targets: %v", err) + } + reconcileRetentionRoute(t, r, haKey) + assertRetentionRecord(t, r, recordKey, []string{"ha.example.com"}, "192.0.2.2") + }) + t.Run("changed listener ownership", func(t *testing.T) { + ctx := context.Background() + r, _, haKey, gatewayKey, recordKey := retentionFixture(t, false) + var gateway gatewayv1.Gateway + if err := r.Get(ctx, gatewayKey, &gateway); err != nil { + t.Fatal(err) + } + sameNamespace := gatewayv1.NamespacesFromSame + gateway.Spec.Listeners[0].AllowedRoutes.Namespaces.From = &sameNamespace + if err := r.Update(ctx, &gateway); err != nil { + t.Fatal(err) + } + reconcileRetentionRoute(t, r, haKey) + + var record endpointv1alpha1.EndpointRecordSet + if err := r.Get(ctx, recordKey, &record); !apierrors.IsNotFound(err) { + t.Fatalf("changed listener ownership retained DNS intent: %v", err) + } + }) +} + +func TestGatewayEndpointRecordSetDoesNotPublishUnreceiptedHostnameDuringAddressLoss(t *testing.T) { + ctx := context.Background() + r, _, haKey, gatewayKey, recordKey := retentionFixture(t, false) + setGatewayAddresses(t, r, gatewayKey, nil) + + pending := retentionRoute("app", "pending", "pending-route", "pending.example.com", metav1.ConditionUnknown) + if err := r.Create(ctx, pending); err != nil { + t.Fatal(err) + } + reconcileRetentionRoute(t, r, client.ObjectKeyFromObject(pending)) + + assertRetentionRecord(t, r, recordKey, []string{"ha.example.com"}, "192.0.2.1") + var record endpointv1alpha1.EndpointRecordSet + if err := r.Get(ctx, recordKey, &record); err != nil { + t.Fatal(err) + } + for _, hostname := range record.Spec.Hostnames { + if hostname == "pending.example.com" { + t.Fatal("unaccepted hostname inherited an existing EndpointRecordSet target") + } + } + + setRouteAcceptance(t, r, haKey, metav1.ConditionTrue) + setGatewayAddresses(t, r, gatewayKey, []gatewayv1.GatewayStatusAddress{{Value: "192.0.2.2"}}) + reconcileRetentionRoute(t, r, haKey) + assertRetentionRecord(t, r, recordKey, []string{"ha.example.com"}, "192.0.2.2") +} + +func TestGatewayEndpointRecordSetDoesNotRetainWithoutReceiptOrSelectorProof(t *testing.T) { + for _, test := range []struct { + name string + policy gatewayv1.FromNamespaces + legacy bool + }{ + {name: "legacy receipt", policy: gatewayv1.NamespacesFromAll, legacy: true}, + {name: "dynamic namespace selector", policy: gatewayv1.NamespacesFromSelector}, + } { + t.Run(test.name, func(t *testing.T) { + ctx := context.Background() + r, _, haKey, gatewayKey, recordKey := retentionFixtureWithNamespacePolicy(t, false, test.policy) + if test.legacy { + var record endpointv1alpha1.EndpointRecordSet + if err := r.Get(ctx, recordKey, &record); err != nil { + t.Fatal(err) + } + if record.Annotations[testAdmissionReceiptAnnotation] == "" { + t.Fatal("EndpointRecordSet did not record its admitted ownership bindings") + } + delete(record.Annotations, testAdmissionReceiptAnnotation) + if err := r.Update(ctx, &record); err != nil { + t.Fatal(err) + } + } + setRouteAcceptance(t, r, haKey, metav1.ConditionUnknown) + setGatewayAddresses(t, r, gatewayKey, nil) + reconcileRetentionRoute(t, r, haKey) + + var record endpointv1alpha1.EndpointRecordSet + if err := r.Get(ctx, recordKey, &record); !apierrors.IsNotFound(err) { + t.Fatalf("unproven retained DNS intent: %v", err) + } + }) + } +} + +func retentionFixture(t *testing.T, includePayment bool) (*Reconciler, client.ObjectKey, client.ObjectKey, client.ObjectKey, client.ObjectKey) { + return retentionFixtureWithNamespacePolicy(t, includePayment, gatewayv1.NamespacesFromAll) +} + +func retentionFixtureWithNamespacePolicy(t *testing.T, includePayment bool, namespacePolicy gatewayv1.FromNamespaces) (*Reconciler, client.ObjectKey, client.ObjectKey, client.ObjectKey, client.ObjectKey) { + t.Helper() + routeNamespaces := &gatewayv1.RouteNamespaces{From: &namespacePolicy} + if namespacePolicy == gatewayv1.NamespacesFromSelector { + routeNamespaces.Selector = &metav1.LabelSelector{MatchLabels: map[string]string{"dns-api.example/tenant": "app"}} + } + gateway := &gatewayv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{Namespace: "platform", Name: "public", UID: types.UID("gateway-original"), Generation: 1}, + Spec: gatewayv1.GatewaySpec{ + GatewayClassName: "test-gateway", + Listeners: []gatewayv1.Listener{{ + Name: "https", + AllowedRoutes: &gatewayv1.AllowedRoutes{Namespaces: routeNamespaces}, + }}, + }, + Status: gatewayv1.GatewayStatus{Addresses: []gatewayv1.GatewayStatusAddress{{Value: "192.0.2.1"}}}, + } + ha := retentionRoute("app", "ha", "ha-route", "ha.example.com", metav1.ConditionTrue) + objects := []client.Object{gateway, ha} + paymentKey := client.ObjectKey{} + if includePayment { + payment := retentionRoute("app", "payment", "payment-route", "payment.example.com", metav1.ConditionTrue) + objects = append(objects, payment) + paymentKey = client.ObjectKeyFromObject(payment) + } + k8sClient := fake.NewClientBuilder().WithScheme(testScheme(t)).WithStatusSubresource(ha, gateway).WithObjects(objects...).Build() + r := &Reconciler{Client: k8sClient, EndpointRecordSetNamespace: "dns-system"} + if includePayment { + reconcileRetentionRoute(t, r, paymentKey) + } + reconcileRetentionRoute(t, r, client.ObjectKeyFromObject(ha)) + return r, paymentKey, client.ObjectKeyFromObject(ha), client.ObjectKeyFromObject(gateway), client.ObjectKey{Namespace: "dns-system", Name: generatedGatewayEndpointRecordSetName("platform", "public")} +} + +func retentionRoute(namespace, name, uid, hostname string, status metav1.ConditionStatus) *gatewayv1.HTTPRoute { + section := gatewayv1.SectionName("https") + gatewayNamespace := gatewayv1.Namespace("platform") + return &gatewayv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: name, UID: types.UID(uid), Generation: 1}, + Spec: gatewayv1.HTTPRouteSpec{ + CommonRouteSpec: gatewayv1.CommonRouteSpec{ParentRefs: []gatewayv1.ParentReference{{ + Namespace: &gatewayNamespace, + Name: "public", + SectionName: §ion, + }}}, + Hostnames: []gatewayv1.Hostname{gatewayv1.Hostname(hostname)}, + }, + Status: gatewayv1.HTTPRouteStatus{RouteStatus: gatewayv1.RouteStatus{Parents: []gatewayv1.RouteParentStatus{{ + ParentRef: gatewayv1.ParentReference{Namespace: &gatewayNamespace, Name: "public", SectionName: §ion}, + Conditions: []metav1.Condition{{ + Type: string(gatewayv1.RouteConditionAccepted), + Status: status, + ObservedGeneration: 1, + }}, + }}}}, + } +} + +func reconcileRetentionRoute(t *testing.T, r *Reconciler, key client.ObjectKey) { + t.Helper() + if _, err := r.Reconcile(context.Background(), reconcile.Request{NamespacedName: key}); err != nil { + t.Fatal(err) + } +} + +func setRouteAcceptance(t *testing.T, r *Reconciler, key client.ObjectKey, status metav1.ConditionStatus) { + t.Helper() + ctx := context.Background() + var route gatewayv1.HTTPRoute + if err := r.Get(ctx, key, &route); err != nil { + t.Fatal(err) + } + route.Status.Parents[0].Conditions[0].Status = status + route.Status.Parents[0].Conditions[0].ObservedGeneration = route.Generation + if err := r.Status().Update(ctx, &route); err != nil { + t.Fatal(err) + } +} +func setGatewayAcceptance(t *testing.T, r *Reconciler, key client.ObjectKey, status metav1.ConditionStatus) { + t.Helper() + ctx := context.Background() + var gateway gatewayv1.Gateway + if err := r.Get(ctx, key, &gateway); err != nil { + t.Fatal(err) + } + gateway.Status.Conditions = []metav1.Condition{{ + Type: string(gatewayv1.GatewayConditionAccepted), + Status: status, + ObservedGeneration: gateway.Generation, + }} + if err := r.Status().Update(ctx, &gateway); err != nil { + t.Fatal(err) + } +} + +func setGatewayAddresses(t *testing.T, r *Reconciler, key client.ObjectKey, addresses []gatewayv1.GatewayStatusAddress) { + t.Helper() + ctx := context.Background() + var gateway gatewayv1.Gateway + if err := r.Get(ctx, key, &gateway); err != nil { + t.Fatal(err) + } + gateway.Status.Addresses = addresses + if err := r.Status().Update(ctx, &gateway); err != nil { + t.Fatal(err) + } +} + +func deleteRoute(t *testing.T, r *Reconciler, key client.ObjectKey) { + t.Helper() + ctx := context.Background() + var route gatewayv1.HTTPRoute + if err := r.Get(ctx, key, &route); err != nil { + t.Fatal(err) + } + if err := r.Delete(ctx, &route); err != nil { + t.Fatal(err) + } + reconcileRetentionRoute(t, r, key) +} + +func assertRetentionRecord(t *testing.T, r *Reconciler, key client.ObjectKey, hostnames []string, target string) { + t.Helper() + var record endpointv1alpha1.EndpointRecordSet + if err := r.Get(context.Background(), key, &record); err != nil { + t.Fatal(err) + } + if len(record.Spec.Hostnames) != len(hostnames) { + t.Fatalf("hostnames = %#v, want %#v", record.Spec.Hostnames, hostnames) + } + for i, hostname := range hostnames { + if record.Spec.Hostnames[i] != hostname { + t.Fatalf("hostnames = %#v, want %#v", record.Spec.Hostnames, hostnames) + } + } + if len(record.Spec.Targets) != 1 || record.Spec.Targets[0].Value != target { + t.Fatalf("targets = %#v, want %q", record.Spec.Targets, target) + } +}