Files
kamaji/internal/resources/k8s_gateway_utils.go
Parth Yadav 87242ff005 feat: extend Gateway API support to Konnectivity addons (#1054)
This change extends Gateway API support to Konnectivity addons.
When `spec.controlPlane.gateway` is configured and Konnectivity addon is
enabled, Kamaji automatically creates two TLSRoutes:
1. A Control plane TLSRoute (port 6443, sectionName "kube-apiserver")
2. A Konnectivity TLSRoute (port 8132, sectionName "konnectivity-server")

Both routes use the hostname specified in `gateway.hostname` and reference
the same Gateway resource via `parentRefs`, with `port` and `sectionName`
set automatically by Kamaji.

This patch also adds CEL validation to prevent users from specifying
`port` or `sectionName` in Gateway `parentRefs`, as these fields are now
managed automatically by Kamaji.

Signed-off-by: Parth Yadav <parth@coredge.io>
2026-01-11 11:31:24 +01:00

242 lines
8.5 KiB
Go

// Copyright 2022 Clastix Labs
// SPDX-License-Identifier: Apache-2.0
package resources
import (
"context"
"fmt"
"net/url"
k8serrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/fields"
"sigs.k8s.io/controller-runtime/pkg/client"
gatewayv1 "sigs.k8s.io/gateway-api/apis/v1"
gatewayv1alpha2 "sigs.k8s.io/gateway-api/apis/v1alpha2"
kamajiv1alpha1 "github.com/clastix/kamaji/api/v1alpha1"
)
// fetchGatewayByListener uses the indexer to efficiently find a gateway with a specific listener.
// This avoids the need to iterate through all listeners in a gateway.
func fetchGatewayByListener(ctx context.Context, c client.Client, ref gatewayv1.ParentReference) (*gatewayv1.Gateway, error) {
if ref.SectionName == nil {
return nil, fmt.Errorf("missing sectionName")
}
// Build the composite key that matches our indexer format: namespace/gatewayName/listenerName
listenerKey := fmt.Sprintf("%s/%s/%s", *ref.Namespace, ref.Name, *ref.SectionName)
// Query gateways using the indexer
gatewayList := &gatewayv1.GatewayList{}
if err := c.List(ctx, gatewayList, client.MatchingFieldsSelector{
Selector: fields.OneTermEqualSelector(kamajiv1alpha1.GatewayListenerNameKey, listenerKey),
}); err != nil {
return nil, fmt.Errorf("failed to list gateways by listener: %w", err)
}
if len(gatewayList.Items) == 0 {
return nil, fmt.Errorf("no gateway found with listener '%s'", *ref.SectionName)
}
// Since we're using a composite key with namespace/name/listener, we should get exactly one result
if len(gatewayList.Items) > 1 {
return nil, fmt.Errorf("found multiple gateways with listener '%s', expected exactly one", *ref.SectionName)
}
return &gatewayList.Items[0], nil
}
// FindMatchingListener finds a listener in the given list that matches the parent reference.
func FindMatchingListener(listeners []gatewayv1.Listener, ref gatewayv1.ParentReference) (gatewayv1.Listener, error) {
if ref.SectionName == nil {
return gatewayv1.Listener{}, fmt.Errorf("missing sectionName")
}
name := *ref.SectionName
for _, listener := range listeners {
if listener.Name == name {
return listener, nil
}
}
// TODO: Handle the cases according to the spec:
// - When both Port (experimental) and SectionName are
// specified, the name and port of the selected listener
// must match both specified values.
// - When unspecified (empty string) this will reference
// the entire resource [...] an attachment is considered
// successful if at least one section in the parent resource accepts it
return gatewayv1.Listener{}, fmt.Errorf("could not find listener '%s'", name)
}
// IsGatewayRouteStatusChanged checks if the gateway route status has changed compared to the stored status.
// Returns true if the status has changed (update needed), false if it's the same.
func IsGatewayRouteStatusChanged(currentStatus *kamajiv1alpha1.KubernetesGatewayStatus, resourceStatus gatewayv1alpha2.RouteStatus) bool {
if currentStatus == nil {
return true
}
// Compare RouteStatus - check if number of parents changed
if len(currentStatus.RouteStatus.Parents) != len(resourceStatus.Parents) {
return true
}
// Compare individual parent statuses
// NOTE: Multiple Parent References are assumed.
for i, currentParent := range currentStatus.RouteStatus.Parents {
if i >= len(resourceStatus.Parents) {
return true
}
resourceParent := resourceStatus.Parents[i]
// Compare parent references
if currentParent.ParentRef.Name != resourceParent.ParentRef.Name ||
(currentParent.ParentRef.Namespace == nil) != (resourceParent.ParentRef.Namespace == nil) ||
(currentParent.ParentRef.Namespace != nil && resourceParent.ParentRef.Namespace != nil &&
*currentParent.ParentRef.Namespace != *resourceParent.ParentRef.Namespace) ||
(currentParent.ParentRef.SectionName == nil) != (resourceParent.ParentRef.SectionName == nil) ||
(currentParent.ParentRef.SectionName != nil && resourceParent.ParentRef.SectionName != nil &&
*currentParent.ParentRef.SectionName != *resourceParent.ParentRef.SectionName) {
return true
}
if len(currentParent.Conditions) != len(resourceParent.Conditions) {
return true
}
// Compare each condition
for j, currentCondition := range currentParent.Conditions {
if j >= len(resourceParent.Conditions) {
return true
}
resourceCondition := resourceParent.Conditions[j]
if currentCondition.Type != resourceCondition.Type ||
currentCondition.Status != resourceCondition.Status ||
currentCondition.Reason != resourceCondition.Reason ||
currentCondition.Message != resourceCondition.Message ||
!currentCondition.LastTransitionTime.Equal(&resourceCondition.LastTransitionTime) {
return true
}
}
}
// Since access points are derived from route status and gateway conditions,
// and we've already compared the route status above, we can assume that
// if the route status hasn't changed, the access points calculation
// will produce the same result. This avoids the need for complex
// gateway fetching in the status comparison.
//
// If there are edge cases where gateway state changes but route status doesn't,
// those will be caught in the next reconciliation cycle anyway.
return false
}
// CleanupTLSRoute cleans up a TLSRoute resource if it's managed by the given TenantControlPlane.
func CleanupTLSRoute(ctx context.Context, c client.Client, routeName, routeNamespace string, tcp metav1.Object) (bool, error) {
route := gatewayv1alpha2.TLSRoute{}
if err := c.Get(ctx, client.ObjectKey{
Namespace: routeNamespace,
Name: routeName,
}, &route); err != nil {
if !k8serrors.IsNotFound(err) {
return false, fmt.Errorf("failed to get TLSRoute before cleanup: %w", err)
}
return false, nil
}
if !metav1.IsControlledBy(&route, tcp) {
return false, nil
}
if err := c.Delete(ctx, &route); err != nil {
if !k8serrors.IsNotFound(err) {
return false, fmt.Errorf("cannot delete TLSRoute route: %w", err)
}
return false, nil
}
return true, nil
}
// BuildGatewayAccessPointsStatus builds access points from route statuses.
func BuildGatewayAccessPointsStatus(ctx context.Context, c client.Client, route *gatewayv1alpha2.TLSRoute, routeStatuses gatewayv1alpha2.RouteStatus) ([]kamajiv1alpha1.GatewayAccessPoint, error) {
accessPoints := []kamajiv1alpha1.GatewayAccessPoint{}
routeNamespace := gatewayv1.Namespace(route.Namespace)
for _, routeStatus := range routeStatuses.Parents {
routeAccepted := meta.IsStatusConditionTrue(
routeStatus.Conditions,
string(gatewayv1.RouteConditionAccepted),
)
if !routeAccepted {
continue
}
if routeStatus.ParentRef.Namespace == nil {
// Set the namespace to the route namespace if not set
routeStatus.ParentRef.Namespace = &routeNamespace
}
// Use the indexer to efficiently find the gateway with the specific listener
gateway, err := fetchGatewayByListener(ctx, c, routeStatus.ParentRef)
if err != nil {
return nil, fmt.Errorf("could not fetch gateway with listener '%v': %w",
routeStatus.ParentRef.SectionName, err)
}
gatewayProgrammed := meta.IsStatusConditionTrue(
gateway.Status.Conditions,
string(gatewayv1.GatewayConditionProgrammed),
)
if !gatewayProgrammed {
continue
}
// Since we fetched the gateway using the indexer, we know the listener exists
// but we still need to get its details from the gateway spec
listener, err := FindMatchingListener(
gateway.Spec.Listeners, routeStatus.ParentRef,
)
if err != nil {
return nil, fmt.Errorf("failed to match listener: %w", err)
}
for _, hostname := range route.Spec.Hostnames {
rawURL := fmt.Sprintf("https://%s:%d", hostname, listener.Port)
parsedURL, err := url.Parse(rawURL)
if err != nil {
return nil, fmt.Errorf("invalid url: %w", err)
}
hostnameAddressType := gatewayv1.HostnameAddressType
accessPoints = append(accessPoints, kamajiv1alpha1.GatewayAccessPoint{
Type: &hostnameAddressType,
Value: parsedURL.String(),
Port: listener.Port,
})
}
}
return accessPoints, nil
}
// NewParentRefsSpecWithPortAndSection creates a copy of parentRefs with port and sectionName set for each reference.
func NewParentRefsSpecWithPortAndSection(parentRefs []gatewayv1.ParentReference, port int32, sectionName string) []gatewayv1.ParentReference {
result := make([]gatewayv1.ParentReference, len(parentRefs))
sectionNamePtr := gatewayv1.SectionName(sectionName)
for i, parentRef := range parentRefs {
result[i] = *parentRef.DeepCopy()
result[i].Port = &port
result[i].SectionName = &sectionNamePtr
}
return result
}