diff --git a/cmd/main.go b/cmd/main.go new file mode 100644 index 0000000..ed1282c --- /dev/null +++ b/cmd/main.go @@ -0,0 +1,120 @@ +/* +Copyright 2025 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package main + +import ( + "context" + "flag" + "time" + + kubeinformers "k8s.io/client-go/informers" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/tools/clientcmd" + "k8s.io/klog/v2" + + gatewayclient "sigs.k8s.io/gateway-api/pkg/client/clientset/versioned" + gatewayinformers "sigs.k8s.io/gateway-api/pkg/client/informers/externalversions" + agenticclient "sigs.k8s.io/kube-agentic-networking/k8s/client/clientset/versioned" + agenticinformers "sigs.k8s.io/kube-agentic-networking/k8s/client/informers/externalversions" + "sigs.k8s.io/kube-agentic-networking/pkg/controller" + discovery "sigs.k8s.io/kube-agentic-networking/pkg/dicovery" +) + +const ( + workerCount = 2 +) + +var ( + masterURL string + kubeconfig string +) + +func main() { + klog.InitFlags(nil) + flag.Parse() + + // set up signals so we handle the shutdown signal gracefully + ctx := context.Background() + logger := klog.FromContext(ctx) + + cfg, err := clientcmd.BuildConfigFromFlags(masterURL, kubeconfig) + if err != nil { + logger.Error(err, "Error building kubeconfig") + klog.FlushAndExit(klog.ExitFlushTimeout, 1) + } + + kubeClient, err := kubernetes.NewForConfig(cfg) + if err != nil { + logger.Error(err, "Error building kubernetes clientset") + klog.FlushAndExit(klog.ExitFlushTimeout, 1) + } + + gatewayClientset, err := gatewayclient.NewForConfig(cfg) + if err != nil { + logger.Error(err, "Error building Gateway API clientset") + klog.FlushAndExit(klog.ExitFlushTimeout, 1) + } + + agenticClientset, err := agenticclient.NewForConfig(cfg) + if err != nil { + logger.Error(err, "Error building Agentic Networking clientset") + klog.FlushAndExit(klog.ExitFlushTimeout, 1) + } + sharedKubeInformers := kubeinformers.NewSharedInformerFactory(kubeClient, 60*time.Second) + sharedGwInformers := gatewayinformers.NewSharedInformerFactory(gatewayClientset, 60*time.Second) + sharedAgenticInformers := agenticinformers.NewSharedInformerFactory(agenticClientset, 60*time.Second) + + jwtIssuer, err := discovery.JWTIssuer(cfg) + if err != nil { + logger.Error(err, "Error discovering JWT issuer") + klog.FlushAndExit(klog.ExitFlushTimeout, 1) + } + c, err := controller.New( + ctx, + jwtIssuer, + kubeClient, + gatewayClientset, + agenticClientset, + sharedKubeInformers.Core().V1().Namespaces(), + sharedKubeInformers.Core().V1().Services(), + sharedKubeInformers.Core().V1().Secrets(), + sharedGwInformers.Gateway().V1().GatewayClasses(), + sharedGwInformers.Gateway().V1().Gateways(), + sharedGwInformers.Gateway().V1().HTTPRoutes(), + sharedAgenticInformers.Agentic().V0alpha0().XBackends(), + sharedAgenticInformers.Agentic().V0alpha0().XAccessPolicies()) + if err != nil { + logger.Error(err, "Error creating controller") + klog.FlushAndExit(klog.ExitFlushTimeout, 1) + } + + // notice that there is no need to run Start methods in a separate goroutine. (i.e. go kubeInformerFactory.Start(ctx.done()) + // Start method is non-blocking and runs all registered informers in a dedicated goroutine. + sharedKubeInformers.Start(ctx.Done()) + sharedGwInformers.Start(ctx.Done()) + sharedAgenticInformers.Start(ctx.Done()) + + if err = c.Run(ctx, workerCount); err != nil { + logger.Error(err, "Error running controller") + klog.FlushAndExit(klog.ExitFlushTimeout, 1) + } +} + +func init() { + flag.StringVar(&kubeconfig, "kubeconfig", "", "Path to a kubeconfig. Only required if out-of-cluster.") + flag.StringVar(&masterURL, "master", "", "The address of the Kubernetes API server. Overrides any value in kubeconfig. Only required if out-of-cluster.") +} diff --git a/go.mod b/go.mod index b1a59d4..7048d70 100644 --- a/go.mod +++ b/go.mod @@ -3,14 +3,22 @@ module sigs.k8s.io/kube-agentic-networking go 1.24.8 require ( + github.com/envoyproxy/go-control-plane v0.14.0 + k8s.io/api v0.34.1 k8s.io/apimachinery v0.34.2 k8s.io/client-go v0.34.1 + k8s.io/klog/v2 v2.130.1 sigs.k8s.io/gateway-api v1.4.0 ) require ( + cel.dev/expr v0.24.0 // indirect + github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/emicklei/go-restful/v3 v3.13.0 // indirect + github.com/envoyproxy/go-control-plane/envoy v1.36.0 // indirect + github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 // indirect + github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-openapi/jsonpointer v0.21.2 // indirect @@ -26,7 +34,9 @@ require ( github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/spf13/pflag v1.0.7 // indirect github.com/x448/float16 v0.8.4 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect @@ -36,12 +46,13 @@ require ( golang.org/x/term v0.34.0 // indirect golang.org/x/text v0.28.0 // indirect golang.org/x/time v0.12.0 // indirect - google.golang.org/protobuf v1.36.8 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250728155136-f173205681a0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250826171959-ef028d996bc1 // indirect + google.golang.org/grpc v1.75.1 // indirect + google.golang.org/protobuf v1.36.10 // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - k8s.io/api v0.34.1 // indirect - k8s.io/klog/v2 v2.130.1 // indirect k8s.io/kube-openapi v0.0.0-20250814151709-d7b6acb124c3 // indirect k8s.io/utils v0.0.0-20250820121507-0af2bda4dd1d // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect diff --git a/go.sum b/go.sum index 8a3ba30..158dfff 100644 --- a/go.sum +++ b/go.sum @@ -1,13 +1,27 @@ +cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY= +cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= +github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 h1:aQ3y1lwWyqYPiWZThqv1aFbZMiM9vblcSArJRf2Irls= +github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes= github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= +github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU= +github.com/envoyproxy/go-control-plane/envoy v1.36.0 h1:yg/JjO5E7ubRyKX3m07GF3reDNEnfOboJ0QySbH736g= +github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98= +github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI= +github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= +github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8= +github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-openapi/jsonpointer v0.21.2 h1:AqQaNADVwq/VnkCmQg6ogE+M3FOsKTytwges0JdwVuA= github.com/go-openapi/jsonpointer v0.21.2/go.mod h1:50I1STOfbY1ycR8jGz8DaMeLCdXiI6aDteEdRNNzpdk= github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= @@ -18,6 +32,8 @@ github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1v github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= @@ -51,6 +67,8 @@ github.com/onsi/ginkgo/v2 v2.21.0 h1:7rg/4f3rB88pb5obDgNZrNHrQ4e6WpjonchcpuBRnZM github.com/onsi/ginkgo/v2 v2.21.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo= github.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4= github.com/onsi/gomega v1.35.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -62,12 +80,24 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.11.0 h1:ib4sjIrwZKxE5u/Japgo/7SJV3PvgjGiRNAvTVGqQl8= -github.com/stretchr/testify v1.11.0/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= +go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= +go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= +go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= +go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI= +go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg= +go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc= +go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps= +go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= +go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= @@ -113,8 +143,16 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= -google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/genproto/googleapis/api v0.0.0-20250728155136-f173205681a0 h1:0UOBWO4dC+e51ui0NFKSPbkHHiQ4TmrEfEZMLDyRmY8= +google.golang.org/genproto/googleapis/api v0.0.0-20250728155136-f173205681a0/go.mod h1:8ytArBbtOy2xfht+y2fqKd5DRDJRUQhqbyEnQ4bDChs= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250826171959-ef028d996bc1 h1:pmJpJEvT846VzausCQ5d7KreSROcDqmO388w5YbnltA= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250826171959-ef028d996bc1/go.mod h1:GmFNa4BdJZ2a8G+wCe9Bg3wwThLrJun751XstdJt5Og= +google.golang.org/grpc v1.75.1 h1:/ODCNEuf9VghjgO3rqLcfg8fiOP0nSluljWFlDxELLI= +google.golang.org/grpc v1.75.1/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/hack/verify-golint.sh b/hack/verify-golint.sh index 66a5279..4f546bb 100755 --- a/hack/verify-golint.sh +++ b/hack/verify-golint.sh @@ -38,7 +38,7 @@ for module in $(find . -name "go.mod" | xargs -n1 dirname); do -e GOLANGCI_LINT_CACHE=/cache \ -e GOFLAGS="-buildvcs=false" \ "golangci/golangci-lint:$VERSION" \ - golangci-lint run || failed=true + golangci-lint run --timeout=5m || failed=true done if ${failed}; then diff --git a/pkg/controller/accesspolicy.go b/pkg/controller/accesspolicy.go new file mode 100644 index 0000000..c8fc9f1 --- /dev/null +++ b/pkg/controller/accesspolicy.go @@ -0,0 +1,72 @@ +/* +Copyright 2025 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controller + +import ( + "fmt" + + "k8s.io/apimachinery/pkg/util/runtime" + "k8s.io/client-go/tools/cache" + "k8s.io/klog/v2" + + agenticv0alpha0 "sigs.k8s.io/kube-agentic-networking/api/v0alpha0" + agenticinformers "sigs.k8s.io/kube-agentic-networking/k8s/client/informers/externalversions/api/v0alpha0" +) + +func (c *Controller) setupAccessPolicyEventHandlers(accessPolicyInformer agenticinformers.XAccessPolicyInformer) error { + _, err := accessPolicyInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{ + AddFunc: c.onAccessPolicyAdd, + UpdateFunc: c.onAccessPolicyUpdate, + DeleteFunc: c.onAccessPolicyDelete, + }) + return err +} + +func (c *Controller) onAccessPolicyAdd(obj interface{}) { + policy := obj.(*agenticv0alpha0.XAccessPolicy) + klog.V(4).InfoS("Adding AccessPolicy", "accesspolicy", klog.KObj(policy)) + c.enqueueGatewaysForAccessPolicy(policy) +} + +func (c *Controller) onAccessPolicyUpdate(old, new interface{}) { + oldPolicy := old.(*agenticv0alpha0.XAccessPolicy) + newPolicy := new.(*agenticv0alpha0.XAccessPolicy) + klog.V(4).InfoS("Updating AccessPolicy", "accesspolicy", klog.KObj(oldPolicy)) + c.enqueueGatewaysForAccessPolicy(newPolicy) +} + +func (c *Controller) onAccessPolicyDelete(obj interface{}) { + policy, ok := obj.(*agenticv0alpha0.XAccessPolicy) + if !ok { + tombstone, ok := obj.(cache.DeletedFinalStateUnknown) + if !ok { + runtime.HandleError(fmt.Errorf("couldn't get object from tombstone %#v", obj)) + return + } + policy, ok = tombstone.Obj.(*agenticv0alpha0.XAccessPolicy) + if !ok { + runtime.HandleError(fmt.Errorf("tombstone contained object that is not a AccessPolicy %#v", obj)) + return + } + } + klog.V(4).InfoS("Deleting AccessPolicy", "accesspolicy", klog.KObj(policy)) + c.enqueueGatewaysForAccessPolicy(policy) +} + +func (c *Controller) enqueueGatewaysForAccessPolicy(policy *agenticv0alpha0.XAccessPolicy) { + // TODO: Find the Backends that are targeted by this AccessPolicy, then find the HTTPRoutes that reference those Backends, then find the Gateways that reference those HTTPRoutes, and enqueue them. +} diff --git a/pkg/controller/backend.go b/pkg/controller/backend.go new file mode 100644 index 0000000..ccfca20 --- /dev/null +++ b/pkg/controller/backend.go @@ -0,0 +1,72 @@ +/* +Copyright 2025 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controller + +import ( + "fmt" + + "k8s.io/apimachinery/pkg/util/runtime" + "k8s.io/client-go/tools/cache" + "k8s.io/klog/v2" + + agenticv0alpha0 "sigs.k8s.io/kube-agentic-networking/api/v0alpha0" + agenticinformers "sigs.k8s.io/kube-agentic-networking/k8s/client/informers/externalversions/api/v0alpha0" +) + +func (c *Controller) setupBackendEventHandlers(backendInformer agenticinformers.XBackendInformer) error { + _, err := backendInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{ + AddFunc: c.onBackendAdd, + UpdateFunc: c.onBackendUpdate, + DeleteFunc: c.onBackendDelete, + }) + return err +} + +func (c *Controller) onBackendAdd(obj interface{}) { + backend := obj.(*agenticv0alpha0.XBackend) + klog.V(4).InfoS("Adding Backend", "backend", klog.KObj(backend)) + c.enqueueGatewaysForBackend(backend) +} + +func (c *Controller) onBackendUpdate(old, new interface{}) { + oldBackend := old.(*agenticv0alpha0.XBackend) + newBackend := new.(*agenticv0alpha0.XBackend) + klog.V(4).InfoS("Updating Backend", "backend", klog.KObj(oldBackend)) + c.enqueueGatewaysForBackend(newBackend) +} + +func (c *Controller) onBackendDelete(obj interface{}) { + backend, ok := obj.(*agenticv0alpha0.XBackend) + if !ok { + tombstone, ok := obj.(cache.DeletedFinalStateUnknown) + if !ok { + runtime.HandleError(fmt.Errorf("couldn't get object from tombstone %#v", obj)) + return + } + backend, ok = tombstone.Obj.(*agenticv0alpha0.XBackend) + if !ok { + runtime.HandleError(fmt.Errorf("tombstone contained object that is not a Backend %#v", obj)) + return + } + } + klog.V(4).InfoS("Deleting Backend", "backend", klog.KObj(backend)) + c.enqueueGatewaysForBackend(backend) +} + +func (c *Controller) enqueueGatewaysForBackend(backend *agenticv0alpha0.XBackend) { + // TODO: Find the HTTPRoutes that reference this Backend, then find the Gateways that reference those HTTPRoutes, and enqueue them. +} diff --git a/pkg/controller/controller.go b/pkg/controller/controller.go new file mode 100644 index 0000000..6d7d79f --- /dev/null +++ b/pkg/controller/controller.go @@ -0,0 +1,265 @@ +/* +Copyright 2025 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controller + +import ( + "context" + "errors" + "fmt" + "time" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/util/runtime" + "k8s.io/apimachinery/pkg/util/wait" + corev1informers "k8s.io/client-go/informers/core/v1" + "k8s.io/client-go/kubernetes" + corev1listers "k8s.io/client-go/listers/core/v1" + "k8s.io/client-go/tools/cache" + "k8s.io/client-go/util/workqueue" + "k8s.io/klog/v2" + + gatewayclient "sigs.k8s.io/gateway-api/pkg/client/clientset/versioned" + gatewayinformers "sigs.k8s.io/gateway-api/pkg/client/informers/externalversions/apis/v1" + gatewaylisters "sigs.k8s.io/gateway-api/pkg/client/listers/apis/v1" + + agenticclient "sigs.k8s.io/kube-agentic-networking/k8s/client/clientset/versioned" + agenticinformers "sigs.k8s.io/kube-agentic-networking/k8s/client/informers/externalversions/api/v0alpha0" + agenticlisters "sigs.k8s.io/kube-agentic-networking/k8s/client/listers/api/v0alpha0" +) + +const ( + controllerName = "sig.k8s.io/kube-agentic-networking-controller" +) + +type coreResources struct { + client kubernetes.Interface + + nsLister corev1listers.NamespaceLister + nsSynced cache.InformerSynced + + svcLister corev1listers.ServiceLister + svcSynced cache.InformerSynced + + secretLister corev1listers.SecretLister + secretSynced cache.InformerSynced +} + +type gatewayResources struct { + client gatewayclient.Interface + + gatewayClassLister gatewaylisters.GatewayClassLister + gatewayClassSynced cache.InformerSynced + + gatewayLister gatewaylisters.GatewayLister + gatewaySynced cache.InformerSynced + + httprouteLister gatewaylisters.HTTPRouteLister + httprouteSynced cache.InformerSynced +} + +type agenticNetResources struct { + client agenticclient.Interface + + backendLister agenticlisters.XBackendLister + backendSynced cache.InformerSynced + + accessPolicyLister agenticlisters.XAccessPolicyLister + accessPolicySynced cache.InformerSynced +} + +// Controller is the controller implementation for Gateway resources +type Controller struct { + core coreResources + gateway gatewayResources + agentic agenticNetResources + + jwtIssuer string + + gatewayqueue workqueue.TypedRateLimitingInterface[string] +} + +// New returns a new *Controller with the event handlers setup for types we are interested in. +func New( + ctx context.Context, + jwtIssuer string, + kubeClientSet kubernetes.Interface, + gwClientSet gatewayclient.Interface, + agenticClientSet agenticclient.Interface, + namespaceInformer corev1informers.NamespaceInformer, + serviceInformer corev1informers.ServiceInformer, + secretInformer corev1informers.SecretInformer, + gatewayClassInformer gatewayinformers.GatewayClassInformer, + gatewayInformer gatewayinformers.GatewayInformer, + httprouteInformer gatewayinformers.HTTPRouteInformer, + backendInformer agenticinformers.XBackendInformer, + accessPolicyInformer agenticinformers.XAccessPolicyInformer, +) (*Controller, error) { + c := &Controller{ + core: coreResources{ + client: kubeClientSet, + nsLister: namespaceInformer.Lister(), + nsSynced: namespaceInformer.Informer().HasSynced, + svcLister: serviceInformer.Lister(), + svcSynced: serviceInformer.Informer().HasSynced, + secretLister: secretInformer.Lister(), + secretSynced: secretInformer.Informer().HasSynced, + }, + gateway: gatewayResources{ + client: gwClientSet, + gatewayClassLister: gatewayClassInformer.Lister(), + gatewayClassSynced: gatewayClassInformer.Informer().HasSynced, + gatewayLister: gatewayInformer.Lister(), + gatewaySynced: gatewayInformer.Informer().HasSynced, + httprouteLister: httprouteInformer.Lister(), + httprouteSynced: httprouteInformer.Informer().HasSynced, + }, + agentic: agenticNetResources{ + client: agenticClientSet, + backendLister: backendInformer.Lister(), + backendSynced: backendInformer.Informer().HasSynced, + accessPolicyLister: accessPolicyInformer.Lister(), + accessPolicySynced: accessPolicyInformer.Informer().HasSynced, + }, + jwtIssuer: jwtIssuer, + gatewayqueue: workqueue.NewTypedRateLimitingQueueWithConfig( + workqueue.DefaultTypedControllerRateLimiter[string](), + workqueue.TypedRateLimitingQueueConfig[string]{Name: "gateway"}, + ), + } + + // Setup event handlers for all relevant resources. + if err := c.setupGatewayClassEventHandlers(gatewayClassInformer); err != nil { + return nil, err + } + if err := c.setupGatewayEventHandlers(gatewayInformer); err != nil { + return nil, err + } + if err := c.setupHTTPRouteEventHandlers(httprouteInformer); err != nil { + return nil, err + } + if err := c.setupBackendEventHandlers(backendInformer); err != nil { + return nil, err + } + if err := c.setupAccessPolicyEventHandlers(accessPolicyInformer); err != nil { + return nil, err + } + if err := c.setupServiceEventHandlers(serviceInformer); err != nil { + return nil, err + } + + return c, nil +} + +// Run will +// - sync informer caches and start workers. +// - start the xDS server +func (c *Controller) Run(ctx context.Context, workers int) error { + defer runtime.HandleCrashWithContext(ctx) + defer c.gatewayqueue.ShutDown() + + // TODO: Start the Envoy xDS server. + klog.Info("Starting the Envoy xDS server") + + klog.Info("Waiting for informer caches to sync") + if ok := cache.WaitForCacheSync(ctx.Done(), + c.core.nsSynced, + c.core.svcSynced, + c.core.secretSynced, + c.gateway.gatewayClassSynced, + c.gateway.gatewaySynced, + c.gateway.httprouteSynced, + c.agentic.backendSynced, + c.agentic.accessPolicySynced); !ok { + return errors.New("failed to wait for caches to sync") + } + + klog.InfoS("Starting workers", "count", workers) + for i := 0; i < workers; i++ { + go wait.UntilWithContext(ctx, c.runWorker, time.Second) + } + + klog.Info("Started workers") + <-ctx.Done() + klog.Info("Shutting down workers") + + return nil +} + +// runWorker is a long-running function that will continually call the +// processNextWorkItem function in order to read and process a message on the +// workqueue. +func (c *Controller) runWorker(ctx context.Context) { + for c.processNextWorkItem(ctx) { + } +} + +// processNextWorkItem will read a single work item off the workqueue and +// attempt to process it, by calling the syncHandler. +func (c *Controller) processNextWorkItem(ctx context.Context) bool { + obj, shutdown := c.gatewayqueue.Get() + if shutdown { + return false + } + defer c.gatewayqueue.Done(obj) + + // We expect strings (namespace/name) to come off the workqueue. + if err := c.syncHandler(ctx, obj); err != nil { + // Put the item back on the workqueue to handle any transient errors. + c.gatewayqueue.AddRateLimited(obj) + klog.ErrorS(err, "Error syncing", "key", obj) + return true + } + + // Finally, if no error occurs we Forget this item so it does not + // get queued again until another change happens. + c.gatewayqueue.Forget(obj) + klog.InfoS("Successfully synced", "key", obj) + return true +} + +// syncHandler compares the actual state with the desired, and attempts to +// converge the two. +func (c *Controller) syncHandler(ctx context.Context, key string) error { + // Convert the namespace/name string into a distinct namespace and name + namespace, name, err := cache.SplitMetaNamespaceKey(key) + if err != nil { + runtime.HandleError(fmt.Errorf("invalid resource key: %s", key)) + return nil + } + + // Get the Gateway resource with this namespace/name + gateway, err := c.gateway.gatewayLister.Gateways(namespace).Get(name) + if err != nil { + if apierrors.IsNotFound(err) { + klog.InfoS("Gateway deleted", "gateway", klog.KRef(namespace, name)) + return nil + } + return err + } + + klog.InfoS("Syncing gateway", "gateway", klog.KObj(gateway)) + + // TODO: Implement the reconciliation logic here. + // This will involve: + // 1. Finding all relevant resources (HTTPRoutes, Backends, Services, AccessPolicies). + // 2. Validating them. + // 3. Generating an Envoy configuration snapshot. + // 4. Updating the xDS cache with the new snapshot. + + klog.InfoS("Finished syncing gateway", "gateway", klog.KRef(namespace, name)) + return nil +} diff --git a/pkg/controller/gateway.go b/pkg/controller/gateway.go new file mode 100644 index 0000000..92a5a14 --- /dev/null +++ b/pkg/controller/gateway.go @@ -0,0 +1,51 @@ +/* +Copyright 2025 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controller + +import ( + "k8s.io/client-go/tools/cache" + "k8s.io/klog/v2" + + gatewayinformers "sigs.k8s.io/gateway-api/pkg/client/informers/externalversions/apis/v1" +) + +func (c *Controller) setupGatewayEventHandlers(gatewayInformer gatewayinformers.GatewayInformer) error { + _, err := gatewayInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{ + AddFunc: func(obj interface{}) { + key, err := cache.DeletionHandlingMetaNamespaceKeyFunc(obj) + if err == nil { + c.gatewayqueue.Add(key) + } + klog.V(4).InfoS("Gateway added", "gateway", key) + }, + UpdateFunc: func(oldObj, newObj interface{}) { + key, err := cache.DeletionHandlingMetaNamespaceKeyFunc(newObj) + if err == nil { + c.gatewayqueue.Add(key) + } + klog.V(4).InfoS("Gateway updated", "gateway", key) + }, + DeleteFunc: func(obj interface{}) { + key, err := cache.DeletionHandlingMetaNamespaceKeyFunc(obj) + if err == nil { + c.gatewayqueue.Add(key) + } + klog.V(4).InfoS("Gateway deleted", "gateway", key) + }, + }) + return err +} diff --git a/pkg/controller/gatewayclass.go b/pkg/controller/gatewayclass.go new file mode 100644 index 0000000..c861399 --- /dev/null +++ b/pkg/controller/gatewayclass.go @@ -0,0 +1,93 @@ +/* +Copyright 2025 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controller + +import ( + "context" + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/tools/cache" + "k8s.io/klog/v2" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" + gatewayinformers "sigs.k8s.io/gateway-api/pkg/client/informers/externalversions/apis/v1" +) + +func (c *Controller) setupGatewayClassEventHandlers(gatewayClassInformer gatewayinformers.GatewayClassInformer) error { + _, err := gatewayClassInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{ + AddFunc: func(obj interface{}) { + key, err := cache.DeletionHandlingMetaNamespaceKeyFunc(obj) + if err == nil { + c.syncGatewayClass(key) + } + }, + UpdateFunc: func(oldObj, newObj interface{}) { + key, err := cache.DeletionHandlingMetaNamespaceKeyFunc(newObj) + if err == nil { + c.syncGatewayClass(key) + } + }, + DeleteFunc: func(obj interface{}) { + key, err := cache.DeletionHandlingMetaNamespaceKeyFunc(obj) + if err == nil { + c.syncGatewayClass(key) + } + }, + }) + return err +} + +func (c *Controller) syncGatewayClass(key string) { + startTime := time.Now() + klog.V(2).Infof("Started syncing gatewayclass %q (%v)", key, time.Since(startTime)) + defer func() { + klog.V(2).Infof("Finished syncing gatewayclass %q (%v)", key, time.Since(startTime)) + }() + + gwc, err := c.gateway.gatewayClassLister.Get(key) + if err != nil { + if apierrors.IsNotFound(err) { + klog.InfoS("GatewayClass deleted", "gatewayclass", key) + } + return + } + + // We only care about the GatewayClass that matches our controller name. + if gwc.Spec.ControllerName != controllerName { + return + } + + newGwc := gwc.DeepCopy() + // Set the "Accepted" condition to True and update the observedGeneration. + meta.SetStatusCondition(&newGwc.Status.Conditions, metav1.Condition{ + Type: string(gatewayv1.GatewayClassConditionStatusAccepted), + Status: metav1.ConditionTrue, + Reason: string(gatewayv1.GatewayClassReasonAccepted), + Message: "GatewayClass is accepted by this controller.", + ObservedGeneration: gwc.Generation, + }) + + // Update the GatewayClass status + if _, err := c.gateway.client.GatewayV1().GatewayClasses().UpdateStatus(context.Background(), newGwc, metav1.UpdateOptions{}); err != nil { + klog.Errorf("failed to update gatewayclass status: %v", err) + } else { + klog.InfoS("GatewayClass status updated", "gatewayclass", key) + } +} diff --git a/pkg/controller/httproute.go b/pkg/controller/httproute.go new file mode 100644 index 0000000..b7925bb --- /dev/null +++ b/pkg/controller/httproute.go @@ -0,0 +1,88 @@ +/* +Copyright 2025 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controller + +import ( + "fmt" + + "k8s.io/apimachinery/pkg/util/runtime" + "k8s.io/client-go/tools/cache" + "k8s.io/klog/v2" + + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" + gatewayinformers "sigs.k8s.io/gateway-api/pkg/client/informers/externalversions/apis/v1" +) + +func (c *Controller) setupHTTPRouteEventHandlers(httprouteInformer gatewayinformers.HTTPRouteInformer) error { + _, err := httprouteInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{ + AddFunc: c.onHTTPRouteAdd, + UpdateFunc: c.onHTTPRouteUpdate, + DeleteFunc: c.onHTTPRouteDelete, + }) + return err +} + +func (c *Controller) onHTTPRouteAdd(obj interface{}) { + route := obj.(*gatewayv1.HTTPRoute) + klog.V(4).InfoS("Adding HTTPRoute", "httproute", klog.KObj(route)) + c.enqueueGatewaysForHTTPRoute(route.Spec.ParentRefs, route.Namespace) +} + +func (c *Controller) onHTTPRouteUpdate(old, new interface{}) { + oldRoute := old.(*gatewayv1.HTTPRoute) + newRoute := new.(*gatewayv1.HTTPRoute) + klog.V(4).InfoS("Updating HTTPRoute", "httproute", klog.KObj(oldRoute)) + c.enqueueGatewaysForHTTPRoute(append(oldRoute.Spec.ParentRefs, newRoute.Spec.ParentRefs...), newRoute.Namespace) +} + +func (c *Controller) onHTTPRouteDelete(obj interface{}) { + route, ok := obj.(*gatewayv1.HTTPRoute) + if !ok { + tombstone, ok := obj.(cache.DeletedFinalStateUnknown) + if !ok { + runtime.HandleError(fmt.Errorf("couldn't get object from tombstone %#v", obj)) + return + } + route, ok = tombstone.Obj.(*gatewayv1.HTTPRoute) + if !ok { + runtime.HandleError(fmt.Errorf("tombstone contained object that is not a HTTPRoute %#v", obj)) + return + } + } + klog.V(4).InfoS("Deleting HTTPRoute", "httproute", klog.KObj(route)) + c.enqueueGatewaysForHTTPRoute(route.Spec.ParentRefs, route.Namespace) +} + +func (c *Controller) enqueueGatewaysForHTTPRoute(references []gatewayv1.ParentReference, localNamespace string) { + gatewaysToEnqueue := make(map[string]struct{}) + for _, ref := range references { + if (ref.Group != nil && string(*ref.Group) != gatewayv1.GroupName) || + (ref.Kind != nil && string(*ref.Kind) != "Gateway") { + continue + } + namespace := localNamespace + if ref.Namespace != nil { + namespace = string(*ref.Namespace) + } + key := namespace + "/" + string(ref.Name) + gatewaysToEnqueue[key] = struct{}{} + } + + for key := range gatewaysToEnqueue { + c.gatewayqueue.Add(key) + } +} diff --git a/pkg/controller/service.go b/pkg/controller/service.go new file mode 100644 index 0000000..4bfc4c1 --- /dev/null +++ b/pkg/controller/service.go @@ -0,0 +1,71 @@ +/* +Copyright 2025 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controller + +import ( + "fmt" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/util/runtime" + corev1informers "k8s.io/client-go/informers/core/v1" + "k8s.io/client-go/tools/cache" + "k8s.io/klog/v2" +) + +func (c *Controller) setupServiceEventHandlers(informer corev1informers.ServiceInformer) error { + _, err := informer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{ + AddFunc: c.onServiceAdd, + UpdateFunc: c.onServiceUpdate, + DeleteFunc: c.onServiceDelete, + }) + return err +} + +func (c *Controller) onServiceAdd(obj interface{}) { + svc := obj.(*corev1.Service) + klog.V(4).InfoS("Service added", "service", klog.KObj(svc)) + c.enqueueGatewaysForService(svc) +} + +func (c *Controller) onServiceUpdate(old, new interface{}) { + oldSvc := old.(*corev1.Service) + newSvc := new.(*corev1.Service) + klog.V(4).InfoS("Service updated", "service", klog.KObj(oldSvc)) + c.enqueueGatewaysForService(newSvc) +} + +func (c *Controller) onServiceDelete(obj interface{}) { + svc, ok := obj.(*corev1.Service) + if !ok { + tombstone, ok := obj.(cache.DeletedFinalStateUnknown) + if !ok { + runtime.HandleError(fmt.Errorf("couldn't get object from tombstone %#v", obj)) + return + } + svc, ok = tombstone.Obj.(*corev1.Service) + if !ok { + runtime.HandleError(fmt.Errorf("tombstone contained object that is not a Service %#v", obj)) + return + } + } + klog.V(4).InfoS("Deleting Service", "service", klog.KObj(svc)) + c.enqueueGatewaysForService(svc) +} + +func (c *Controller) enqueueGatewaysForService(svc *corev1.Service) { + // A change to a Service can affect multiple Gateways via Backends and HTTPRoutes. +} diff --git a/pkg/dicovery/discovery.go b/pkg/dicovery/discovery.go new file mode 100644 index 0000000..aee1f48 --- /dev/null +++ b/pkg/dicovery/discovery.go @@ -0,0 +1,62 @@ +/* +Copyright 2025 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package discovery + +import ( + "encoding/json" + "fmt" + "net/http" + + "k8s.io/client-go/rest" +) + +// JWTIssuer automatically discovers the JWT issuer from the Kubernetes API server +// by querying the OIDC discovery endpoint. +func JWTIssuer(config *rest.Config) (string, error) { + // Use the REST config to create a transport that trusts the cluster's CA. + transport, err := rest.TransportFor(config) + if err != nil { + return "", fmt.Errorf("failed to create transport from kubeconfig: %w", err) + } + client := &http.Client{Transport: transport} + + // Make a request to the standard OIDC discovery endpoint. + wellKnownURL := config.Host + "/.well-known/openid-configuration" + resp, err := client.Get(wellKnownURL) + if err != nil { + return "", fmt.Errorf("failed to get OIDC discovery endpoint %s: %w", wellKnownURL, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("OIDC discovery endpoint %s returned status %d", wellKnownURL, resp.StatusCode) + } + + // Parse the JSON response and extract the issuer. + var oidcDiscovery struct { + Issuer string `json:"issuer"` + } + if err := json.NewDecoder(resp.Body).Decode(&oidcDiscovery); err != nil { + return "", fmt.Errorf("failed to decode OIDC discovery response: %w", err) + } + + if oidcDiscovery.Issuer == "" { + return "", fmt.Errorf("issuer field not found in OIDC discovery response from %s", wellKnownURL) + } + + return oidcDiscovery.Issuer, nil +} diff --git a/pkg/placeholder.go b/pkg/placeholder.go deleted file mode 100644 index 8e3758a..0000000 --- a/pkg/placeholder.go +++ /dev/null @@ -1,17 +0,0 @@ -/* -Copyright 2025 The Kubernetes Authors. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package main diff --git a/pkg/translator/authPolicy.go b/pkg/translator/authPolicy.go new file mode 100644 index 0000000..726ef84 --- /dev/null +++ b/pkg/translator/authPolicy.go @@ -0,0 +1,323 @@ +/* +Copyright 2025 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package translator + +import ( + "fmt" + + rbacconfigv3 "github.com/envoyproxy/go-control-plane/envoy/config/rbac/v3" + routev3 "github.com/envoyproxy/go-control-plane/envoy/config/route/v3" + rbacv3 "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/http/rbac/v3" + matcherv3 "github.com/envoyproxy/go-control-plane/envoy/type/matcher/v3" + "k8s.io/apimachinery/pkg/labels" + agenticv0alpha0 "sigs.k8s.io/kube-agentic-networking/api/v0alpha0" + agenticlisters "sigs.k8s.io/kube-agentic-networking/k8s/client/listers/api/v0alpha0" +) + +const ( + // allowMCPSessionClosePolicyName is the name of the RBAC policy that allows agents to close MCP sessions. + allowMCPSessionClosePolicyName = "allow-mcp-session-close" + + // allowAnyoneToInitializeAndListToolsPolicyName is the name of the RBAC policy that allows anyone to initialize a session and list available tools. + allowAnyoneToInitializeAndListToolsPolicyName = "allow-anyone-to-initialize-and-list-tools" + initializeMethod = "initialize" + initializedMethod = "notifications/initialized" + toolsListMethod = "tools/list" + + // allowHTTPGet is the name of the RBAC policy that allows an HTTP GET to the MCP endpoint for SSE stream. + allowHTTPGet = "allow-http-get" + + mcpSessionIDHeader = "mcp-session-id" + toolsCallMethod = "tools/call" + mcpProxyFilterName = "mcp_proxy" +) + +// rbacConfigFromAccessPolicy generates all RBAC policies for a given backend, including common policies +// and those derived from AccessPolicy resources. +func rbacConfigFromAccessPolicy(accessPolicyLister agenticlisters.XAccessPolicyLister, backend *agenticv0alpha0.XBackend) (*rbacv3.RBAC, error) { + var rbacPolicies = make(map[string]*rbacconfigv3.Policy) + + // Add AuthPolicy-derived RBAC policies. + // Currently, we assume only one AuthPolicy targets a given backend. + accessPolicy, err := findAccessPolicyForBackend(backend, accessPolicyLister) + if err != nil { + return nil, err + } + if accessPolicy != nil { + rbacPolicies = translateAccessPolicyToRBAC(accessPolicy, backend) + } + // It's deny-by-default (a.k.a ALLOW action), we explicitly allow necessary + // MCP operations for all backends. These policies are essential for MCP + // session management and tool initialization. + rbacPolicies[allowMCPSessionClosePolicyName] = buildAllowMCPSessionClosePolicy() + rbacPolicies[allowAnyoneToInitializeAndListToolsPolicyName] = buildAllowAnyoneToInitializeAndListToolsPolicy() + rbacPolicies[allowHTTPGet] = buildAllowHTTPGetPolicy() + + rbacConfig := &rbacv3.RBAC{ + Rules: &rbacconfigv3.RBAC{ + Action: rbacconfigv3.RBAC_ALLOW, + Policies: rbacPolicies, + }, + } + + return rbacConfig, nil +} + +// findAccessPolicyForBackend finds the AccessPolicy that targets the given backend. +// It assumes that there is only one AccessPolicy for each backend. +func findAccessPolicyForBackend(backend *agenticv0alpha0.XBackend, accessPolicyLister agenticlisters.XAccessPolicyLister) (*agenticv0alpha0.XAccessPolicy, error) { + // List all AccessPolicies in the Backend's namespace. + allAccessPolicies, err := accessPolicyLister.XAccessPolicies(backend.Namespace).List(labels.Everything()) + if err != nil { + return nil, fmt.Errorf("failed to list AccessPolicies in namespace %s: %w", backend.Namespace, err) + } + + // Find the first AuthPolicy that targets this specific backend. + // We assume only one AccessPolicy will target a given backend. + // TODO: Enforce this uniqueness constraint at the API level or merge multiple policies if needed. + for _, accessPolicy := range allAccessPolicies { + for _, targetRef := range accessPolicy.Spec.TargetRefs { + if targetRef.Kind == "Backend" && string(targetRef.Name) == backend.Name { + return accessPolicy, nil + } + } + } + return nil, nil // No AccessPolicy found for the backend. +} + +func translateAccessPolicyToRBAC(accessPolicy *agenticv0alpha0.XAccessPolicy, backend *agenticv0alpha0.XBackend) map[string]*rbacconfigv3.Policy { + policies := make(map[string]*rbacconfigv3.Policy) + + for i, rule := range accessPolicy.Spec.Rules { + policyName := fmt.Sprintf(rbacPolicyNameFormat, backend.Namespace, backend.Name, i) + var principalIDs []*rbacconfigv3.Principal + + var allSources []string + if rule.Source.SPIFFE != nil { + allSources = append(allSources, string(*rule.Source.SPIFFE)) + } + if rule.Source.ServiceAccount != nil { + ns := rule.Source.ServiceAccount.Namespace + if ns == "" { + ns = accessPolicy.Namespace + } + allSources = append(allSources, fmt.Sprintf("%s/%s", ns, rule.Source.ServiceAccount.Name)) + } + + if len(allSources) > 0 { + var sourcePrincipals []*rbacconfigv3.Principal + for _, source := range allSources { + sourcePrincipal := &rbacconfigv3.Principal{ + Identifier: &rbacconfigv3.Principal_Header{ + Header: &routev3.HeaderMatcher{ + Name: "x-user-role", + HeaderMatchSpecifier: &routev3.HeaderMatcher_StringMatch{ + StringMatch: &matcherv3.StringMatcher{ + MatchPattern: &matcherv3.StringMatcher_Exact{Exact: source}, + }, + }, + }, + }, + } + sourcePrincipals = append(sourcePrincipals, sourcePrincipal) + } + principalIDs = append(principalIDs, &rbacconfigv3.Principal{ + Identifier: &rbacconfigv3.Principal_OrIds{ + OrIds: &rbacconfigv3.Principal_Set{Ids: sourcePrincipals}, + }, + }) + } + + // Build permissions based on tools if specified + var permissions []*rbacconfigv3.Permission + if len(rule.Tools) > 0 { + var toolValueMatchers []*matcherv3.ValueMatcher + for _, tool := range rule.Tools { + toolValueMatchers = append(toolValueMatchers, &matcherv3.ValueMatcher{ + MatchPattern: &matcherv3.ValueMatcher_StringMatch{ + StringMatch: &matcherv3.StringMatcher{ + MatchPattern: &matcherv3.StringMatcher_Exact{Exact: tool}, + }, + }, + }) + } + + var toolsMatcher *matcherv3.ValueMatcher + if len(toolValueMatchers) == 1 { + toolsMatcher = toolValueMatchers[0] + } else { + toolsMatcher = &matcherv3.ValueMatcher{ + MatchPattern: &matcherv3.ValueMatcher_OrMatch{OrMatch: &matcherv3.OrMatcher{ValueMatchers: toolValueMatchers}}, + } + } + + permissions = append(permissions, &rbacconfigv3.Permission{ + Rule: &rbacconfigv3.Permission_AndRules{ + AndRules: &rbacconfigv3.Permission_Set{ + Rules: []*rbacconfigv3.Permission{ + { + Rule: &rbacconfigv3.Permission_SourcedMetadata{ + SourcedMetadata: &rbacconfigv3.SourcedMetadata{ + MetadataMatcher: &matcherv3.MetadataMatcher{ + Filter: mcpProxyFilterName, + Path: []*matcherv3.MetadataMatcher_PathSegment{{Segment: &matcherv3.MetadataMatcher_PathSegment_Key{Key: "method"}}}, + Value: &matcherv3.ValueMatcher{MatchPattern: &matcherv3.ValueMatcher_StringMatch{StringMatch: &matcherv3.StringMatcher{MatchPattern: &matcherv3.StringMatcher_Exact{Exact: toolsCallMethod}}}}, + }, + }, + }, + }, + { + Rule: &rbacconfigv3.Permission_SourcedMetadata{ + SourcedMetadata: &rbacconfigv3.SourcedMetadata{ + MetadataMatcher: &matcherv3.MetadataMatcher{ + Filter: mcpProxyFilterName, + Path: []*matcherv3.MetadataMatcher_PathSegment{{Segment: &matcherv3.MetadataMatcher_PathSegment_Key{Key: "params"}}, {Segment: &matcherv3.MetadataMatcher_PathSegment_Key{Key: "name"}}}, + Value: toolsMatcher, + }, + }, + }, + }, + }, + }, + }, + }) + } + + policies[policyName] = &rbacconfigv3.Policy{ + Principals: principalIDs, + Permissions: permissions, + } + } + return policies +} + +// buildAllowMCPSessionClosePolicy creates the RBAC policy that allows agents to close MCP sessions. +func buildAllowMCPSessionClosePolicy() *rbacconfigv3.Policy { + return &rbacconfigv3.Policy{ + Principals: []*rbacconfigv3.Principal{ + { + Identifier: &rbacconfigv3.Principal_AndIds{ + AndIds: &rbacconfigv3.Principal_Set{ + Ids: []*rbacconfigv3.Principal{ + { // Condition 1: The HTTP method must be DELETE + Identifier: &rbacconfigv3.Principal_Header{ + Header: &routev3.HeaderMatcher{ + Name: ":method", + HeaderMatchSpecifier: &routev3.HeaderMatcher_StringMatch{ + StringMatch: &matcherv3.StringMatcher{ + MatchPattern: &matcherv3.StringMatcher_Exact{Exact: "DELETE"}, + }, + }, + }, + }, + }, + { // Condition 2: The 'mcp-session-id' header must exist + Identifier: &rbacconfigv3.Principal_Header{ + Header: &routev3.HeaderMatcher{Name: mcpSessionIDHeader, HeaderMatchSpecifier: &routev3.HeaderMatcher_PresentMatch{PresentMatch: true}}, + }, + }, + }, + }, + }, + }, + }, + Permissions: []*rbacconfigv3.Permission{ + { + // If the principal (the request's identity) matches, allow it. + Rule: &rbacconfigv3.Permission_Any{ + Any: true, + }, + }, + }, + } +} + +// buildAllowAnyoneToInitializeAndListToolsPolicy creates the RBAC policy that allows anyone to +// initialize a session and list available tools. +func buildAllowAnyoneToInitializeAndListToolsPolicy() *rbacconfigv3.Policy { + return &rbacconfigv3.Policy{ + Principals: []*rbacconfigv3.Principal{ + { + Identifier: &rbacconfigv3.Principal_Any{ + Any: true, + }, + }, + }, + Permissions: []*rbacconfigv3.Permission{ + { + Rule: &rbacconfigv3.Permission_AndRules{ + AndRules: &rbacconfigv3.Permission_Set{ + Rules: []*rbacconfigv3.Permission{ + { + Rule: &rbacconfigv3.Permission_SourcedMetadata{ + SourcedMetadata: &rbacconfigv3.SourcedMetadata{ + MetadataMatcher: &matcherv3.MetadataMatcher{ + Filter: mcpProxyFilterName, + Path: []*matcherv3.MetadataMatcher_PathSegment{{Segment: &matcherv3.MetadataMatcher_PathSegment_Key{Key: "method"}}}, + Value: &matcherv3.ValueMatcher{ + MatchPattern: &matcherv3.ValueMatcher_OrMatch{ + OrMatch: &matcherv3.OrMatcher{ + ValueMatchers: []*matcherv3.ValueMatcher{ + {MatchPattern: &matcherv3.ValueMatcher_StringMatch{StringMatch: &matcherv3.StringMatcher{MatchPattern: &matcherv3.StringMatcher_Exact{Exact: initializeMethod}}}}, + {MatchPattern: &matcherv3.ValueMatcher_StringMatch{StringMatch: &matcherv3.StringMatcher{MatchPattern: &matcherv3.StringMatcher_Exact{Exact: initializedMethod}}}}, + {MatchPattern: &matcherv3.ValueMatcher_StringMatch{StringMatch: &matcherv3.StringMatcher{MatchPattern: &matcherv3.StringMatcher_Exact{Exact: toolsListMethod}}}}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } +} + +// This policy explicitly allows GET requests for streamable HTTP transports. +// In the MCP protocol, after an initial POST handshake, a long-lived GET request +// is established to receive server-sent events (SSE). Without this rule, the RBAC +// filter would implicitly deny these GET requests, leading to a 403 Forbidden error. +// https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http +func buildAllowHTTPGetPolicy() *rbacconfigv3.Policy { + return &rbacconfigv3.Policy{ + Principals: []*rbacconfigv3.Principal{ + { + Identifier: &rbacconfigv3.Principal_Any{ + Any: true, + }, + }, + }, + Permissions: []*rbacconfigv3.Permission{ + { + Rule: &rbacconfigv3.Permission_Header{ + Header: &routev3.HeaderMatcher{ + Name: ":method", + HeaderMatchSpecifier: &routev3.HeaderMatcher_StringMatch{ + StringMatch: &matcherv3.StringMatcher{ + MatchPattern: &matcherv3.StringMatcher_Exact{Exact: "GET"}, + }, + }, + }, + }, + }, + }, + } +} diff --git a/pkg/translator/backend.go b/pkg/translator/backend.go new file mode 100644 index 0000000..9f39d5a --- /dev/null +++ b/pkg/translator/backend.go @@ -0,0 +1,163 @@ +/* +Copyright 2025 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package translator + +import ( + "fmt" + "time" + + clusterv3 "github.com/envoyproxy/go-control-plane/envoy/config/cluster/v3" + corev3 "github.com/envoyproxy/go-control-plane/envoy/config/core/v3" + tlsv3 "github.com/envoyproxy/go-control-plane/envoy/extensions/transport_sockets/tls/v3" + "google.golang.org/protobuf/types/known/anypb" + "google.golang.org/protobuf/types/known/durationpb" + corev1listers "k8s.io/client-go/listers/core/v1" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" + agenticv0alpha0 "sigs.k8s.io/kube-agentic-networking/api/v0alpha0" + agenticlisters "sigs.k8s.io/kube-agentic-networking/k8s/client/listers/api/v0alpha0" +) + +const ( + // The timeout for new network connections to hosts in the cluster. + defaultConnectTimeout = 5 * time.Second +) + +func fetchBackend(namespace string, backendRef gatewayv1.BackendRef, backendLister agenticlisters.XBackendLister, serviceLister corev1listers.ServiceLister) (*agenticv0alpha0.XBackend, error) { + // 1. Validate that the Kind is Backend. + if backendRef.Kind != nil && *backendRef.Kind != "Backend" { + return nil, &ControllerError{ + Reason: string(gatewayv1.RouteReasonInvalidKind), + Message: fmt.Sprintf("unsupported backend kind: %s", *backendRef.Kind), + } + } + + ns := namespace + if backendRef.Namespace != nil { + ns = string(*backendRef.Namespace) + } + + // 2. Fetch the Backend resource. + backend, err := backendLister.XBackends(ns).Get(string(backendRef.Name)) + if err != nil { + return nil, &ControllerError{ + Reason: string(gatewayv1.RouteReasonBackendNotFound), + Message: fmt.Sprintf("failed to get Backend %s/%s: %v", ns, backendRef.Name, err), + } + } + + // 3. Check if the referenced Service exists. + if svcName := backend.Spec.MCP.ServiceName; svcName != nil { + if _, err := serviceLister.Services(ns).Get(*svcName); err != nil { + fmt.Printf("Service lookup error for backend %s/%s, error: %v\n", ns, backendRef.Name, err) + return nil, &ControllerError{ + Reason: string(gatewayv1.RouteReasonBackendNotFound), + Message: fmt.Sprintf("failed to get Backend service %s/%s: %v", ns, *svcName, err), + } + } + } + + // TODO: Do we need to check hostname resolution for external MCP backends? + return backend, nil +} + +func convertBackendToCluster(backend *agenticv0alpha0.XBackend) (*clusterv3.Cluster, error) { + clusterName := fmt.Sprintf(clusterNameFormat, backend.Namespace, backend.Name) + + // Create the base cluster configuration. + cluster := &clusterv3.Cluster{ + Name: clusterName, + ConnectTimeout: durationpb.New(defaultConnectTimeout), + } + + if backend.Spec.MCP.ServiceName != nil { + // For in-cluster services, use the FQDN. + serviceFQDN := fmt.Sprintf("%s.%s.svc.cluster.local", *backend.Spec.MCP.ServiceName, backend.Namespace) + cluster.ClusterDiscoveryType = &clusterv3.Cluster_Type{Type: clusterv3.Cluster_STRICT_DNS} + cluster.LoadAssignment = createClusterLoadAssignment(clusterName, serviceFQDN, uint32(backend.Spec.MCP.Port)) + return cluster, nil + } + + // External MCP backend specified via backend.Spec.MCP.Hostname + cluster.ClusterDiscoveryType = &clusterv3.Cluster_Type{Type: clusterv3.Cluster_LOGICAL_DNS} + cluster.LoadAssignment = createClusterLoadAssignment(clusterName, *backend.Spec.MCP.Hostname, uint32(backend.Spec.MCP.Port)) + // TODO: A new field will probably be added to Backend to allow configuring TLS for external MCP backends. + // For now, we always enable TLS for external MCP backends. + if true { + tlsContext := &tlsv3.UpstreamTlsContext{ + Sni: *backend.Spec.MCP.Hostname, + } + any, err := anypb.New(tlsContext) + if err != nil { + return nil, err + } + cluster.TransportSocket = &corev3.TransportSocket{ + Name: "envoy.transport_sockets.tls", + ConfigType: &corev3.TransportSocket_TypedConfig{ + TypedConfig: any, + }, + } + } + + return cluster, nil +} + +func buildClustersFromBackends(backends []*agenticv0alpha0.XBackend) ([]*clusterv3.Cluster, error) { + var clusters []*clusterv3.Cluster + for _, backend := range backends { + cluster, err := convertBackendToCluster(backend) + if err != nil { + return nil, err + } + clusters = append(clusters, cluster) + } + return clusters, nil +} + +func buildK8sApiCluster() (*clusterv3.Cluster, error) { + tlsContext := &tlsv3.UpstreamTlsContext{ + Sni: "kubernetes.default.svc", + CommonTlsContext: &tlsv3.CommonTlsContext{ + ValidationContextType: &tlsv3.CommonTlsContext_ValidationContext{ + ValidationContext: &tlsv3.CertificateValidationContext{ + TrustedCa: &corev3.DataSource{ + Specifier: &corev3.DataSource_Filename{ + // This tells Envoy to trust the K8s API server's cert + Filename: "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt", + }, + }, + }, + }, + }, + } + anyTlsContext, err := anypb.New(tlsContext) + if err != nil { + return nil, fmt.Errorf("failed to marshal UpstreamTlsContext: %w", err) + } + + cluster := &clusterv3.Cluster{ + Name: k8sAPIClusterName, + ClusterDiscoveryType: &clusterv3.Cluster_Type{Type: clusterv3.Cluster_LOGICAL_DNS}, + LoadAssignment: createClusterLoadAssignment(k8sAPIClusterName, "kubernetes.default.svc", 443), // Use port 443 for HTTPS + TransportSocket: &corev3.TransportSocket{ + Name: "envoy.transport_sockets.tls", + ConfigType: &corev3.TransportSocket_TypedConfig{ + TypedConfig: anyTlsContext, + }, + }, + } + return cluster, nil +} diff --git a/pkg/translator/httproute.go b/pkg/translator/httproute.go new file mode 100644 index 0000000..76dd530 --- /dev/null +++ b/pkg/translator/httproute.go @@ -0,0 +1,544 @@ +/* +Copyright 2025 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package translator + +import ( + "errors" + "fmt" + "sort" + "strings" + + corev3 "github.com/envoyproxy/go-control-plane/envoy/config/core/v3" + routev3 "github.com/envoyproxy/go-control-plane/envoy/config/route/v3" + rbacv3 "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/http/rbac/v3" + matcherv3 "github.com/envoyproxy/go-control-plane/envoy/type/matcher/v3" + "github.com/envoyproxy/go-control-plane/pkg/wellknown" + "google.golang.org/protobuf/types/known/anypb" + "google.golang.org/protobuf/types/known/wrapperspb" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + corev1listers "k8s.io/client-go/listers/core/v1" + "k8s.io/klog/v2" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" + agenticv0alpha0 "sigs.k8s.io/kube-agentic-networking/api/v0alpha0" + agenticlisters "sigs.k8s.io/kube-agentic-networking/k8s/client/listers/api/v0alpha0" +) + +// translateHTTPRouteToEnvoyRoutes translates a full HTTPRoute into a slice of Envoy Routes. +// It now correctly handles RequestHeaderModifier filters. +func translateHTTPRouteToEnvoyRoutes( + httpRoute *gatewayv1.HTTPRoute, + serviceLister corev1listers.ServiceLister, + accessPolicyLister agenticlisters.XAccessPolicyLister, + backendLister agenticlisters.XBackendLister, +) ([]*routev3.Route, []*agenticv0alpha0.XBackend, metav1.Condition) { + + var envoyRoutes []*routev3.Route + var allValidBackends []*agenticv0alpha0.XBackend + overallCondition := createSuccessCondition(httpRoute.Generation) + + for ruleIndex, rule := range httpRoute.Spec.Rules { + var redirectAction *routev3.RedirectAction + var headersToAdd []*corev3.HeaderValueOption + var headersToRemove []string + var urlRewriteAction *routev3.RouteAction + + // Process filters using a switch and delegate logic to helpers. + FilterLoop: + for _, filter := range rule.Filters { + switch filter.Type { + case gatewayv1.HTTPRouteFilterRequestRedirect: + redirectAction = processRequestRedirectFilter(filter.RequestRedirect) + if redirectAction != nil { + // Only one redirect filter is allowed per rule: stop processing further filters. + break FilterLoop + } + case gatewayv1.HTTPRouteFilterRequestHeaderModifier: + adds, removes := processRequestHeaderModifierFilter(filter.RequestHeaderModifier) + headersToAdd = append(headersToAdd, adds...) + headersToRemove = append(headersToRemove, removes...) + case gatewayv1.HTTPRouteFilterURLRewrite: + urlRewriteAction = processURLRewriteFilter(filter.URLRewrite) + default: + // Unsupported/ignored filter types are skipped here. + klog.Warningf("Unsupported HTTPRoute filter type: %s", filter.Type) + } + } + + buildRoutesForRule := func(match gatewayv1.HTTPRouteMatch, matchIndex int) { + routeMatch, matchCondition := translateHTTPRouteMatch(match, httpRoute.Generation) + if matchCondition.Status == metav1.ConditionFalse { + overallCondition = matchCondition + return + } + + envoyRoute := &routev3.Route{ + Name: fmt.Sprintf(envoyRouteNameFormat, httpRoute.Namespace, httpRoute.Name, ruleIndex, matchIndex), + Match: routeMatch, + RequestHeadersToAdd: headersToAdd, + RequestHeadersToRemove: headersToRemove, + } + + if redirectAction != nil { + // If this is a redirect, set the Redirect action. No backends are needed. + envoyRoute.Action = &routev3.Route_Redirect{ + Redirect: redirectAction, + } + } else { + // Build the forwarding action with backend clusters and per-cluster security policies. + routeAction, validBackends, err := buildHTTPRouteAction( + httpRoute.Namespace, + rule.BackendRefs, + serviceLister, + accessPolicyLister, + backendLister, + ) + var controllerErr *ControllerError + if errors.As(err, &controllerErr) { + overallCondition = createFailureCondition(gatewayv1.RouteConditionReason(controllerErr.Reason), controllerErr.Message, httpRoute.Generation) + envoyRoute.Action = &routev3.Route_DirectResponse{ + DirectResponse: &routev3.DirectResponseAction{Status: 500}, + } + // Skip further processing for this route if backends are invalid. + envoyRoutes = append(envoyRoutes, envoyRoute) + return + } + allValidBackends = append(allValidBackends, validBackends...) + + // If a URLRewrite filter was present, merge its properties into the RouteAction. + if urlRewriteAction != nil { + routeAction.HostRewriteSpecifier = urlRewriteAction.HostRewriteSpecifier + routeAction.RegexRewrite = urlRewriteAction.RegexRewrite + routeAction.PrefixRewrite = urlRewriteAction.PrefixRewrite + } + + envoyRoute.Action = &routev3.Route_Route{ + Route: routeAction, + } + } + envoyRoutes = append(envoyRoutes, envoyRoute) + } + + if len(rule.Matches) == 0 { + buildRoutesForRule(gatewayv1.HTTPRouteMatch{}, 0) + } else { + for matchIndex, match := range rule.Matches { + buildRoutesForRule(match, matchIndex) + } + } + } + return envoyRoutes, allValidBackends, overallCondition +} + +// processURLRewriteFilter translates a gatewayv1.HTTPURLRewriteFilter into an +// Envoy routev3.RouteAction with the appropriate rewrite actions. +func processURLRewriteFilter(f *gatewayv1.HTTPURLRewriteFilter) *routev3.RouteAction { + if f == nil { + return nil + } + + routeAction := &routev3.RouteAction{} + // The flag prevents the function from returning an empty &routev3.RouteAction{} + // struct when no actual rewrite is needed. + rewriteActionSet := false + + // Handle hostname rewrite. + if f.Hostname != nil { + routeAction.HostRewriteSpecifier = &routev3.RouteAction_HostRewriteLiteral{ + HostRewriteLiteral: string(*f.Hostname), + } + rewriteActionSet = true + + } + + // Handle path rewrite. + if f.Path != nil { + switch f.Path.Type { + case gatewayv1.FullPathHTTPPathModifier: + if f.Path.ReplaceFullPath != nil { + routeAction.RegexRewrite = &matcherv3.RegexMatchAndSubstitute{ + Pattern: &matcherv3.RegexMatcher{EngineType: &matcherv3.RegexMatcher_GoogleRe2{}, Regex: ".*"}, + Substitution: *f.Path.ReplaceFullPath, + } + rewriteActionSet = true + } + case gatewayv1.PrefixMatchHTTPPathModifier: + if f.Path.ReplacePrefixMatch != nil { + routeAction.PrefixRewrite = *f.Path.ReplacePrefixMatch + rewriteActionSet = true + } + } + } + + // If no rewrite actions were set, return nil. + if !rewriteActionSet { + return nil + } + + return routeAction +} + +// processRequestRedirectFilter converts a Gateway API HTTPRequestRedirectFilter into an Envoy RedirectAction. +func processRequestRedirectFilter(f *gatewayv1.HTTPRequestRedirectFilter) *routev3.RedirectAction { + if f == nil { + return nil + } + + action := &routev3.RedirectAction{} + + if f.Hostname != nil { + action.HostRedirect = string(*f.Hostname) + } + + if f.StatusCode != nil { + switch *f.StatusCode { + case 301: + action.ResponseCode = routev3.RedirectAction_MOVED_PERMANENTLY + case 302: + action.ResponseCode = routev3.RedirectAction_FOUND + case 303: + action.ResponseCode = routev3.RedirectAction_SEE_OTHER + case 307: + action.ResponseCode = routev3.RedirectAction_TEMPORARY_REDIRECT + case 308: + action.ResponseCode = routev3.RedirectAction_PERMANENT_REDIRECT + default: + action.ResponseCode = routev3.RedirectAction_MOVED_PERMANENTLY + } + } else { + // The Gateway API spec defaults to a 302 redirect (Envoy: FOUND). + action.ResponseCode = routev3.RedirectAction_FOUND + } + + return action +} + +// processRequestHeaderModifierFilter converts a Gateway API HTTPHeaderFilter into Envoy header mutations. +func processRequestHeaderModifierFilter(f *gatewayv1.HTTPHeaderFilter) ([]*corev3.HeaderValueOption, []string) { + var headersToAdd []*corev3.HeaderValueOption + var headersToRemove []string + + if f == nil { + return headersToAdd, headersToRemove + } + + // Handle "set" actions (overwrite) + for _, header := range f.Set { + headersToAdd = append(headersToAdd, &corev3.HeaderValueOption{ + Header: &corev3.HeaderValue{ + Key: string(header.Name), + Value: header.Value, + }, + AppendAction: corev3.HeaderValueOption_OVERWRITE_IF_EXISTS_OR_ADD, + }) + } + + // Handle "add" actions (append) + for _, header := range f.Add { + headersToAdd = append(headersToAdd, &corev3.HeaderValueOption{ + Header: &corev3.HeaderValue{ + Key: string(header.Name), + Value: header.Value, + }, + AppendAction: corev3.HeaderValueOption_APPEND_IF_EXISTS_OR_ADD, + }) + } + + // Handle "remove" actions + headersToRemove = append(headersToRemove, f.Remove...) + + return headersToAdd, headersToRemove +} + +// buildHTTPRouteAction returns an action, a list of *valid* BackendRefs, and a structured error. +func buildHTTPRouteAction(namespace string, + backendRefs []gatewayv1.HTTPBackendRef, + serviceLister corev1listers.ServiceLister, + accessPolicyLister agenticlisters.XAccessPolicyLister, + backendLister agenticlisters.XBackendLister) (*routev3.RouteAction, []*agenticv0alpha0.XBackend, error) { + + weightedClusters := &routev3.WeightedCluster{} + var validBackends []*agenticv0alpha0.XBackend + + for _, httpBackendRef := range backendRefs { + backend, err := fetchBackend(namespace, httpBackendRef.BackendRef, backendLister, serviceLister) + if err != nil { + return nil, nil, err + } + validBackends = append(validBackends, backend) + weight := int32(1) + if httpBackendRef.Weight != nil { + weight = *httpBackendRef.Weight + } + if weight == 0 { + continue + } + + clusterWeight := &routev3.WeightedCluster_ClusterWeight{ + Name: fmt.Sprintf(clusterNameFormat, backend.Namespace, backend.Name), + Weight: &wrapperspb.UInt32Value{Value: uint32(weight)}, + } + + // The HostRewriteLiteral is set on the individual clusterWeight within the WeightedCluster. + // This ensures that for external backends with a specified hostname, Envoy rewrites + // the Host header to the correct external hostname before forwarding the request. + if backend.Spec.MCP.Hostname != nil { + clusterWeight.HostRewriteSpecifier = &routev3.WeightedCluster_ClusterWeight_HostRewriteLiteral{ + HostRewriteLiteral: *backend.Spec.MCP.Hostname, + } + } + + clusterWeight.TypedPerFilterConfig, err = buildPerClusterRBACFilterConfig(accessPolicyLister, backend) + if err != nil { + klog.Errorf("Failed to build per-cluster RBAC config for backend %s/%s: %v", backend.Namespace, backend.Name, err) + // Continue without RBAC config for this cluster if it fails to build. + } + weightedClusters.Clusters = append(weightedClusters.Clusters, clusterWeight) + } + + if len(weightedClusters.Clusters) == 0 { + return nil, nil, &ControllerError{Reason: string(gatewayv1.RouteReasonUnsupportedValue), Message: "no valid backends provided with a weight > 0"} + } + + action := &routev3.RouteAction{ClusterSpecifier: &routev3.RouteAction_WeightedClusters{WeightedClusters: weightedClusters}} + + return action, validBackends, nil +} + +// buildPerClusterRBACFilterConfig creates the TypedPerFilterConfig for a cluster, specifically for the RBAC filter. +func buildPerClusterRBACFilterConfig(accessPolicyLister agenticlisters.XAccessPolicyLister, backend *agenticv0alpha0.XBackend) (map[string]*anypb.Any, error) { + perFilterConfig := make(map[string]*anypb.Any) + + // Envoy's per-cluster configuration requires an RBACPerRoute message containing + // RBAC rules derived from AuthPolicy resources targeting this backend. + rbacConfig, err := rbacConfigFromAccessPolicy(accessPolicyLister, backend) + if err != nil { + return nil, fmt.Errorf("failed to generate RBAC policies: %w", err) + } + rbacPerRouteProto := &rbacv3.RBACPerRoute{ + Rbac: rbacConfig, + } + + // Marshal the RBAC config into an Any proto. + rbacAny, err := anypb.New(rbacPerRouteProto) + if err != nil { + return nil, fmt.Errorf("failed to marshal RBACPerRoute proto: %w", err) + } + + perFilterConfig[wellknown.HTTPRoleBasedAccessControl] = rbacAny + + return perFilterConfig, nil +} + +// translateHTTPRouteMatch translates a Gateway API HTTPRouteMatch into an Envoy RouteMatch. +// It returns the result and a condition indicating success or failure. +func translateHTTPRouteMatch(match gatewayv1.HTTPRouteMatch, generation int64) (*routev3.RouteMatch, metav1.Condition) { + routeMatch := &routev3.RouteMatch{} + + if match.Path != nil { + pathType := gatewayv1.PathMatchPathPrefix + if match.Path.Type != nil { + pathType = *match.Path.Type + } + if match.Path.Value == nil { + msg := "path match value cannot be nil" + return nil, createFailureCondition(gatewayv1.RouteReasonUnsupportedValue, msg, generation) + } + pathValue := *match.Path.Value + + switch pathType { + case gatewayv1.PathMatchExact: + routeMatch.PathSpecifier = &routev3.RouteMatch_Path{Path: pathValue} + case gatewayv1.PathMatchPathPrefix: + if pathValue == "/" { + routeMatch.PathSpecifier = &routev3.RouteMatch_Prefix{Prefix: "/"} + } else { + path := strings.TrimSuffix(pathValue, "/") + routeMatch.PathSpecifier = &routev3.RouteMatch_PathSeparatedPrefix{PathSeparatedPrefix: path} + } + case gatewayv1.PathMatchRegularExpression: + routeMatch.PathSpecifier = &routev3.RouteMatch_SafeRegex{ + SafeRegex: &matcherv3.RegexMatcher{ + EngineType: &matcherv3.RegexMatcher_GoogleRe2{GoogleRe2: &matcherv3.RegexMatcher_GoogleRE2{}}, + Regex: pathValue, + }, + } + default: + msg := fmt.Sprintf("unsupported path match type: %s", pathType) + return nil, createFailureCondition(gatewayv1.RouteReasonUnsupportedValue, msg, generation) + } + } else { + // As per Gateway API spec, a nil path match defaults to matching everything. + routeMatch.PathSpecifier = &routev3.RouteMatch_Prefix{Prefix: "/"} + } + + // Translate Header Matches + for _, headerMatch := range match.Headers { + headerMatcher := &routev3.HeaderMatcher{ + Name: string(headerMatch.Name), + } + matchType := gatewayv1.HeaderMatchExact + if headerMatch.Type != nil { + matchType = *headerMatch.Type + } + + switch matchType { + case gatewayv1.HeaderMatchExact: + headerMatcher.HeaderMatchSpecifier = &routev3.HeaderMatcher_StringMatch{ + StringMatch: &matcherv3.StringMatcher{ + MatchPattern: &matcherv3.StringMatcher_Exact{Exact: headerMatch.Value}, + }, + } + case gatewayv1.HeaderMatchRegularExpression: + headerMatcher.HeaderMatchSpecifier = &routev3.HeaderMatcher_SafeRegexMatch{ + SafeRegexMatch: &matcherv3.RegexMatcher{ + EngineType: &matcherv3.RegexMatcher_GoogleRe2{GoogleRe2: &matcherv3.RegexMatcher_GoogleRE2{}}, + Regex: headerMatch.Value, + }, + } + default: + msg := fmt.Sprintf("unsupported header match type: %s", matchType) + return nil, createFailureCondition(gatewayv1.RouteReasonUnsupportedValue, msg, generation) + } + routeMatch.Headers = append(routeMatch.Headers, headerMatcher) + } + + // Translate Query Parameter Matches + for _, queryMatch := range match.QueryParams { + // Gateway API only supports "Exact" match for query parameters. + queryMatcher := &routev3.QueryParameterMatcher{ + Name: string(queryMatch.Name), + QueryParameterMatchSpecifier: &routev3.QueryParameterMatcher_StringMatch{ + StringMatch: &matcherv3.StringMatcher{ + MatchPattern: &matcherv3.StringMatcher_Exact{Exact: queryMatch.Value}, + }, + }, + } + routeMatch.QueryParameters = append(routeMatch.QueryParameters, queryMatcher) + } + + // If all translations were successful, return the final object and a success condition. + return routeMatch, createSuccessCondition(generation) +} + +func createSuccessCondition(generation int64) metav1.Condition { + return metav1.Condition{ + Type: string(gatewayv1.RouteConditionResolvedRefs), + Status: metav1.ConditionTrue, + Reason: string(gatewayv1.RouteReasonResolvedRefs), + Message: "All references resolved", + ObservedGeneration: generation, + LastTransitionTime: metav1.Now(), + } +} + +func createFailureCondition(reason gatewayv1.RouteConditionReason, message string, generation int64) metav1.Condition { + return metav1.Condition{ + Type: string(gatewayv1.RouteConditionResolvedRefs), + Status: metav1.ConditionFalse, + Reason: string(reason), + Message: message, + ObservedGeneration: generation, + LastTransitionTime: metav1.Now(), + } +} + +// sortRoutes is the definitive sorter for Envoy routes based on Gateway API precedence. +func sortRoutes(routes []*routev3.Route) { + sort.Slice(routes, func(i, j int) bool { + matchI := routes[i].GetMatch() + matchJ := routes[j].GetMatch() + + // De-prioritize the catch-all route, ensuring it's always last. + isCatchAllI := isCatchAll(matchI) + isCatchAllJ := isCatchAll(matchJ) + + if isCatchAllI != isCatchAllJ { + // If I is the catch-all, it should come after J (return false). + // If J is the catch-all, it should come after I (return true). + return isCatchAllJ + } + + // Precedence Rule 1: Exact Path Match vs. Other Path Matches + isExactPathI := matchI.GetPath() != "" + isExactPathJ := matchJ.GetPath() != "" + if isExactPathI != isExactPathJ { + return isExactPathI // Exact path is higher precedence + } + + // Precedence Rule 2: Longest Prefix Match + prefixI := getPathMatchValue(matchI) + prefixJ := getPathMatchValue(matchJ) + + if len(prefixI) != len(prefixJ) { + return len(prefixI) > len(prefixJ) // Longer prefix is higher precedence + } + + // Precedence Rule 3: Number of Header Matches + headerCountI := len(matchI.GetHeaders()) + headerCountJ := len(matchJ.GetHeaders()) + if headerCountI != headerCountJ { + return headerCountI > headerCountJ // More headers is higher precedence + } + + // Precedence Rule 4: Number of Query Param Matches + queryCountI := len(matchI.GetQueryParameters()) + queryCountJ := len(matchJ.GetQueryParameters()) + if queryCountI != queryCountJ { + return queryCountI > queryCountJ // More query params is higher precedence + } + + // If all else is equal, maintain original order (stable sort) + return false + }) +} + +// getPathMatchValue is a helper to extract the path string for comparison. +func getPathMatchValue(match *routev3.RouteMatch) string { + if match.GetPath() != "" { + return match.GetPath() + } + if match.GetPrefix() != "" { + return match.GetPrefix() + } + if match.GetPathSeparatedPrefix() != "" { + return match.GetPathSeparatedPrefix() + } + if sr := match.GetSafeRegex(); sr != nil { // Regex Match (used for other PathPrefix) + // This correctly handles the output of translateHTTPRouteMatch. + regex := sr.GetRegex() + // Remove the trailing regex that matches subpaths. + path := strings.TrimSuffix(regex, "(/.*)?") + // Remove the quoting added by regexp.QuoteMeta. + path = strings.ReplaceAll(path, `\`, "") + return path + } + return "" +} + +// isCatchAll determines if a route match is a generic "catch-all" rule. +// A catch-all matches all paths ("/") and has no other specific conditions. +func isCatchAll(match *routev3.RouteMatch) bool { + if match == nil { + return false + } + // It's a catch-all if the path match is for "/" AND there are no other constraints. + isRootPrefix := match.GetPrefix() == "/" + hasNoHeaders := len(match.GetHeaders()) == 0 + hasNoParams := len(match.GetQueryParameters()) == 0 + + return isRootPrefix && hasNoHeaders && hasNoParams +} diff --git a/pkg/translator/listener.go b/pkg/translator/listener.go new file mode 100644 index 0000000..43a95a4 --- /dev/null +++ b/pkg/translator/listener.go @@ -0,0 +1,602 @@ +/* +Copyright 2025 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package translator + +import ( + "context" + "encoding/pem" + "fmt" + "time" + + corev3 "github.com/envoyproxy/go-control-plane/envoy/config/core/v3" + listener "github.com/envoyproxy/go-control-plane/envoy/config/listener/v3" + routev3 "github.com/envoyproxy/go-control-plane/envoy/config/route/v3" + jwt_authnv3 "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/http/jwt_authn/v3" + mcpv3 "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/http/mcp/v3" + rbacv3 "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/http/rbac/v3" + routerv3 "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/http/router/v3" + tlsinspector "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/listener/tls_inspector/v3" + hcm "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/network/http_connection_manager/v3" + tcpproxyv3 "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/network/tcp_proxy/v3" + udpproxy "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/udp/udp_proxy/v3" + tlsv3 "github.com/envoyproxy/go-control-plane/envoy/extensions/transport_sockets/tls/v3" + "github.com/envoyproxy/go-control-plane/pkg/wellknown" + "google.golang.org/protobuf/types/known/anypb" + "google.golang.org/protobuf/types/known/durationpb" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/klog/v2" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" +) + +const ( + standardK8sOIDCJWKSURI = "https://kubernetes.default.svc/openid/v1/jwks" + // Cache the keys for 1 hour to avoid spamming the API server + jwksCacheDuration = 1 * time.Hour + uriTimeout = 5 * time.Second +) + +// setListenerCondition is a helper to safely set a condition on a listener's status +// in a map of conditions. +func setListenerCondition( + conditionsMap map[gatewayv1.SectionName][]metav1.Condition, + listenerName gatewayv1.SectionName, + condition metav1.Condition, +) { + // This "get, modify, set" pattern is the standard way to + // work around the Go constraint that map values are not addressable. + conditions := conditionsMap[listenerName] + if conditions == nil { + conditions = []metav1.Condition{} + } + meta.SetStatusCondition(&conditions, condition) + conditionsMap[listenerName] = conditions +} + +// validateListeners checks for conflicts among all listeners on a Gateway as per the spec. +// It returns a map of conflicted listener conditions and a Gateway-level condition if any conflicts exist. +func (t *Translator) validateListeners(gateway *gatewayv1.Gateway) map[gatewayv1.SectionName][]metav1.Condition { + listenerConditions := make(map[gatewayv1.SectionName][]metav1.Condition) + for _, listener := range gateway.Spec.Listeners { + // Initialize with a fresh slice. + listenerConditions[listener.Name] = []metav1.Condition{} + } + + // Check for Port and Hostname Conflicts + listenersByPort := make(map[gatewayv1.PortNumber][]gatewayv1.Listener) + for _, listener := range gateway.Spec.Listeners { + listenersByPort[listener.Port] = append(listenersByPort[listener.Port], listener) + } + + for _, listenersOnPort := range listenersByPort { + // Rule: A TCP listener cannot share a port with HTTP/HTTPS/TLS listeners. + hasTCP := false + hasHTTPTLS := false + for _, listener := range listenersOnPort { + if listener.Protocol == gatewayv1.TCPProtocolType || listener.Protocol == gatewayv1.UDPProtocolType { + hasTCP = true + } + if listener.Protocol == gatewayv1.HTTPProtocolType || listener.Protocol == gatewayv1.HTTPSProtocolType || listener.Protocol == gatewayv1.TLSProtocolType { + hasHTTPTLS = true + } + } + + if hasTCP && hasHTTPTLS { + for _, listener := range listenersOnPort { + setListenerCondition(listenerConditions, listener.Name, metav1.Condition{ + Type: string(gatewayv1.ListenerConditionConflicted), + Status: metav1.ConditionTrue, + Reason: string(gatewayv1.ListenerReasonProtocolConflict), + Message: "Protocol conflict: TCP/UDP listeners cannot share a port with HTTP/HTTPS/TLS listeners.", + }) + } + continue // Skip further checks for this port + } + + // Rule: HTTP/HTTPS/TLS listeners on the same port must have unique hostnames. + seenHostnames := make(map[gatewayv1.Hostname]gatewayv1.SectionName) + for _, listener := range listenersOnPort { + // This check only applies to protocols that use hostnames for distinction. + if listener.Protocol == gatewayv1.HTTPProtocolType || listener.Protocol == gatewayv1.HTTPSProtocolType || listener.Protocol == gatewayv1.TLSProtocolType { + hostname := gatewayv1.Hostname("") + if listener.Hostname != nil { + hostname = *listener.Hostname + } + + if conflictingListenerName, exists := seenHostnames[hostname]; exists { + conflictedCondition := metav1.Condition{ + Type: string(gatewayv1.ListenerConditionConflicted), + Status: metav1.ConditionTrue, + Reason: string(gatewayv1.ListenerReasonHostnameConflict), + Message: fmt.Sprintf("Hostname '%s' conflicts with another listener on the same port.", hostname), + } + setListenerCondition(listenerConditions, listener.Name, conflictedCondition) + setListenerCondition(listenerConditions, conflictingListenerName, conflictedCondition) + } else { + seenHostnames[hostname] = listener.Name + } + } + } + } + + for _, listener := range gateway.Spec.Listeners { + // If a listener is already conflicted, we don't need to check its secrets. + if meta.IsStatusConditionTrue(listenerConditions[listener.Name], string(gatewayv1.ListenerConditionConflicted)) { + continue + } + + if listener.TLS == nil { + // No TLS config, so no secrets to resolve. This listener is considered resolved. + setListenerCondition(listenerConditions, listener.Name, metav1.Condition{ + Type: string(gatewayv1.ListenerConditionResolvedRefs), + Status: metav1.ConditionTrue, + Reason: string(gatewayv1.ListenerReasonResolvedRefs), + Message: "All references resolved", + }) + continue + } + + for _, certRef := range listener.TLS.CertificateRefs { + if certRef.Group != nil && *certRef.Group != "" { + setListenerCondition(listenerConditions, listener.Name, metav1.Condition{ + Type: string(gatewayv1.ListenerConditionResolvedRefs), + Status: metav1.ConditionFalse, + Reason: string(gatewayv1.ListenerReasonInvalidCertificateRef), + Message: fmt.Sprintf("unsupported certificate ref grup: %s", *certRef.Group), + }) + break + } + + if certRef.Kind != nil && *certRef.Kind != "Secret" { + setListenerCondition(listenerConditions, listener.Name, metav1.Condition{ + Type: string(gatewayv1.ListenerConditionResolvedRefs), + Status: metav1.ConditionFalse, + Reason: string(gatewayv1.ListenerReasonInvalidCertificateRef), + Message: fmt.Sprintf("unsupported certificate ref kind: %s", *certRef.Kind), + }) + break + } + + secretNamespace := gateway.Namespace + if certRef.Namespace != nil { + secretNamespace = string(*certRef.Namespace) + } + + secret, err := t.secretLister.Secrets(secretNamespace).Get(string(certRef.Name)) + if err != nil { + setListenerCondition(listenerConditions, listener.Name, metav1.Condition{ + Type: string(gatewayv1.ListenerConditionResolvedRefs), + Status: metav1.ConditionFalse, + Reason: string(gatewayv1.ListenerReasonInvalidCertificateRef), + Message: fmt.Sprintf("reference to Secret %s/%s not found", secretNamespace, certRef.Name), + }) + break + } + if err := validateSecretCertificate(secret); err != nil { + setListenerCondition(listenerConditions, listener.Name, metav1.Condition{ + Type: string(gatewayv1.ListenerConditionResolvedRefs), + Status: metav1.ConditionFalse, + Reason: string(gatewayv1.ListenerReasonInvalidCertificateRef), + Message: fmt.Sprintf("malformed Secret %s/%s : %v", secretNamespace, certRef.Name, err.Error()), + }) + break + } + } + + // Set the ResolvedRefs condition based on the outcome of the secret validation. + if !meta.IsStatusConditionFalse(listenerConditions[listener.Name], string(gatewayv1.ListenerConditionResolvedRefs)) { + setListenerCondition(listenerConditions, listener.Name, metav1.Condition{ + Type: string(gatewayv1.ListenerConditionResolvedRefs), + Status: metav1.ConditionTrue, + Reason: string(gatewayv1.ListenerReasonResolvedRefs), + Message: "All references resolved", + }) + } + } + + return listenerConditions +} + +func (t *Translator) translateListenerToFilterChain(gateway *gatewayv1.Gateway, lis gatewayv1.Listener, virtualHosts []*routev3.VirtualHost, routeName string) (*listener.FilterChain, error) { + var filterChain *listener.FilterChain + var err error + + switch lis.Protocol { + case gatewayv1.HTTPProtocolType, gatewayv1.HTTPSProtocolType: + filterChain, err = buildHTTPFilterChain(lis, routeName, virtualHosts, t.jwtIssuer) + case gatewayv1.TCPProtocolType, gatewayv1.TLSProtocolType: + filterChain, err = buildTCPFilterChain(lis) + case gatewayv1.UDPProtocolType: + filterChain, err = buildUDPFilterChain(lis) + } + if err != nil { + return nil, err + } + + // Add SNI matching for applicable protocols + if lis.Protocol == gatewayv1.HTTPSProtocolType || lis.Protocol == gatewayv1.TLSProtocolType { + if lis.Hostname != nil && *lis.Hostname != "" { + filterChain.FilterChainMatch = &listener.FilterChainMatch{ + ServerNames: []string{string(*lis.Hostname)}, + } + } + // Configure TLS context + tlsContext, err := t.buildDownstreamTLSContext(context.Background(), gateway, lis) + if err != nil { + return nil, fmt.Errorf("failed to build TLS context for listener %s: %w", lis.Name, err) + } + if tlsContext != nil { + filterChain.TransportSocket = &corev3.TransportSocket{ + Name: "envoy.transport_sockets.tls", + ConfigType: &corev3.TransportSocket_TypedConfig{ + TypedConfig: tlsContext, + }, + } + } + } + + return filterChain, nil +} + +func buildHTTPFilterChain(lis gatewayv1.Listener, routeName string, virtualHosts []*routev3.VirtualHost, jwtIssuer string) (*listener.FilterChain, error) { + httpFilters, err := buildHTTPFilters(jwtIssuer) + if err != nil { + return nil, err + } + + // Embed the route configuration directly in the HttpConnectionManager. + // This is known as an "inline" RouteConfig, as opposed to fetching it dynamically via RDS. + inlineRouteConfig := &hcm.HttpConnectionManager_RouteConfig{ + RouteConfig: &routev3.RouteConfiguration{ + Name: routeName, + VirtualHosts: virtualHosts, + }, + } + + hcmConfig := &hcm.HttpConnectionManager{ + StatPrefix: string(lis.Name), + RouteSpecifier: inlineRouteConfig, + HttpFilters: httpFilters, + } + hcmAny, err := anypb.New(hcmConfig) + if err != nil { + return nil, err + } + + return &listener.FilterChain{ + Filters: []*listener.Filter{{ + Name: wellknown.HTTPConnectionManager, + ConfigType: &listener.Filter_TypedConfig{ + TypedConfig: hcmAny, + }, + }}, + }, nil +} + +func buildTCPFilterChain(lis gatewayv1.Listener) (*listener.FilterChain, error) { + // TCP and TLS listeners require a TCP proxy filter. + // We'll assume for now that routes for these are not supported and it's a direct pass-through. + tcpProxy := &tcpproxyv3.TcpProxy{ + StatPrefix: string(lis.Name), + ClusterSpecifier: &tcpproxyv3.TcpProxy_Cluster{ + Cluster: "some_static_cluster", // This needs to be determined from a TCPRoute/TLSRoute + }, + } + tcpProxyAny, err := anypb.New(tcpProxy) + if err != nil { + return nil, err + } + return &listener.FilterChain{ + Filters: []*listener.Filter{{ + Name: wellknown.TCPProxy, + ConfigType: &listener.Filter_TypedConfig{ + TypedConfig: tcpProxyAny, + }, + }}, + }, nil +} + +func buildUDPFilterChain(lis gatewayv1.Listener) (*listener.FilterChain, error) { + udpProxy := &udpproxy.UdpProxyConfig{ + StatPrefix: string(lis.Name), + RouteSpecifier: &udpproxy.UdpProxyConfig_Cluster{ + Cluster: "some_udp_cluster", // This needs to be determined from a UDPRoute + }, + } + udpProxyAny, err := anypb.New(udpProxy) + if err != nil { + return nil, err + } + return &listener.FilterChain{ + Filters: []*listener.Filter{{ + Name: "envoy.filters.udp_listener.udp_proxy", + ConfigType: &listener.Filter_TypedConfig{ + TypedConfig: udpProxyAny, + }, + }}, + }, nil +} + +func buildHTTPFilters(issuer string) ([]*hcm.HttpFilter, error) { + // Configure JWT filter globally as it's needed for all routes to establish identity. + jwtAuthnFilter, err := buildJwtAuthnFilter(issuer) + if err != nil { + return nil, err + } + + mcpFilter, err := buildMCPFilter() + if err != nil { + return nil, err + } + rbacFilter, err := buildRBACFilter() + if err != nil { + return nil, err + } + + routerFilter, err := buildRouterFilter() + if err != nil { + return nil, err + } + + return []*hcm.HttpFilter{ + // IMPORTANT: Order matters here! + // JWT filter must come before RBAC to populate claims for evaluation. + // RBAC filter must come before the router filter to enforce access control before routing. + // Router filter must come last to handle routing after all other filters have processed the request. + jwtAuthnFilter, + mcpFilter, + rbacFilter, + routerFilter, + }, nil +} + +func buildJwtAuthnFilter(issuer string) (*hcm.HttpFilter, error) { + jwtProto := &jwt_authnv3.JwtAuthentication{ + Providers: map[string]*jwt_authnv3.JwtProvider{ + "kubernetes_provider": { + Issuer: issuer, // Dynamically set issuer + JwksSourceSpecifier: &jwt_authnv3.JwtProvider_RemoteJwks{ + RemoteJwks: &jwt_authnv3.RemoteJwks{ + HttpUri: &corev3.HttpUri{ + Uri: standardK8sOIDCJWKSURI, + HttpUpstreamType: &corev3.HttpUri_Cluster{ + Cluster: k8sAPIClusterName, + }, + Timeout: durationpb.New(uriTimeout), + }, + CacheDuration: durationpb.New(jwksCacheDuration), + }, + }, + FromHeaders: []*jwt_authnv3.JwtHeader{ + { + Name: saAuthTokenHeader, + }, + }, + ClaimToHeaders: []*jwt_authnv3.JwtClaimToHeader{ + { + ClaimName: "sub", + HeaderName: userRoleHeader, + }, + }, + }, + }, + Rules: []*jwt_authnv3.RequirementRule{ + { + Match: &routev3.RouteMatch{ + PathSpecifier: &routev3.RouteMatch_Prefix{Prefix: "/"}, + }, + RequirementType: &jwt_authnv3.RequirementRule_Requires{ + Requires: &jwt_authnv3.JwtRequirement{ + RequiresType: &jwt_authnv3.JwtRequirement_ProviderName{ + ProviderName: "kubernetes_provider", + }, + }, + }, + }, + }, + } + jwtAny, err := anypb.New(jwtProto) + if err != nil { + klog.Errorf("Failed to marshal jwt_authn config: %v", err) + return nil, err + } + + return &hcm.HttpFilter{ + Name: "envoy.filters.http.jwt_authn", + ConfigType: &hcm.HttpFilter_TypedConfig{ + TypedConfig: jwtAny, + }, + }, nil +} + +func buildMCPFilter() (*hcm.HttpFilter, error) { + mcpProto := &mcpv3.Mcp{} + mcpAny, err := anypb.New(mcpProto) + if err != nil { + klog.Errorf("Failed to marshal mcp config: %v", err) + return nil, err + } + + return &hcm.HttpFilter{ + Name: "envoy.filters.http.mcp", + ConfigType: &hcm.HttpFilter_TypedConfig{ + TypedConfig: mcpAny, + }, + }, nil +} + +func buildRBACFilter() (*hcm.HttpFilter, error) { + rbacProto := &rbacv3.RBAC{} + rbacAny, err := anypb.New(rbacProto) + if err != nil { + klog.Errorf("Failed to marshal rbac config: %v", err) + return nil, err + } + + return &hcm.HttpFilter{ + Name: wellknown.HTTPRoleBasedAccessControl, + ConfigType: &hcm.HttpFilter_TypedConfig{ + TypedConfig: rbacAny, + }, + }, nil +} + +func buildRouterFilter() (*hcm.HttpFilter, error) { + routerProto := &routerv3.Router{} + routerAny, err := anypb.New(routerProto) + if err != nil { + klog.Errorf("Failed to marshal router config: %v", err) + return nil, err + } + + return &hcm.HttpFilter{ + Name: wellknown.Router, + ConfigType: &hcm.HttpFilter_TypedConfig{ + TypedConfig: routerAny, + }, + }, nil +} + +func (t *Translator) buildDownstreamTLSContext(ctx context.Context, gateway *gatewayv1.Gateway, lis gatewayv1.Listener) (*anypb.Any, error) { + if lis.TLS == nil { + return nil, nil + } + if len(lis.TLS.CertificateRefs) == 0 { + return nil, fmt.Errorf("TLS is configured, but no certificate refs are provided") + } + + tlsContext := &tlsv3.DownstreamTlsContext{ + CommonTlsContext: &tlsv3.CommonTlsContext{ + TlsCertificates: []*tlsv3.TlsCertificate{}, + }, + } + + for _, certRef := range lis.TLS.CertificateRefs { + if certRef.Group != nil && *certRef.Group != "" { + return nil, fmt.Errorf("unsupported certificate ref group: %s", *certRef.Group) + } + if certRef.Kind != nil && *certRef.Kind != "Secret" { + return nil, fmt.Errorf("unsupported certificate ref kind: %s", *certRef.Kind) + } + + secretNamespace := gateway.Namespace + if certRef.Namespace != nil { + secretNamespace = string(*certRef.Namespace) + } + + secretName := string(certRef.Name) + secret, err := t.secretLister.Secrets(secretNamespace).Get(secretName) + if err != nil { + // Per the spec, if the grant was missing, we must not reveal that the secret doesn't exist. + // The error from the grant check above takes precedence. + return nil, fmt.Errorf("failed to get secret %s/%s: %w", secretNamespace, secretName, err) + } + + tlsCert, err := toEnvoyTlsCertificate(secret) + if err != nil { + return nil, fmt.Errorf("failed to convert secret to tls certificate: %v", err) + } + tlsContext.CommonTlsContext.TlsCertificates = append(tlsContext.CommonTlsContext.TlsCertificates, tlsCert) + } + + any, err := anypb.New(tlsContext) + if err != nil { + return nil, err + } + return any, nil +} + +func validateSecretCertificate(secret *corev1.Secret) error { + privateKey, ok := secret.Data[corev1.TLSPrivateKeyKey] + if !ok { + return fmt.Errorf("secret %s/%s does not contain key %s", secret.Namespace, secret.Name, corev1.TLSPrivateKeyKey) + } + block, _ := pem.Decode(privateKey) + if block == nil { + return fmt.Errorf("secret %s/%s key %s does not contain a valid PEM-encoded private key", secret.Namespace, secret.Name, corev1.TLSPrivateKeyKey) + } + + certChain, ok := secret.Data[corev1.TLSCertKey] + if !ok { + return fmt.Errorf("secret %s/%s does not contain key %s", secret.Namespace, secret.Name, corev1.TLSCertKey) + } + block, _ = pem.Decode(certChain) + if block == nil { + return fmt.Errorf("secret %s/%s key %s does not contain a valid PEM-encoded certificate chain", secret.Namespace, secret.Name, corev1.TLSCertKey) + } + return nil +} + +func toEnvoyTlsCertificate(secret *corev1.Secret) (*tlsv3.TlsCertificate, error) { + privateKey, ok := secret.Data[corev1.TLSPrivateKeyKey] + if !ok { + return nil, fmt.Errorf("secret %s/%s does not contain key %s", secret.Namespace, secret.Name, corev1.TLSPrivateKeyKey) + } + block, _ := pem.Decode(privateKey) + if block == nil { + return nil, fmt.Errorf("secret %s/%s key %s does not contain a valid PEM-encoded private key", secret.Namespace, secret.Name, corev1.TLSPrivateKeyKey) + } + + certChain, ok := secret.Data[corev1.TLSCertKey] + if !ok { + return nil, fmt.Errorf("secret %s/%s does not contain key %s", secret.Namespace, secret.Name, corev1.TLSCertKey) + } + block, _ = pem.Decode(certChain) + if block == nil { + return nil, fmt.Errorf("secret %s/%s key %s does not contain a valid PEM-encoded certificate chain", secret.Namespace, secret.Name, corev1.TLSCertKey) + } + + return &tlsv3.TlsCertificate{ + CertificateChain: &corev3.DataSource{ + Specifier: &corev3.DataSource_InlineBytes{ + InlineBytes: certChain, + }, + }, + PrivateKey: &corev3.DataSource{ + Specifier: &corev3.DataSource_InlineBytes{ + InlineBytes: privateKey, + }, + }, + }, nil +} + +func createEnvoyAddress(port uint32) *corev3.Address { + return &corev3.Address{ + Address: &corev3.Address_SocketAddress{ + SocketAddress: &corev3.SocketAddress{ + Protocol: corev3.SocketAddress_TCP, + Address: "0.0.0.0", + PortSpecifier: &corev3.SocketAddress_PortValue{ + PortValue: port, + }, + }, + }, + } +} + +func createListenerFilters() []*listener.ListenerFilter { + tlsInspectorConfig, _ := anypb.New(&tlsinspector.TlsInspector{}) + return []*listener.ListenerFilter{ + { + Name: wellknown.TlsInspector, + ConfigType: &listener.ListenerFilter_TypedConfig{ + TypedConfig: tlsInspectorConfig, + }, + }, + } +} diff --git a/pkg/translator/names.go b/pkg/translator/names.go new file mode 100644 index 0000000..6ff8097 --- /dev/null +++ b/pkg/translator/names.go @@ -0,0 +1,42 @@ +/* +Copyright 2025 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package translator + +const ( + // listenerNameFormat is the format string for Envoy listener names, becoming `listener-`. + listenerNameFormat = "listener-%d" + // routeNameFormat is the format string for Envoy route configuration names, becoming `route-`. + routeNameFormat = "route-%d" + // envoyRouteNameFormat is the format string for individual Envoy route names within a RouteConfiguration, + // becoming `--rule-match`. + envoyRouteNameFormat = "%s-%s-rule%d-match%d" + // vHostNameFormat is the format string for Envoy virtual host names, becoming `-vh--`. + vHostNameFormat = "%s-vh-%d-%s" + // clusterNameFormat is the format string for Envoy cluster names, becoming `-`. + clusterNameFormat = "%s-%s" + // rbacPolicyNameFormat is the format string for Envoy RBAC policies, becoming `--rule-`. + rbacPolicyNameFormat = "%s-%s-rule-%d" +) + +const ( + // k8sAPIClusterName is the name of the cluster that points to the Kubernetes API server. + k8sAPIClusterName = "kubernetes_api_cluster" + // saAuthTokenHeader is the header used to carry the Kubernetes service account token. + saAuthTokenHeader = "x-k8s-sa-token" + // userRoleHeader is the header populated with the subject claim from the JWT. + userRoleHeader = "x-user-role" +) diff --git a/pkg/translator/routes.go b/pkg/translator/routes.go new file mode 100644 index 0000000..471b676 --- /dev/null +++ b/pkg/translator/routes.go @@ -0,0 +1,243 @@ +/* +Copyright 2025 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package translator + +import ( + "strings" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/util/sets" + corev1listers "k8s.io/client-go/listers/core/v1" + "k8s.io/klog/v2" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" +) + +// isAllowedByListener checks if a given route is allowed to attach to a listener +// based on the listener's `allowedRoutes` specification for namespaces and kinds. +func isAllowedByListener(gateway *gatewayv1.Gateway, listener gatewayv1.Listener, route metav1.Object, namespaceLister corev1listers.NamespaceLister) bool { + allowed := listener.AllowedRoutes + if allowed == nil { + // If AllowedRoutes is not set, only routes in the same namespace are allowed. + return route.GetNamespace() == gateway.GetNamespace() + } + + routeNamespace := route.GetNamespace() + gatewayNamespace := gateway.GetNamespace() + + // Check if the route's namespace is allowed. + namespaceAllowed := false + effectiveFrom := gatewayv1.NamespacesFromSame + if allowed.Namespaces != nil && allowed.Namespaces.From != nil { + effectiveFrom = *allowed.Namespaces.From + } + + switch effectiveFrom { + case gatewayv1.NamespacesFromAll: + namespaceAllowed = true + case gatewayv1.NamespacesFromSame: + namespaceAllowed = (routeNamespace == gatewayNamespace) + case gatewayv1.NamespacesFromSelector: + if allowed.Namespaces.Selector == nil { + klog.Errorf("Invalid AllowedRoutes: Namespaces.From is 'Selector' but Namespaces.Selector is nil for Gateway %s/%s, Listener %s", gatewayNamespace, gateway.GetName(), listener.Name) + return false + } + if namespaceLister == nil { + klog.Warningf("Namespace selection using 'Selector' requires a Namespace Lister, but none was provided. Denying route %s/%s.", routeNamespace, route.GetName()) + return false + } + selector, err := metav1.LabelSelectorAsSelector(allowed.Namespaces.Selector) + if err != nil { + klog.Errorf("Failed to parse label selector for Gateway %s/%s, Listener %s: %v", gatewayNamespace, gateway.GetName(), listener.Name, err) + return false + } + routeNsObj, err := namespaceLister.Get(routeNamespace) + if err != nil { + klog.Warningf("Failed to get namespace %s for route %s/%s: %v", routeNamespace, routeNamespace, route.GetName(), err) + return false + } + namespaceAllowed = selector.Matches(labels.Set(routeNsObj.GetLabels())) + default: + klog.Errorf("Unknown 'From' value %q in AllowedRoutes.Namespaces for Gateway %s/%s, Listener %s", effectiveFrom, gatewayNamespace, gateway.GetName(), listener.Name) + return false + } + + if !namespaceAllowed { + return false + } + + // If namespaces are allowed, check if the route's kind is allowed. + if len(allowed.Kinds) == 0 { + // No kinds specified, so all kinds are allowed. + return true + } + + var routeGroup, routeKind string + switch route.(type) { + case *gatewayv1.HTTPRoute: + routeGroup = gatewayv1.GroupName + routeKind = "HTTPRoute" + case *gatewayv1.GRPCRoute: + routeGroup = gatewayv1.GroupName + routeKind = "GRPCRoute" + default: + klog.Warningf("Cannot determine GroupKind for route object type %T for route %s/%s", route, routeNamespace, route.GetName()) + return false + } + + for _, allowedKind := range allowed.Kinds { + allowedGroup := gatewayv1.Group(gatewayv1.GroupName) + if allowedKind.Group != nil && *allowedKind.Group != "" { + allowedGroup = *allowedKind.Group + } + if routeKind == string(allowedKind.Kind) && routeGroup == string(allowedGroup) { + return true + } + } + + // The route's kind is not in the allowed list. + return false +} + +// isAllowedByHostname checks if a route is allowed to attach to a listener +// based on hostname matching rules. +func isAllowedByHostname(listener gatewayv1.Listener, route metav1.Object) bool { + // If the listener specifies no hostname, it allows all route hostnames. + if listener.Hostname == nil || *listener.Hostname == "" { + return true + } + listenerHostname := string(*listener.Hostname) + + var routeHostnames []gatewayv1.Hostname + switch r := route.(type) { + case *gatewayv1.HTTPRoute: + routeHostnames = r.Spec.Hostnames + case *gatewayv1.GRPCRoute: + routeHostnames = r.Spec.Hostnames + default: + // Not a type with hostnames, so no hostname check needed. + return true + } + + // If the route specifies no hostnames, it inherits from the listener, which is always valid. + if len(routeHostnames) == 0 { + return true + } + + // If the route specifies hostnames, at least one must be permitted by the listener. + for _, routeHostname := range routeHostnames { + if isHostnameSubset(string(routeHostname), listenerHostname) { + // Found a valid hostname match. The route is allowed by this listener. + return true + } + } + + // If we reach here, the route specified hostnames, but NONE of them were valid + // for this listener. The route must not be attached. + return false +} + +// getIntersectingHostnames calculates the precise set of hostnames for an Envoy VirtualHost, +// resolving each match to the most restrictive hostname as per the Gateway API specification. +// It returns a slice of the resulting hostnames, or an empty slice if there is no valid intersection. +func getIntersectingHostnames(listener gatewayv1.Listener, routeHostnames []gatewayv1.Hostname) []string { + // Case 1: The listener has no hostname specified. It acts as a universal wildcard, + // allowing any and all hostnames from the route. + if listener.Hostname == nil || *listener.Hostname == "" { + if len(routeHostnames) == 0 { + return []string{"*"} // Universal match + } + // The result is simply the route's own hostnames. + var names []string + for _, h := range routeHostnames { + names = append(names, string(h)) + } + return names + } + listenerHostname := string(*listener.Hostname) + + // Case 2: The route has no hostnames. It implicitly inherits the listener's specific hostname. + if len(routeHostnames) == 0 { + return []string{listenerHostname} + } + + // Case 3: Both have hostnames. We must find the intersection and then determine + // the most restrictive hostname for each match. + intersection := sets.New[string]() + for _, h := range routeHostnames { + routeHostname := string(h) + if isHostnameSubset(routeHostname, listenerHostname) { + // A valid intersection was found. Now, determine the most specific + // hostname to use for the configuration. + if strings.HasPrefix(routeHostname, "*") && !strings.HasPrefix(listenerHostname, "*") { + // If the route is a wildcard and the listener is specific, the listener's + // specific hostname is the most restrictive result. + intersection.Insert(listenerHostname) + } else { + // In all other valid cases (exact match, specific route on a wildcard listener), + // the route's hostname is the most restrictive result. + intersection.Insert(routeHostname) + } + } + } + + // Return the unique set of resulting hostnames. + return intersection.UnsortedList() +} + +// isHostnameSubset checks if a route hostname is a valid subset of a listener hostname, +// implementing the precise matching rules from the Gateway API specification. +func isHostnameSubset(routeHostname, listenerHostname string) bool { + // Rule 1: An exact match is always a valid intersection. + if routeHostname == listenerHostname { + return true + } + + // Rule 2: Listener has a wildcard (e.g., "*.example.com"). + if strings.HasPrefix(listenerHostname, "*.") { + // Use the part of the string including the dot as the suffix. + listenerSuffix := listenerHostname[1:] // e.g., ".example.com" + + // Case 2a: Route also has a wildcard (e.g., "*.foo.example.com"). + // The route's suffix must be identical to or a sub-suffix of the listener's. + if strings.HasPrefix(routeHostname, "*.") { + routeSuffix := routeHostname[1:] // e.g., ".foo.example.com" + return strings.HasSuffix(routeSuffix, listenerSuffix) + } + + // Case 2b: Route is specific (e.g., "foo.example.com"). + // The route must end with the listener's suffix. This correctly handles + // the "parent domain" case because "example.com" does not have the + // suffix ".example.com". + return strings.HasSuffix(routeHostname, listenerSuffix) + } + + // Rule 3: Route has a wildcard (e.g., "*.example.com"). + if strings.HasPrefix(routeHostname, "*.") { + routeSuffix := routeHostname[1:] // e.g., ".example.com" + routeDomain := routeHostname[2:] // e.g., "example.com" + + // The listener must be more specific (not a wildcard). + if !strings.HasPrefix(listenerHostname, "*.") { + // The listener hostname must be the parent domain or a subdomain. + // e.g., "example.com" or "foo.example.com" are subsets of "*.example.com". + return listenerHostname == routeDomain || strings.HasSuffix(listenerHostname, routeSuffix) + } + } + + return false +} diff --git a/pkg/translator/translator.go b/pkg/translator/translator.go new file mode 100644 index 0000000..18fa26f --- /dev/null +++ b/pkg/translator/translator.go @@ -0,0 +1,548 @@ +/* +Copyright 2025 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package translator + +import ( + "context" + "fmt" + + corev3 "github.com/envoyproxy/go-control-plane/envoy/config/core/v3" + endpointv3 "github.com/envoyproxy/go-control-plane/envoy/config/endpoint/v3" + listenerv3 "github.com/envoyproxy/go-control-plane/envoy/config/listener/v3" + routev3 "github.com/envoyproxy/go-control-plane/envoy/config/route/v3" + envoyproxytypes "github.com/envoyproxy/go-control-plane/pkg/cache/types" + resourcev3 "github.com/envoyproxy/go-control-plane/pkg/resource/v3" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/sets" + "k8s.io/client-go/kubernetes" + corev1listers "k8s.io/client-go/listers/core/v1" + "k8s.io/klog/v2" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" + gatewayclient "sigs.k8s.io/gateway-api/pkg/client/clientset/versioned" + gatewaylisters "sigs.k8s.io/gateway-api/pkg/client/listers/apis/v1" + agenticlisters "sigs.k8s.io/kube-agentic-networking/k8s/client/listers/api/v0alpha0" +) + +type ControllerError struct { + Reason string + Message string +} + +// Error implements the error interface. +func (e *ControllerError) Error() string { + return e.Message +} + +// Translator holds the xDS cache and version for generating snapshots. +type Translator struct { + jwtIssuer string + client kubernetes.Interface + gwClient gatewayclient.Interface + namespaceLister corev1listers.NamespaceLister + serviceLister corev1listers.ServiceLister + secretLister corev1listers.SecretLister + gatewayLister gatewaylisters.GatewayLister + httprouteLister gatewaylisters.HTTPRouteLister + accessPolicyLister agenticlisters.XAccessPolicyLister + backendLister agenticlisters.XBackendLister +} + +func New( + jwtIssuer string, + client kubernetes.Interface, + gwClient gatewayclient.Interface, + namespaceLister corev1listers.NamespaceLister, + serviceLister corev1listers.ServiceLister, + secretLister corev1listers.SecretLister, + gatewayLister gatewaylisters.GatewayLister, + httpRouteLister gatewaylisters.HTTPRouteLister, + accessPolicyLister agenticlisters.XAccessPolicyLister, + backendLister agenticlisters.XBackendLister, +) *Translator { + return &Translator{ + jwtIssuer, + client, + gwClient, + namespaceLister, + serviceLister, + secretLister, + gatewayLister, + httpRouteLister, + accessPolicyLister, + backendLister, + } +} + +// TranslateGatewayToXDS translates Gateway and HTTPRoute resources into Envoy xDS resources. +func (t *Translator) TranslateGatewayToXDS(ctx context.Context, gw *gatewayv1.Gateway) (map[resourcev3.Type][]envoyproxytypes.Resource, error) { + // Get the desired state + envoyResources, _, _, err := t.buildEnvoyResourcesForGateway(gw) + if err != nil { + return nil, err + } + + return envoyResources, nil +} + +var ( + SupportedKinds = sets.New[gatewayv1.Kind]( + "HTTPRoute", + ) +) + +// Main State Calculation Function +func (t *Translator) buildEnvoyResourcesForGateway(gateway *gatewayv1.Gateway) ( + map[resourcev3.Type][]envoyproxytypes.Resource, + []gatewayv1.ListenerStatus, + map[types.NamespacedName][]gatewayv1.RouteParentStatus, // HTTPRoutes + error, +) { + + httpRouteStatuses := make(map[types.NamespacedName][]gatewayv1.RouteParentStatus) + routesByListener := make(map[gatewayv1.SectionName][]*gatewayv1.HTTPRoute) + + // 1. List HTTPRoutes referencing this Gateway + allHTTPRoutesForGateway := t.getHTTPRoutesForGateway(gateway) + // 2. Validate each HTTPRoute and group accepted ones by listener + for _, httpRoute := range allHTTPRoutesForGateway { + key := types.NamespacedName{Name: httpRoute.Name, Namespace: httpRoute.Namespace} + parentStatuses, acceptingListeners := t.validateHTTPRoute(gateway, httpRoute) + + // Store the definitive status for the route. + if len(parentStatuses) > 0 { + httpRouteStatuses[key] = parentStatuses + } + // If the route was accepted, associate it with the listeners that accepted it. + if len(acceptingListeners) > 0 { + // Associate the accepted route with the listeners that will handle it. + // Use a set to prevent adding a route multiple times to the same listener. + processedListeners := make(map[gatewayv1.SectionName]bool) + for _, listener := range acceptingListeners { + if _, ok := processedListeners[listener.Name]; !ok { + routesByListener[listener.Name] = append(routesByListener[listener.Name], httpRoute) + processedListeners[listener.Name] = true + } + } + } + } + + // Start building Envoy config using only the pre-validated and accepted routes + envoyRoutes := []envoyproxytypes.Resource{} + envoyClusters := make(map[string]envoyproxytypes.Resource) + allListenerStatuses := make(map[gatewayv1.SectionName]gatewayv1.ListenerStatus) + + // 3. Group Gateway listeners by port + listenersByPort := make(map[gatewayv1.PortNumber][]gatewayv1.Listener) + for _, listener := range gateway.Spec.Listeners { + listenersByPort[listener.Port] = append(listenersByPort[listener.Port], listener) + } + + // validate listeners that may reuse the same port + listenerValidationConditions := t.validateListeners(gateway) + + finalEnvoyListeners := []envoyproxytypes.Resource{} + // 4. For each port group, process Listeners (build routes & filter chains) + for port, listeners := range listenersByPort { + // This slice will hold the filter chains. + var filterChains []*listenerv3.FilterChain + // Prepare to collect ALL virtual hosts for this port into a single list. + virtualHostsForPort := make(map[string]*routev3.VirtualHost) + routeName := fmt.Sprintf(routeNameFormat, port) + + // All these listeners have the same port + for _, listener := range listeners { + var attachedRoutes int32 + listenerStatus := gatewayv1.ListenerStatus{ + Name: gatewayv1.SectionName(listener.Name), + SupportedKinds: []gatewayv1.RouteGroupKind{}, + Conditions: listenerValidationConditions[listener.Name], + AttachedRoutes: 0, + } + supportedKinds, allKindsValid := getSupportedKinds(listener) + listenerStatus.SupportedKinds = supportedKinds + + if !allKindsValid { + meta.SetStatusCondition(&listenerStatus.Conditions, metav1.Condition{ + Type: string(gatewayv1.ListenerConditionResolvedRefs), + Status: metav1.ConditionFalse, + Reason: string(gatewayv1.ListenerReasonInvalidRouteKinds), + Message: "Invalid route kinds specified in allowedRoutes", + ObservedGeneration: gateway.Generation, + }) + allListenerStatuses[listener.Name] = listenerStatus + continue // Stop processing this invalid listener + } + + isConflicted := meta.IsStatusConditionTrue(listenerStatus.Conditions, string(gatewayv1.ListenerConditionConflicted)) + // If the listener is conflicted set its status and skip Envoy config generation. + if isConflicted { + allListenerStatuses[listener.Name] = listenerStatus + continue + } + + // If there are not references issues then set condition to true + if !meta.IsStatusConditionFalse(listenerStatus.Conditions, string(gatewayv1.ListenerConditionResolvedRefs)) { + meta.SetStatusCondition(&listenerStatus.Conditions, metav1.Condition{ + Type: string(gatewayv1.ListenerConditionResolvedRefs), + Status: metav1.ConditionTrue, + Reason: string(gatewayv1.ListenerReasonResolvedRefs), + Message: "All references resolved", + ObservedGeneration: gateway.Generation, + }) + } + + switch listener.Protocol { + case gatewayv1.HTTPProtocolType, gatewayv1.HTTPSProtocolType: + // 5. For each accepted HTTPRoute for this listener -> translate to Envoy routes + for _, httpRoute := range routesByListener[listener.Name] { + routes, allValidBackends, resolvedRefsCondition := translateHTTPRouteToEnvoyRoutes(httpRoute, t.serviceLister, t.accessPolicyLister, t.backendLister) + + key := types.NamespacedName{Name: httpRoute.Name, Namespace: httpRoute.Namespace} + currentParentStatuses := httpRouteStatuses[key] + for i := range currentParentStatuses { + // Only add the ResolvedRefs condition if the parent was Accepted. + if meta.IsStatusConditionTrue(currentParentStatuses[i].Conditions, string(gatewayv1.RouteConditionAccepted)) { + meta.SetStatusCondition(¤tParentStatuses[i].Conditions, resolvedRefsCondition) + } + } + httpRouteStatuses[key] = currentParentStatuses + + clusters, err := buildClustersFromBackends(allValidBackends) + if err != nil { + return nil, nil, nil, fmt.Errorf("failed to build clusters from HTTPRoute %s/%s: %w", httpRoute.Namespace, httpRoute.Name, err) + } + for _, cluster := range clusters { + envoyClusters[cluster.Name] = cluster + } + + // Aggregate Envoy routes into VirtualHosts. + if routes != nil { + attachedRoutes++ + // 7. Put routes into virtual hosts for each intersecting hostname + // Get the domain for this listener's VirtualHost. + vhostDomains := getIntersectingHostnames(listener, httpRoute.Spec.Hostnames) + for _, domain := range vhostDomains { + vh, ok := virtualHostsForPort[domain] + if !ok { + vh = &routev3.VirtualHost{ + Name: fmt.Sprintf(vHostNameFormat, gateway.Name, port, domain), + Domains: []string{domain}, + } + virtualHostsForPort[domain] = vh + } + vh.Routes = append(vh.Routes, routes...) + klog.V(4).Infof("created VirtualHost %s for listener %s with domain %s", vh.Name, listener. + Name, domain) + if klog.V(4).Enabled() { + for _, route := range routes { + klog.Infof("adding route %s to VirtualHost %s", route.Name, vh.Name) + } + } + } + } + } + + // TODO: Process GRPCRoutes + + default: + klog.Warningf("Unsupported listener protocol for route processing: %s", listener.Protocol) + } + + // 8. translate listener into a filter chain (HTTP connection manager that references route config 'route-') + vhSlice := make([]*routev3.VirtualHost, 0, len(virtualHostsForPort)) + for _, vh := range virtualHostsForPort { + vhSlice = append(vhSlice, vh) + } + + filterChain, err := t.translateListenerToFilterChain(gateway, listener, vhSlice, routeName) + if err != nil { + meta.SetStatusCondition(&listenerStatus.Conditions, metav1.Condition{ + Type: string(gatewayv1.ListenerConditionProgrammed), + Status: metav1.ConditionFalse, + Reason: string(gatewayv1.ListenerReasonInvalid), + Message: fmt.Sprintf("Failed to program listener: %v", err), + ObservedGeneration: gateway.Generation, + }) + } else { + meta.SetStatusCondition(&listenerStatus.Conditions, metav1.Condition{ + Type: string(gatewayv1.ListenerConditionProgrammed), + Status: metav1.ConditionTrue, + Reason: string(gatewayv1.ListenerReasonProgrammed), + Message: "Listener is programmed", + ObservedGeneration: gateway.Generation, + }) + + filterChains = append(filterChains, filterChain) + } + + listenerStatus.AttachedRoutes = attachedRoutes + meta.SetStatusCondition(&listenerStatus.Conditions, metav1.Condition{ + Type: string(gatewayv1.ListenerConditionAccepted), + Status: metav1.ConditionTrue, + Reason: string(gatewayv1.ListenerReasonAccepted), + Message: "Listener is valid", + ObservedGeneration: gateway.Generation, + }) + allListenerStatuses[listener.Name] = listenerStatus + } + + // 9. Create RouteConfiguration (one per port group) with virtual hosts + allVirtualHosts := make([]*routev3.VirtualHost, 0, len(virtualHostsForPort)) + for _, vh := range virtualHostsForPort { + sortRoutes(vh.Routes) + allVirtualHosts = append(allVirtualHosts, vh) + } + + // now aggregate all the listeners on the same port + routeConfig := &routev3.RouteConfiguration{ + Name: routeName, + VirtualHosts: allVirtualHosts, + IgnorePortInHostMatching: true, // tricky to figure out thanks to howardjohn + } + envoyRoutes = append(envoyRoutes, routeConfig) + + // 10. If there are any filterChains -> create an Envoy Listener for port with those filterChains + if len(filterChains) > 0 { + envoyListener := &listenerv3.Listener{ + Name: fmt.Sprintf(listenerNameFormat, port), + Address: createEnvoyAddress(uint32(port)), + FilterChains: filterChains, + ListenerFilters: createListenerFilters(), + } + // If this is plain HTTP, we must now create exactly ONE default filter chain. + // Use first listener as a template + // For HTTPS, we create one filter chain per listener because they have unique + // SNI matches and TLS settings. + if listeners[0].Protocol == gatewayv1.HTTPProtocolType { + filterChain, _ := t.translateListenerToFilterChain(gateway, listeners[0], allVirtualHosts, routeName) + envoyListener.FilterChains = []*listenerv3.FilterChain{filterChain} + } + finalEnvoyListeners = append(finalEnvoyListeners, envoyListener) + } + } + + // 11. Convert clusters map to slice + clustersSlice := make([]envoyproxytypes.Resource, 0, len(envoyClusters)) + for _, cluster := range envoyClusters { + clustersSlice = append(clustersSlice, cluster) + } + + k8sApiCluster, err := buildK8sApiCluster() + if err != nil { + return nil, nil, nil, fmt.Errorf("failed to build kubernetes_api_cluster: %w", err) + } + clustersSlice = append(clustersSlice, k8sApiCluster) + + orderedStatuses := make([]gatewayv1.ListenerStatus, len(gateway.Spec.Listeners)) + for i, listener := range gateway.Spec.Listeners { + orderedStatuses[i] = allListenerStatuses[listener.Name] + } + + // 12. Return resource map and status objects + return map[resourcev3.Type][]envoyproxytypes.Resource{ + resourcev3.ListenerType: finalEnvoyListeners, + resourcev3.RouteType: envoyRoutes, + resourcev3.ClusterType: clustersSlice, + }, orderedStatuses, + httpRouteStatuses, nil +} + +func getSupportedKinds(listener gatewayv1.Listener) ([]gatewayv1.RouteGroupKind, bool) { + supportedKinds := []gatewayv1.RouteGroupKind{} + allKindsValid := true + groupName := gatewayv1.Group(gatewayv1.GroupName) + + if listener.AllowedRoutes != nil && len(listener.AllowedRoutes.Kinds) > 0 { + for _, kind := range listener.AllowedRoutes.Kinds { + if (kind.Group == nil || *kind.Group == groupName) && SupportedKinds.Has(kind.Kind) { + supportedKinds = append(supportedKinds, gatewayv1.RouteGroupKind{ + Group: &groupName, + Kind: kind.Kind, + }) + } else { + allKindsValid = false + } + } + } else if listener.Protocol == gatewayv1.HTTPProtocolType || listener.Protocol == gatewayv1.HTTPSProtocolType { + for _, kind := range SupportedKinds.UnsortedList() { + supportedKinds = append(supportedKinds, + gatewayv1.RouteGroupKind{ + Group: &groupName, + Kind: kind, + }, + ) + } + } + + return supportedKinds, allKindsValid +} + +// getHTTPRoutesForGateway returns all HTTPRoutes that have a ParentRef pointing to the specified Gateway. +func (t *Translator) getHTTPRoutesForGateway(gw *gatewayv1.Gateway) []*gatewayv1.HTTPRoute { + var matchingRoutes []*gatewayv1.HTTPRoute + allRoutes, err := t.httprouteLister.List(labels.Everything()) + if err != nil { + klog.Errorf("failed to list HTTPRoutes: %v", err) + return matchingRoutes + } + + for _, route := range allRoutes { + for _, parentRef := range route.Spec.ParentRefs { + // Check if the ParentRef targets the Gateway, defaulting to the route's namespace. + refNamespace := route.Namespace + if parentRef.Namespace != nil { + refNamespace = string(*parentRef.Namespace) + } + if parentRef.Name == gatewayv1.ObjectName(gw.Name) && refNamespace == gw.Namespace { + matchingRoutes = append(matchingRoutes, route) + break // Found a matching ref for this gateway, no need to check others. + } + } + } + return matchingRoutes +} + +// validateHTTPRoute is the definitive validation function. It iterates through all +// parentRefs of an HTTPRoute and generates a complete RouteParentStatus for each one +// that targets the specified Gateway. It also returns a slice of all listeners +// that ended up accepting the route. +func (t *Translator) validateHTTPRoute( + gateway *gatewayv1.Gateway, + httpRoute *gatewayv1.HTTPRoute, +) ([]gatewayv1.RouteParentStatus, []gatewayv1.Listener) { + + var parentStatuses []gatewayv1.RouteParentStatus + // Use a map to collect a unique set of listeners that accepted the route. + acceptedListenerSet := make(map[gatewayv1.SectionName]gatewayv1.Listener) + + // --- Determine the ResolvedRefs status for the entire Route first. --- + // This is a property of the route itself, independent of any parent. + resolvedRefsCondition := metav1.Condition{ + Type: string(gatewayv1.RouteConditionResolvedRefs), + ObservedGeneration: httpRoute.Generation, + LastTransitionTime: metav1.Now(), + } + + // --- Iterate over EACH ParentRef in the HTTPRoute --- + for _, parentRef := range httpRoute.Spec.ParentRefs { + // We only care about refs that target our current Gateway. + refNamespace := httpRoute.Namespace + if parentRef.Namespace != nil { + refNamespace = string(*parentRef.Namespace) + } + if parentRef.Name != gatewayv1.ObjectName(gateway.Name) || refNamespace != gateway.Namespace { + continue // This ref is for another Gateway. + } + + // This ref targets our Gateway. We MUST generate a status for it. + var listenersForThisRef []gatewayv1.Listener + rejectionReason := gatewayv1.RouteReasonNoMatchingParent + + // --- Find all listeners on the Gateway that match this specific parentRef --- + for _, listener := range gateway.Spec.Listeners { + sectionNameMatches := (parentRef.SectionName == nil) || (*parentRef.SectionName == listener.Name) + portMatches := (parentRef.Port == nil) || (*parentRef.Port == listener.Port) + + if sectionNameMatches && portMatches { + // The listener matches the ref. Now check if the listener's policy (e.g., hostname) allows it. + if !isAllowedByListener(gateway, listener, httpRoute, t.namespaceLister) { + rejectionReason = gatewayv1.RouteReasonNotAllowedByListeners + continue + } + if !isAllowedByHostname(listener, httpRoute) { + rejectionReason = gatewayv1.RouteReasonNoMatchingListenerHostname + continue + } + listenersForThisRef = append(listenersForThisRef, listener) + } + } + + // --- Build the final status for this ParentRef --- + status := gatewayv1.RouteParentStatus{ + ParentRef: parentRef, + ControllerName: "test", + Conditions: []metav1.Condition{}, + } + + // Create the 'Accepted' condition based on the listener validation. + acceptedCondition := metav1.Condition{ + Type: string(gatewayv1.RouteConditionAccepted), + ObservedGeneration: httpRoute.Generation, + LastTransitionTime: metav1.Now(), + } + + if len(listenersForThisRef) == 0 { + acceptedCondition.Status = metav1.ConditionFalse + acceptedCondition.Reason = string(rejectionReason) + acceptedCondition.Message = "No listener matched the parentRef." + if rejectionReason == gatewayv1.RouteReasonNotAllowedByListeners { + acceptedCondition.Message = "Route is not allowed by a listener's policy." + } else { + acceptedCondition.Message = "The route's hostnames do not match any listener hostnames." + } + } else { + acceptedCondition.Status = metav1.ConditionTrue + acceptedCondition.Reason = string(gatewayv1.RouteReasonAccepted) + acceptedCondition.Message = "Route is accepted." + for _, l := range listenersForThisRef { + acceptedListenerSet[l.Name] = l + } + } + + // --- 4. Combine the two independent conditions into the final status. --- + status.Conditions = append(status.Conditions, acceptedCondition, resolvedRefsCondition) + parentStatuses = append(parentStatuses, status) + } + + var allAcceptingListeners []gatewayv1.Listener + for _, l := range acceptedListenerSet { + allAcceptingListeners = append(allAcceptingListeners, l) + } + + return parentStatuses, allAcceptingListeners +} + +func createClusterLoadAssignment(clusterName, serviceHost string, servicePort uint32) *endpointv3.ClusterLoadAssignment { + return &endpointv3.ClusterLoadAssignment{ + ClusterName: clusterName, + Endpoints: []*endpointv3.LocalityLbEndpoints{ + { + LbEndpoints: []*endpointv3.LbEndpoint{ + { + HostIdentifier: &endpointv3.LbEndpoint_Endpoint{ + Endpoint: &endpointv3.Endpoint{ + Address: &corev3.Address{ + Address: &corev3.Address_SocketAddress{ + SocketAddress: &corev3.SocketAddress{ + Address: serviceHost, + PortSpecifier: &corev3.SocketAddress_PortValue{ + PortValue: servicePort, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } +}