From 938aa22f8ee1dfc0a4b6d352c9ac85ccf806a9f0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 17 Sep 2025 19:56:56 +0000 Subject: [PATCH 1/5] Initial plan From 1c7eba58846a2d187a89432e109bbe132ba970cf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 17 Sep 2025 20:01:10 +0000 Subject: [PATCH 2/5] Add Gateway API dependency for unused gateways feature Co-authored-by: yonahd <47282577+yonahd@users.noreply.github.com> --- go.mod | 3 ++- go.sum | 5 +++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 7f09c9ec..cfefdc4b 100644 --- a/go.mod +++ b/go.mod @@ -25,7 +25,7 @@ require ( github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/go-logr/logr v1.4.2 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect - github.com/go-openapi/jsonreference v0.20.2 // indirect + github.com/go-openapi/jsonreference v0.21.0 // indirect github.com/go-openapi/swag v0.23.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect @@ -68,6 +68,7 @@ require ( gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/klog/v2 v2.130.1 // indirect k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // indirect + sigs.k8s.io/gateway-api v1.3.0 // indirect sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect sigs.k8s.io/randfill v1.0.0 // indirect sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect diff --git a/go.sum b/go.sum index 6d368919..ae4fca29 100644 --- a/go.sum +++ b/go.sum @@ -25,6 +25,8 @@ github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1 github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= +github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= +github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4= github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= @@ -92,6 +94,7 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 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/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= @@ -211,6 +214,8 @@ k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b h1:MloQ9/bdJyIu9lb1PzujOP k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b/go.mod h1:UZ2yyWbFTpuhSbFhv24aGNOdoRdJZgsIObGBUaYVsts= k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 h1:hwvWFiBzdWw1FhfY1FooPn3kzWuJ8tmbZBHi4zVsl1Y= k8s.io/utils v0.0.0-20250604170112-4c0f3b243397/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +sigs.k8s.io/gateway-api v1.3.0 h1:q6okN+/UKDATola4JY7zXzx40WO4VISk7i9DIfOvr9M= +sigs.k8s.io/gateway-api v1.3.0/go.mod h1:d8NV8nJbaRbEKem+5IuxkL8gJGOZ+FJ+NvOIltV8gDk= sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE= sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= From b512e62756d6bd64002152ab74adb8b3ff312432 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 17 Sep 2025 20:09:30 +0000 Subject: [PATCH 3/5] Implement core Gateway discovery functionality with comprehensive tests Co-authored-by: yonahd <47282577+yonahd@users.noreply.github.com> --- cmd/kor/gateways.go | 32 ++ go.mod | 3 +- go.sum | 24 +- pkg/kor/exceptions/gateways/gateways.json | 1 + pkg/kor/gateways.go | 205 +++++++++++ pkg/kor/gateways_test.go | 418 ++++++++++++++++++++++ pkg/kor/kor.go | 17 + 7 files changed, 685 insertions(+), 15 deletions(-) create mode 100644 cmd/kor/gateways.go create mode 100644 pkg/kor/exceptions/gateways/gateways.json create mode 100644 pkg/kor/gateways.go create mode 100644 pkg/kor/gateways_test.go diff --git a/cmd/kor/gateways.go b/cmd/kor/gateways.go new file mode 100644 index 00000000..8ea848bf --- /dev/null +++ b/cmd/kor/gateways.go @@ -0,0 +1,32 @@ +package kor + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/yonahd/kor/pkg/kor" + "github.com/yonahd/kor/pkg/utils" +) + +var gatewayCmd = &cobra.Command{ + Use: "gateway", + Aliases: []string{"gw", "gateways"}, + Short: "Gets unused gateways", + Args: cobra.ExactArgs(0), + Run: func(cmd *cobra.Command, args []string) { + clientset := kor.GetKubeClient(kubeconfig) + gatewayClient := kor.GetGatewayClient(kubeconfig) + + if response, err := kor.GetUnusedGateways(filterOptions, clientset, gatewayClient, outputFormat, opts); err != nil { + fmt.Println(err) + } else { + utils.PrintLogo(outputFormat) + fmt.Println(response) + } + }, +} + +func init() { + rootCmd.AddCommand(gatewayCmd) +} \ No newline at end of file diff --git a/go.mod b/go.mod index cfefdc4b..4bc02a99 100644 --- a/go.mod +++ b/go.mod @@ -13,6 +13,7 @@ require ( k8s.io/apimachinery v0.34.1 k8s.io/client-go v0.34.1 k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 + sigs.k8s.io/gateway-api v1.0.0 sigs.k8s.io/yaml v1.6.0 ) @@ -68,8 +69,8 @@ require ( gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/klog/v2 v2.130.1 // indirect k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // indirect - sigs.k8s.io/gateway-api v1.3.0 // indirect sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect sigs.k8s.io/randfill v1.0.0 // indirect + sigs.k8s.io/structured-merge-diff/v4 v4.7.0 // indirect sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect ) diff --git a/go.sum b/go.sum index ae4fca29..741af8d7 100644 --- a/go.sum +++ b/go.sum @@ -3,7 +3,6 @@ github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6r github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 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= @@ -20,14 +19,10 @@ github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sa github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= -github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= -github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= -github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4= -github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= @@ -38,6 +33,7 @@ github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 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.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= @@ -55,11 +51,8 @@ github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= -github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= @@ -95,6 +88,7 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 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= github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= @@ -125,14 +119,9 @@ github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3A github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 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.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 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/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= @@ -197,7 +186,6 @@ gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSP gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= k8s.io/api v0.34.1 h1:jC+153630BMdlFukegoEL8E/yT7aLyQkIVuwhmwDgJM= @@ -214,13 +202,21 @@ k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b h1:MloQ9/bdJyIu9lb1PzujOP k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b/go.mod h1:UZ2yyWbFTpuhSbFhv24aGNOdoRdJZgsIObGBUaYVsts= k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 h1:hwvWFiBzdWw1FhfY1FooPn3kzWuJ8tmbZBHi4zVsl1Y= k8s.io/utils v0.0.0-20250604170112-4c0f3b243397/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +sigs.k8s.io/gateway-api v1.0.0 h1:iPTStSv41+d9p0xFydll6d7f7MOBGuqXM6p2/zVYMAs= +sigs.k8s.io/gateway-api v1.0.0/go.mod h1:4cUgr0Lnp5FZ0Cdq8FdRwCvpiWws7LVhLHGIudLlf4c= +sigs.k8s.io/gateway-api v1.1.0 h1:DsLDXCi6jR+Xz8/xd0Z1PYl2Pn0TyaFMOPPZIj4inDM= +sigs.k8s.io/gateway-api v1.1.0/go.mod h1:ZH4lHrL2sDi0FHZ9jjneb8kKnGzFWyrTya35sWUTrRs= sigs.k8s.io/gateway-api v1.3.0 h1:q6okN+/UKDATola4JY7zXzx40WO4VISk7i9DIfOvr9M= sigs.k8s.io/gateway-api v1.3.0/go.mod h1:d8NV8nJbaRbEKem+5IuxkL8gJGOZ+FJ+NvOIltV8gDk= sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE= sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= +sigs.k8s.io/randfill v0.0.0-20250304075658-069ef1bbf016/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/structured-merge-diff/v4 v4.7.0 h1:qPeWmscJcXP0snki5IYF79Z8xrl8ETFxgMd7wez1XkI= +sigs.k8s.io/structured-merge-diff/v4 v4.7.0/go.mod h1:dDy58f92j70zLsuZVuUX5Wp9vtxXpaZnkPGWeqDfCps= sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/pkg/kor/exceptions/gateways/gateways.json b/pkg/kor/exceptions/gateways/gateways.json new file mode 100644 index 00000000..11dda03e --- /dev/null +++ b/pkg/kor/exceptions/gateways/gateways.json @@ -0,0 +1 @@ +{"exceptionGateways": []} diff --git a/pkg/kor/gateways.go b/pkg/kor/gateways.go new file mode 100644 index 00000000..0dde599d --- /dev/null +++ b/pkg/kor/gateways.go @@ -0,0 +1,205 @@ +package kor + +import ( + "bytes" + "context" + _ "embed" + "encoding/json" + "fmt" + "os" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + _ "k8s.io/client-go/plugin/pkg/client/auth/oidc" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" + gatewayclientset "sigs.k8s.io/gateway-api/pkg/client/clientset/versioned" + + "github.com/yonahd/kor/pkg/common" + "github.com/yonahd/kor/pkg/filters" +) + +//go:embed exceptions/gateways/gateways.json +var gatewaysConfig []byte + +func processNamespaceGateways(clientset kubernetes.Interface, gatewayClient gatewayclientset.Interface, namespace string, filterOpts *filters.Options, opts common.Opts) ([]ResourceInfo, error) { + gateways, err := gatewayClient.GatewayV1().Gateways(namespace).List(context.TODO(), metav1.ListOptions{LabelSelector: filterOpts.IncludeLabels}) + if err != nil { + return nil, err + } + + config, err := unmarshalConfig(gatewaysConfig) + if err != nil { + return nil, err + } + + var unusedGateways []ResourceInfo + + for _, gateway := range gateways.Items { + // Skip resources with ownerReferences if the general flag is set + if filterOpts.IgnoreOwnerReferences && len(gateway.OwnerReferences) > 0 { + continue + } + + if pass, _ := filter.SetObject(&gateway).Run(filterOpts); pass { + continue + } + + if gateway.Labels["kor/used"] == "false" { + reason := "Marked with unused label" + unusedGateways = append(unusedGateways, ResourceInfo{Name: gateway.Name, Reason: reason}) + continue + } + + exceptionFound, err := isResourceException(gateway.Name, gateway.Namespace, config.ExceptionGateways) + if err != nil { + return nil, err + } + + if exceptionFound { + continue + } + + // Check if the GatewayClass exists + gatewayClassExists, err := checkGatewayClassExists(gatewayClient, gateway.Spec.GatewayClassName) + if err != nil { + return nil, err + } + + if !gatewayClassExists { + reason := "Gateway references a non-existing GatewayClass" + unusedGateways = append(unusedGateways, ResourceInfo{Name: gateway.Name, Reason: reason}) + continue + } + + // Check if the Gateway has at least one attached route + hasRoutes, err := checkGatewayHasRoutes(gatewayClient, &gateway) + if err != nil { + return nil, err + } + + if !hasRoutes { + reason := "Gateway has no attached routes" + unusedGateways = append(unusedGateways, ResourceInfo{Name: gateway.Name, Reason: reason}) + } + } + + if opts.DeleteFlag { + if unusedGateways, err = DeleteResource(unusedGateways, clientset, namespace, "Gateway", opts.NoInteractive); err != nil { + fmt.Fprintf(os.Stderr, "Failed to delete Gateway %s in namespace %s: %v\n", unusedGateways, namespace, err) + } + } + + return unusedGateways, nil +} + +func checkGatewayClassExists(gatewayClient gatewayclientset.Interface, gatewayClassName gatewayv1.ObjectName) (bool, error) { + _, err := gatewayClient.GatewayV1().GatewayClasses().Get(context.TODO(), string(gatewayClassName), metav1.GetOptions{}) + if err != nil { + return false, nil // GatewayClass doesn't exist + } + return true, nil +} + +func checkGatewayHasRoutes(gatewayClient gatewayclientset.Interface, gateway *gatewayv1.Gateway) (bool, error) { + // Check for HTTPRoutes + httpRoutes, err := gatewayClient.GatewayV1().HTTPRoutes(gateway.Namespace).List(context.TODO(), metav1.ListOptions{}) + if err != nil { + return false, err + } + + for _, route := range httpRoutes.Items { + for _, parentRef := range route.Spec.ParentRefs { + if parentRef.Name == gatewayv1.ObjectName(gateway.Name) { + // Check if the namespace matches (default to same namespace if not specified) + routeNamespace := gateway.Namespace + if parentRef.Namespace != nil { + routeNamespace = string(*parentRef.Namespace) + } + if routeNamespace == gateway.Namespace { + return true, nil + } + } + } + } + + // Check for TCPRoutes + tcpRoutes, err := gatewayClient.GatewayV1alpha2().TCPRoutes(gateway.Namespace).List(context.TODO(), metav1.ListOptions{}) + if err != nil { + return false, err + } + + for _, route := range tcpRoutes.Items { + for _, parentRef := range route.Spec.ParentRefs { + if parentRef.Name == gatewayv1.ObjectName(gateway.Name) { + // Check if the namespace matches (default to same namespace if not specified) + routeNamespace := gateway.Namespace + if parentRef.Namespace != nil { + routeNamespace = string(*parentRef.Namespace) + } + if routeNamespace == gateway.Namespace { + return true, nil + } + } + } + } + + // Check for UDPRoutes + udpRoutes, err := gatewayClient.GatewayV1alpha2().UDPRoutes(gateway.Namespace).List(context.TODO(), metav1.ListOptions{}) + if err != nil { + return false, err + } + + for _, route := range udpRoutes.Items { + for _, parentRef := range route.Spec.ParentRefs { + if parentRef.Name == gatewayv1.ObjectName(gateway.Name) { + // Check if the namespace matches (default to same namespace if not specified) + routeNamespace := gateway.Namespace + if parentRef.Namespace != nil { + routeNamespace = string(*parentRef.Namespace) + } + if routeNamespace == gateway.Namespace { + return true, nil + } + } + } + } + + return false, nil +} + +func GetUnusedGateways(filterOpts *filters.Options, clientset kubernetes.Interface, gatewayClient gatewayclientset.Interface, outputFormat string, opts common.Opts) (string, error) { + resources := make(map[string]map[string][]ResourceInfo) + for _, namespace := range filterOpts.Namespaces(clientset) { + diff, err := processNamespaceGateways(clientset, gatewayClient, namespace, filterOpts, opts) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to process namespace %s: %v\n", namespace, err) + continue + } + switch opts.GroupBy { + case "namespace": + resources[namespace] = make(map[string][]ResourceInfo) + resources[namespace]["Gateway"] = diff + case "resource": + appendResources(resources, "Gateway", namespace, diff) + } + } + + var outputBuffer bytes.Buffer + var jsonResponse []byte + switch outputFormat { + case "table": + outputBuffer = FormatOutput(resources, opts) + case "json", "yaml": + var err error + if jsonResponse, err = json.MarshalIndent(resources, "", " "); err != nil { + return "", err + } + } + + unusedGateways, err := unusedResourceFormatter(outputFormat, outputBuffer, opts, jsonResponse) + if err != nil { + fmt.Printf("err: %v\n", err) + } + + return unusedGateways, nil +} \ No newline at end of file diff --git a/pkg/kor/gateways_test.go b/pkg/kor/gateways_test.go new file mode 100644 index 00000000..a2f6002a --- /dev/null +++ b/pkg/kor/gateways_test.go @@ -0,0 +1,418 @@ +package kor + +import ( + "context" + "strings" + "testing" + + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" + gatewayv1alpha2 "sigs.k8s.io/gateway-api/apis/v1alpha2" + gatewayfake "sigs.k8s.io/gateway-api/pkg/client/clientset/versioned/fake" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes/fake" + + "github.com/yonahd/kor/pkg/common" + "github.com/yonahd/kor/pkg/filters" +) + +func createTestGateways(t *testing.T) (*fake.Clientset, *gatewayfake.Clientset) { + clientset := fake.NewSimpleClientset() + gatewayClientset := gatewayfake.NewSimpleClientset() + + // Create a test namespace + _, err := clientset.CoreV1().Namespaces().Create(context.TODO(), &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{Name: "test-namespace"}, + }, metav1.CreateOptions{}) + if err != nil { + t.Fatalf("Error creating test namespace: %v", err) + } + + // Create a GatewayClass + gatewayClass := &gatewayv1.GatewayClass{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-gateway-class", + }, + Spec: gatewayv1.GatewayClassSpec{ + ControllerName: "test-controller", + }, + } + _, err = gatewayClientset.GatewayV1().GatewayClasses().Create(context.TODO(), gatewayClass, metav1.CreateOptions{}) + if err != nil { + t.Fatalf("Error creating test GatewayClass: %v", err) + } + + // Create Gateways for testing + // Gateway 1: References existing GatewayClass but has no routes + gateway1 := &gatewayv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "unused-gateway-no-routes", + Namespace: "test-namespace", + }, + Spec: gatewayv1.GatewaySpec{ + GatewayClassName: "test-gateway-class", + Listeners: []gatewayv1.Listener{ + { + Name: "http", + Protocol: gatewayv1.HTTPProtocolType, + Port: 80, + }, + }, + }, + } + + // Gateway 2: References non-existing GatewayClass + gateway2 := &gatewayv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "unused-gateway-missing-class", + Namespace: "test-namespace", + }, + Spec: gatewayv1.GatewaySpec{ + GatewayClassName: "non-existent-gateway-class", + Listeners: []gatewayv1.Listener{ + { + Name: "http", + Protocol: gatewayv1.HTTPProtocolType, + Port: 80, + }, + }, + }, + } + + // Gateway 3: References existing GatewayClass and has routes attached + gateway3 := &gatewayv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "used-gateway-with-routes", + Namespace: "test-namespace", + }, + Spec: gatewayv1.GatewaySpec{ + GatewayClassName: "test-gateway-class", + Listeners: []gatewayv1.Listener{ + { + Name: "http", + Protocol: gatewayv1.HTTPProtocolType, + Port: 80, + }, + }, + }, + } + + // Gateway 4: Marked as unused with label + gateway4 := &gatewayv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "labeled-unused-gateway", + Namespace: "test-namespace", + Labels: map[string]string{ + "kor/used": "false", + }, + }, + Spec: gatewayv1.GatewaySpec{ + GatewayClassName: "test-gateway-class", + Listeners: []gatewayv1.Listener{ + { + Name: "http", + Protocol: gatewayv1.HTTPProtocolType, + Port: 80, + }, + }, + }, + } + + gateways := []*gatewayv1.Gateway{gateway1, gateway2, gateway3, gateway4} + for _, gw := range gateways { + _, err := gatewayClientset.GatewayV1().Gateways(gw.Namespace).Create(context.TODO(), gw, metav1.CreateOptions{}) + if err != nil { + t.Fatalf("Error creating test Gateway %s: %v", gw.Name, err) + } + } + + // Create an HTTPRoute that references gateway3 + httpRoute := &gatewayv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-http-route", + Namespace: "test-namespace", + }, + Spec: gatewayv1.HTTPRouteSpec{ + CommonRouteSpec: gatewayv1.CommonRouteSpec{ + ParentRefs: []gatewayv1.ParentReference{ + { + Name: "used-gateway-with-routes", + }, + }, + }, + Rules: []gatewayv1.HTTPRouteRule{ + { + BackendRefs: []gatewayv1.HTTPBackendRef{ + { + BackendRef: gatewayv1.BackendRef{ + BackendObjectReference: gatewayv1.BackendObjectReference{ + Name: "test-service", + Port: &[]gatewayv1.PortNumber{8080}[0], + }, + }, + }, + }, + }, + }, + }, + } + + _, err = gatewayClientset.GatewayV1().HTTPRoutes("test-namespace").Create(context.TODO(), httpRoute, metav1.CreateOptions{}) + if err != nil { + t.Fatalf("Error creating test HTTPRoute: %v", err) + } + + return clientset, gatewayClientset +} + +func TestProcessNamespaceGateways(t *testing.T) { + clientset, gatewayClientset := createTestGateways(t) + + unusedGateways, err := processNamespaceGateways(clientset, gatewayClientset, "test-namespace", &filters.Options{}, common.Opts{}) + if err != nil { + t.Fatalf("Error retrieving unused Gateways: %v", err) + } + + if len(unusedGateways) != 3 { // gateway1, gateway2, gateway4 + t.Errorf("Expected 3 unused Gateway objects, got %d", len(unusedGateways)) + } + + expectedUnused := map[string]string{ + "unused-gateway-no-routes": "Gateway has no attached routes", + "unused-gateway-missing-class": "Gateway references a non-existing GatewayClass", + "labeled-unused-gateway": "Marked with unused label", + } + + for _, gw := range unusedGateways { + expectedReason, exists := expectedUnused[gw.Name] + if !exists { + t.Errorf("Unexpected unused gateway: %s", gw.Name) + } else if gw.Reason != expectedReason { + t.Errorf("Gateway %s: expected reason '%s', got '%s'", gw.Name, expectedReason, gw.Reason) + } + } +} + +func TestCheckGatewayClassExists(t *testing.T) { + _, gatewayClientset := createTestGateways(t) + + // Test existing GatewayClass + exists, err := checkGatewayClassExists(gatewayClientset, "test-gateway-class") + if err != nil { + t.Fatalf("Error checking existing GatewayClass: %v", err) + } + if !exists { + t.Error("Expected GatewayClass to exist") + } + + // Test non-existing GatewayClass + exists, err = checkGatewayClassExists(gatewayClientset, "non-existent-gateway-class") + if err != nil { + t.Fatalf("Error checking non-existing GatewayClass: %v", err) + } + if exists { + t.Error("Expected GatewayClass not to exist") + } +} + +func TestCheckGatewayHasRoutes(t *testing.T) { + _, gatewayClientset := createTestGateways(t) + + // Get gateways for testing + gateways, err := gatewayClientset.GatewayV1().Gateways("test-namespace").List(context.TODO(), metav1.ListOptions{}) + if err != nil { + t.Fatalf("Error listing gateways: %v", err) + } + + gatewayMap := make(map[string]*gatewayv1.Gateway) + for _, gw := range gateways.Items { + gatewayMap[gw.Name] = &gw + } + + // Test gateway with routes + hasRoutes, err := checkGatewayHasRoutes(gatewayClientset, gatewayMap["used-gateway-with-routes"]) + if err != nil { + t.Fatalf("Error checking gateway with routes: %v", err) + } + if !hasRoutes { + t.Error("Expected gateway to have routes") + } + + // Test gateway without routes + hasRoutes, err = checkGatewayHasRoutes(gatewayClientset, gatewayMap["unused-gateway-no-routes"]) + if err != nil { + t.Fatalf("Error checking gateway without routes: %v", err) + } + if hasRoutes { + t.Error("Expected gateway not to have routes") + } +} + +func TestGetUnusedGatewaysStructured(t *testing.T) { + clientset, gatewayClientset := createTestGateways(t) + + opts := common.Opts{ + WebhookURL: "", + Channel: "", + Token: "", + DeleteFlag: false, + NoInteractive: true, + GroupBy: "namespace", + } + + output, err := GetUnusedGateways(&filters.Options{}, clientset, gatewayClientset, "json", opts) + if err != nil { + t.Fatalf("Error calling GetUnusedGateways: %v", err) + } + + expectedOutputKeys := []string{"unused-gateway-no-routes", "unused-gateway-missing-class", "labeled-unused-gateway"} + + // Check if all expected gateways are in the output + for _, expectedKey := range expectedOutputKeys { + if !strings.Contains(output, expectedKey) { + t.Errorf("Expected output to contain gateway: %s", expectedKey) + } + } +} + +func TestProcessNamespaceGatewaysTCPRoute(t *testing.T) { + _, gatewayClientset := createTestGateways(t) + + // Create a gateway with TCPRoute + tcpGateway := &gatewayv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "tcp-gateway", + Namespace: "test-namespace", + }, + Spec: gatewayv1.GatewaySpec{ + GatewayClassName: "test-gateway-class", + Listeners: []gatewayv1.Listener{ + { + Name: "tcp", + Protocol: gatewayv1.TCPProtocolType, + Port: 3306, + }, + }, + }, + } + + _, err := gatewayClientset.GatewayV1().Gateways("test-namespace").Create(context.TODO(), tcpGateway, metav1.CreateOptions{}) + if err != nil { + t.Fatalf("Error creating TCP Gateway: %v", err) + } + + // Create a TCPRoute that references the TCP gateway + tcpRoute := &gatewayv1alpha2.TCPRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-tcp-route", + Namespace: "test-namespace", + }, + Spec: gatewayv1alpha2.TCPRouteSpec{ + CommonRouteSpec: gatewayv1.CommonRouteSpec{ + ParentRefs: []gatewayv1.ParentReference{ + { + Name: "tcp-gateway", + }, + }, + }, + Rules: []gatewayv1alpha2.TCPRouteRule{ + { + BackendRefs: []gatewayv1alpha2.BackendRef{ + { + BackendObjectReference: gatewayv1.BackendObjectReference{ + Name: "tcp-service", + Port: &[]gatewayv1.PortNumber{3306}[0], + }, + }, + }, + }, + }, + }, + } + + _, err = gatewayClientset.GatewayV1alpha2().TCPRoutes("test-namespace").Create(context.TODO(), tcpRoute, metav1.CreateOptions{}) + if err != nil { + t.Fatalf("Error creating TCPRoute: %v", err) + } + + // Test that the TCP gateway is not marked as unused + hasRoutes, err := checkGatewayHasRoutes(gatewayClientset, tcpGateway) + if err != nil { + t.Fatalf("Error checking TCP gateway routes: %v", err) + } + if !hasRoutes { + t.Error("Expected TCP gateway to have routes") + } +} + +func TestFilterOwnerReferencedGateways(t *testing.T) { + clientset, gatewayClientset := createTestGateways(t) + + // Create a gateway with owner references + ownedGateway := &gatewayv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "owned-gateway", + Namespace: "test-namespace", + OwnerReferences: []metav1.OwnerReference{ + { + APIVersion: "apps/v1", + Kind: "Deployment", + Name: "some-controller", + UID: "some-uid", + }, + }, + }, + Spec: gatewayv1.GatewaySpec{ + GatewayClassName: "non-existent-gateway-class", // This would normally make it unused + Listeners: []gatewayv1.Listener{ + { + Name: "http", + Protocol: gatewayv1.HTTPProtocolType, + Port: 80, + }, + }, + }, + } + + _, err := gatewayClientset.GatewayV1().Gateways("test-namespace").Create(context.TODO(), ownedGateway, metav1.CreateOptions{}) + if err != nil { + t.Fatalf("Error creating owned Gateway: %v", err) + } + + // Test without owner reference filtering - should include the owned gateway + filterOptsNoSkip := &filters.Options{IgnoreOwnerReferences: false} + unusedWithoutFilter, err := processNamespaceGateways(clientset, gatewayClientset, "test-namespace", filterOptsNoSkip, common.Opts{}) + if err != nil { + t.Fatalf("Error retrieving unused Gateways without filter: %v", err) + } + + found := false + for _, gw := range unusedWithoutFilter { + if gw.Name == "owned-gateway" { + found = true + break + } + } + if !found { + t.Error("Expected owned gateway to be included without owner reference filter") + } + + // Test with owner reference filtering - should exclude the owned gateway + filterOptsWithSkip := &filters.Options{IgnoreOwnerReferences: true} + unusedWithFilter, err := processNamespaceGateways(clientset, gatewayClientset, "test-namespace", filterOptsWithSkip, common.Opts{}) + if err != nil { + t.Fatalf("Error retrieving unused Gateways with filter: %v", err) + } + + found = false + for _, gw := range unusedWithFilter { + if gw.Name == "owned-gateway" { + found = true + break + } + } + if found { + t.Error("Expected owned gateway to be excluded with owner reference filter") + } +} \ No newline at end of file diff --git a/pkg/kor/kor.go b/pkg/kor/kor.go index fc8ad38e..3b758b60 100644 --- a/pkg/kor/kor.go +++ b/pkg/kor/kor.go @@ -14,6 +14,7 @@ import ( "k8s.io/client-go/rest" "k8s.io/client-go/tools/clientcmd" "k8s.io/client-go/util/homedir" + gatewayclientset "sigs.k8s.io/gateway-api/pkg/client/clientset/versioned" ) var ResourceKindList map[string]ResourceKind @@ -38,6 +39,7 @@ type Config struct { ExceptionConfigMaps []ExceptionResource `json:"exceptionConfigMaps"` ExceptionCrds []ExceptionResource `json:"exceptionCrds"` ExceptionDaemonSets []ExceptionResource `json:"exceptionDaemonSets"` + ExceptionGateways []ExceptionResource `json:"exceptionGateways"` ExceptionRoles []ExceptionResource `json:"exceptionRoles"` ExceptionSecrets []ExceptionResource `json:"exceptionSecrets"` ExceptionServiceAccounts []ExceptionResource `json:"exceptionServiceAccounts"` @@ -129,6 +131,21 @@ func GetDynamicClient(kubeconfig string) *dynamic.DynamicClient { return clientset } +func GetGatewayClient(kubeconfig string) *gatewayclientset.Clientset { + config, err := GetConfig(kubeconfig) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to load kubeconfig: %v\n", err) + os.Exit(1) + } + + clientset, err := gatewayclientset.NewForConfig(config) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to create Gateway API client: %v\n", err) + os.Exit(1) + } + return clientset +} + // TODO create formatter by resource "#", "Resource Name", "Namespace" // TODO Functions that use this object are accompanied by repeated data acquisition operations and can be optimized. func CalculateResourceDifference(usedResourceNames []string, allResourceNames []string) []string { From 8ff8fcdfef8f0f61fd4d11700a1476fa6047db32 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 17 Sep 2025 20:12:28 +0000 Subject: [PATCH 4/5] Add RBAC permissions and documentation for Gateway discovery Co-authored-by: yonahd <47282577+yonahd@users.noreply.github.com> --- README.md | 20 ++++++++++++++++++++ charts/kor/templates/role.yaml | 25 +++++++++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/README.md b/README.md index 3f117ef4..29c41448 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ Kor is a tool to discover unused Kubernetes resources. Currently, Kor can identi - NetworkPolicies - RoleBindings - VolumeAttachments +- Gateways ![Kor Screenshot](/images/show_reason_screenshot.png) @@ -135,6 +136,7 @@ Kor provides various subcommands to identify and list unused resources. The avai - `replicaset` - Gets unused replicaSets for the specified namespace or all namespaces. - `daemonset`- Gets unused DaemonSets for the specified namespace or all namespaces. - `volumeattachment` - Gets unused VolumeAttachments in the cluster (non-namespaced resource). +- `gateway` - Gets unused Gateways for the specified namespace or all namespaces. - `finalizer` - Gets unused pending deletion resources for the specified namespace or all namespaces. - `networkpolicy` - Gets unused NetworkPolicies for the specified namespace or all namespaces. - `exporter` - Export Prometheus metrics. @@ -269,6 +271,24 @@ Unused resources in namespace: "test" +---+----------------+----------------------------------------------+--------------------------------------------------------+ ``` +#### Gateway Discovery + +Kor can discover unused Gateways by checking if they reference non-existing GatewayClasses or have no attached Routes: + +```sh +kor gateway --show-reason +``` + +``` +Unused Gateways: ++---+------------+-----------------+--------------------------------------------+ +| # | NAMESPACE | RESOURCE NAME | REASON | ++---+------------+-----------------+--------------------------------------------+ +| 1 | default | web-gateway | Gateway references a non-existing class | +| 2 | prod | api-gateway | Gateway has no attached routes | ++---+------------+-----------------+--------------------------------------------+ +``` + #### Group by resource ```sh diff --git a/charts/kor/templates/role.yaml b/charts/kor/templates/role.yaml index 9035cc2e..0d43dd87 100644 --- a/charts/kor/templates/role.yaml +++ b/charts/kor/templates/role.yaml @@ -30,6 +30,18 @@ rules: - get - list - watch + - apiGroups: ["gateway.networking.k8s.io"] + resources: + - gateways + - httproutes + - grpcroutes + - tcproutes + - udproutes + - tlsroutes + verbs: + - get + - list + - watch --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole @@ -70,3 +82,16 @@ rules: - get - list - watch + - apiGroups: ["gateway.networking.k8s.io"] + resources: + - gateways + - gatewayClasses + - httproutes + - grpcroutes + - tcproutes + - udproutes + - tlsroutes + verbs: + - get + - list + - watch From 7e0262ae201f7fb2c30aadc8528a4ef80e7927c4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 12 Nov 2025 21:01:14 +0000 Subject: [PATCH 5/5] Follow CONTRIBUTING.md: add Gateway support to all.go, multi.go, delete.go and create_test_resources.go Co-authored-by: yonahd <47282577+yonahd@users.noreply.github.com> --- cmd/kor/all.go | 3 +- cmd/kor/exporter.go | 3 +- cmd/kor/root.go | 3 +- pkg/kor/all.go | 23 ++++++++++++--- pkg/kor/create_test_resources.go | 50 ++++++++++++++++++++++++++++++++ pkg/kor/exporter.go | 15 +++++----- pkg/kor/multi.go | 9 ++++-- pkg/kor/multi_test.go | 9 ++++-- 8 files changed, 95 insertions(+), 20 deletions(-) diff --git a/cmd/kor/all.go b/cmd/kor/all.go index d8bfd4a7..78701017 100644 --- a/cmd/kor/all.go +++ b/cmd/kor/all.go @@ -17,9 +17,10 @@ var allCmd = &cobra.Command{ clientset := kor.GetKubeClient(kubeconfig) apiExtClient := kor.GetAPIExtensionsClient(kubeconfig) dynamicClient := kor.GetDynamicClient(kubeconfig) + gatewayClient := kor.GetGatewayClient(kubeconfig) kor.SetNamespacedFlagState(cmd.Flags().Changed("namespaced")) - if response, err := kor.GetUnusedAll(filterOptions, clientset, apiExtClient, dynamicClient, outputFormat, opts); err != nil { + if response, err := kor.GetUnusedAll(filterOptions, clientset, apiExtClient, dynamicClient, gatewayClient, outputFormat, opts); err != nil { fmt.Println(err) } else { utils.PrintLogo(outputFormat) diff --git a/cmd/kor/exporter.go b/cmd/kor/exporter.go index 50abe390..51d4e28b 100644 --- a/cmd/kor/exporter.go +++ b/cmd/kor/exporter.go @@ -16,8 +16,9 @@ var exporterCmd = &cobra.Command{ clientset := kor.GetKubeClient(kubeconfig) apiExtClient := kor.GetAPIExtensionsClient(kubeconfig) dynamicClient := kor.GetDynamicClient(kubeconfig) + gatewayClient := kor.GetGatewayClient(kubeconfig) kor.SetNamespacedFlagState(cmd.Flags().Changed("namespaced")) - kor.Exporter(filterOptions, clientset, apiExtClient, dynamicClient, "json", opts, resourceList) + kor.Exporter(filterOptions, clientset, apiExtClient, dynamicClient, gatewayClient, "json", opts, resourceList) }, } diff --git a/cmd/kor/root.go b/cmd/kor/root.go index 969a037e..c79f76a5 100644 --- a/cmd/kor/root.go +++ b/cmd/kor/root.go @@ -43,8 +43,9 @@ var rootCmd = &cobra.Command{ clientset := kor.GetKubeClient(kubeconfig) apiExtClient := kor.GetAPIExtensionsClient(kubeconfig) dynamicClient := kor.GetDynamicClient(kubeconfig) + gatewayClient := kor.GetGatewayClient(kubeconfig) - if response, err := kor.GetUnusedMulti(resourceNames, filterOptions, clientset, apiExtClient, dynamicClient, outputFormat, opts); err != nil { + if response, err := kor.GetUnusedMulti(resourceNames, filterOptions, clientset, apiExtClient, dynamicClient, gatewayClient, outputFormat, opts); err != nil { fmt.Println(err) } else { utils.PrintLogo(outputFormat) diff --git a/pkg/kor/all.go b/pkg/kor/all.go index ab1ddbae..80428c35 100644 --- a/pkg/kor/all.go +++ b/pkg/kor/all.go @@ -9,6 +9,7 @@ import ( apiextensionsclientset "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset" "k8s.io/client-go/dynamic" "k8s.io/client-go/kubernetes" + gatewayclientset "sigs.k8s.io/gateway-api/pkg/client/clientset/versioned" "github.com/yonahd/kor/pkg/common" "github.com/yonahd/kor/pkg/filters" @@ -302,7 +303,19 @@ func getUnusedRoleBindings(clientset kubernetes.Interface, namespace string, fil return namespaceRoleBindingDiff } -func GetUnusedAllNamespaced(filterOpts *filters.Options, clientset kubernetes.Interface, outputFormat string, opts common.Opts) (string, error) { +func getUnusedGateways(clientset kubernetes.Interface, gatewayClient gatewayclientset.Interface, namespace string, filterOpts *filters.Options, opts common.Opts) ResourceDiff { + gatewayDiff, err := processNamespaceGateways(clientset, gatewayClient, namespace, filterOpts, opts) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to get %s namespace %s: %v\n", "gateways", namespace, err) + } + namespaceGatewayDiff := ResourceDiff{ + "Gateway", + gatewayDiff, + } + return namespaceGatewayDiff +} + +func GetUnusedAllNamespaced(filterOpts *filters.Options, clientset kubernetes.Interface, gatewayClient gatewayclientset.Interface, outputFormat string, opts common.Opts) (string, error) { resources := make(map[string]map[string][]ResourceInfo) for _, namespace := range filterOpts.Namespaces(clientset) { switch opts.GroupBy { @@ -325,6 +338,7 @@ func GetUnusedAllNamespaced(filterOpts *filters.Options, clientset kubernetes.In resources[namespace]["DaemonSet"] = getUnusedDaemonSets(clientset, namespace, filterOpts, opts).diff resources[namespace]["NetworkPolicy"] = getUnusedNetworkPolicies(clientset, namespace, filterOpts, opts).diff resources[namespace]["RoleBinding"] = getUnusedRoleBindings(clientset, namespace, filterOpts, opts).diff + resources[namespace]["Gateway"] = getUnusedGateways(clientset, gatewayClient, namespace, filterOpts, opts).diff case "resource": appendResources(resources, "ConfigMap", namespace, getUnusedCMs(clientset, namespace, filterOpts, opts).diff) appendResources(resources, "Service", namespace, getUnusedSVCs(clientset, namespace, filterOpts, opts).diff) @@ -343,6 +357,7 @@ func GetUnusedAllNamespaced(filterOpts *filters.Options, clientset kubernetes.In appendResources(resources, "DaemonSet", namespace, getUnusedDaemonSets(clientset, namespace, filterOpts, opts).diff) appendResources(resources, "NetworkPolicy", namespace, getUnusedNetworkPolicies(clientset, namespace, filterOpts, opts).diff) appendResources(resources, "RoleBinding", namespace, getUnusedRoleBindings(clientset, namespace, filterOpts, opts).diff) + appendResources(resources, "Gateway", namespace, getUnusedGateways(clientset, gatewayClient, namespace, filterOpts, opts).diff) } } @@ -407,15 +422,15 @@ func GetUnusedAllNonNamespaced(filterOpts *filters.Options, clientset kubernetes return unusedAllNonNamespaced, nil } -func GetUnusedAll(filterOpts *filters.Options, clientset kubernetes.Interface, apiExtClient apiextensionsclientset.Interface, dynamicClient dynamic.Interface, outputFormat string, opts common.Opts) (string, error) { +func GetUnusedAll(filterOpts *filters.Options, clientset kubernetes.Interface, apiExtClient apiextensionsclientset.Interface, dynamicClient dynamic.Interface, gatewayClient gatewayclientset.Interface, outputFormat string, opts common.Opts) (string, error) { if NamespacedFlagUsed { if opts.Namespaced { - return GetUnusedAllNamespaced(filterOpts, clientset, outputFormat, opts) + return GetUnusedAllNamespaced(filterOpts, clientset, gatewayClient, outputFormat, opts) } return GetUnusedAllNonNamespaced(filterOpts, clientset, apiExtClient, dynamicClient, outputFormat, opts) } - unusedAllNamespaced, err := GetUnusedAllNamespaced(filterOpts, clientset, outputFormat, opts) + unusedAllNamespaced, err := GetUnusedAllNamespaced(filterOpts, clientset, gatewayClient, outputFormat, opts) if err != nil { fmt.Printf("err: %v\n", err) } diff --git a/pkg/kor/create_test_resources.go b/pkg/kor/create_test_resources.go index a9a5ae90..0662b00b 100644 --- a/pkg/kor/create_test_resources.go +++ b/pkg/kor/create_test_resources.go @@ -15,6 +15,7 @@ import ( "k8s.io/apimachinery/pkg/api/resource" v1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" ) var testNamespace = "test-namespace" @@ -505,3 +506,52 @@ func CreateTestCSIDriver(name string) *storagev1.CSIDriver { ObjectMeta: v1.ObjectMeta{Name: name}, } } + +func CreateTestGatewayClass(name, controllerName string) *gatewayv1.GatewayClass { + return &gatewayv1.GatewayClass{ + ObjectMeta: v1.ObjectMeta{ + Name: name, + }, + Spec: gatewayv1.GatewayClassSpec{ + ControllerName: gatewayv1.GatewayController(controllerName), + }, + } +} + +func CreateTestGateway(namespace, name string, gatewayClassName string, labels map[string]string) *gatewayv1.Gateway { + return &gatewayv1.Gateway{ + ObjectMeta: v1.ObjectMeta{ + Namespace: namespace, + Name: name, + Labels: labels, + }, + Spec: gatewayv1.GatewaySpec{ + GatewayClassName: gatewayv1.ObjectName(gatewayClassName), + Listeners: []gatewayv1.Listener{ + { + Name: "http", + Protocol: gatewayv1.HTTPProtocolType, + Port: 80, + }, + }, + }, + } +} + +func CreateTestHTTPRoute(namespace, name, gatewayName string) *gatewayv1.HTTPRoute { + return &gatewayv1.HTTPRoute{ + ObjectMeta: v1.ObjectMeta{ + Namespace: namespace, + Name: name, + }, + Spec: gatewayv1.HTTPRouteSpec{ + CommonRouteSpec: gatewayv1.CommonRouteSpec{ + ParentRefs: []gatewayv1.ParentReference{ + { + Name: gatewayv1.ObjectName(gatewayName), + }, + }, + }, + }, + } +} diff --git a/pkg/kor/exporter.go b/pkg/kor/exporter.go index 39b66c3b..3b093c86 100644 --- a/pkg/kor/exporter.go +++ b/pkg/kor/exporter.go @@ -14,6 +14,7 @@ import ( apiextensionsclientset "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset" "k8s.io/client-go/dynamic" "k8s.io/client-go/kubernetes" + gatewayclientset "sigs.k8s.io/gateway-api/pkg/client/clientset/versioned" "github.com/yonahd/kor/pkg/common" "github.com/yonahd/kor/pkg/filters" @@ -34,16 +35,16 @@ func init() { } // TODO: add option to change port / url !? -func Exporter(filterOptions *filters.Options, clientset kubernetes.Interface, apiExtClient apiextensionsclientset.Interface, dynamicClient dynamic.Interface, outputFormat string, opts common.Opts, resourceList []string) { +func Exporter(filterOptions *filters.Options, clientset kubernetes.Interface, apiExtClient apiextensionsclientset.Interface, dynamicClient dynamic.Interface, gatewayClient gatewayclientset.Interface, outputFormat string, opts common.Opts, resourceList []string) { http.Handle("/metrics", promhttp.Handler()) fmt.Println("Server listening on :8080") - go exportMetrics(filterOptions, clientset, apiExtClient, dynamicClient, outputFormat, opts, resourceList) // Start exporting metrics in the background + go exportMetrics(filterOptions, clientset, apiExtClient, dynamicClient, gatewayClient, outputFormat, opts, resourceList) // Start exporting metrics in the background if err := http.ListenAndServe(":8080", nil); err != nil { fmt.Println(err) } } -func exportMetrics(filterOptions *filters.Options, clientset kubernetes.Interface, apiExtClient apiextensionsclientset.Interface, dynamicClient dynamic.Interface, outputFormat string, opts common.Opts, resourceList []string) { +func exportMetrics(filterOptions *filters.Options, clientset kubernetes.Interface, apiExtClient apiextensionsclientset.Interface, dynamicClient dynamic.Interface, gatewayClient gatewayclientset.Interface, outputFormat string, opts common.Opts, resourceList []string) { exporterInterval := os.Getenv("EXPORTER_INTERVAL") if exporterInterval == "" { exporterInterval = "10" @@ -56,7 +57,7 @@ func exportMetrics(filterOptions *filters.Options, clientset kubernetes.Interfac for { fmt.Println("collecting unused resources") - if korOutput, err := getUnusedResources(filterOptions, clientset, apiExtClient, dynamicClient, outputFormat, opts, resourceList); err != nil { + if korOutput, err := getUnusedResources(filterOptions, clientset, apiExtClient, dynamicClient, gatewayClient, outputFormat, opts, resourceList); err != nil { fmt.Println(err) os.Exit(1) } else { @@ -80,10 +81,10 @@ func exportMetrics(filterOptions *filters.Options, clientset kubernetes.Interfac } } -func getUnusedResources(filterOptions *filters.Options, clientset kubernetes.Interface, apiExtClient apiextensionsclientset.Interface, dynamicClient dynamic.Interface, outputFormat string, opts common.Opts, resourceList []string) (string, error) { +func getUnusedResources(filterOptions *filters.Options, clientset kubernetes.Interface, apiExtClient apiextensionsclientset.Interface, dynamicClient dynamic.Interface, gatewayClient gatewayclientset.Interface, outputFormat string, opts common.Opts, resourceList []string) (string, error) { if len(resourceList) == 0 || (len(resourceList) == 1 && resourceList[0] == "all") { - return GetUnusedAll(filterOptions, clientset, apiExtClient, dynamicClient, outputFormat, opts) + return GetUnusedAll(filterOptions, clientset, apiExtClient, dynamicClient, gatewayClient, outputFormat, opts) } - return GetUnusedMulti(strings.Join(resourceList, ","), filterOptions, clientset, apiExtClient, dynamicClient, outputFormat, opts) + return GetUnusedMulti(strings.Join(resourceList, ","), filterOptions, clientset, apiExtClient, dynamicClient, gatewayClient, outputFormat, opts) } diff --git a/pkg/kor/multi.go b/pkg/kor/multi.go index f3dbfbad..e47b83f3 100644 --- a/pkg/kor/multi.go +++ b/pkg/kor/multi.go @@ -10,6 +10,7 @@ import ( apiextensionsclientset "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset" "k8s.io/client-go/dynamic" "k8s.io/client-go/kubernetes" + gatewayclientset "sigs.k8s.io/gateway-api/pkg/client/clientset/versioned" "github.com/yonahd/kor/pkg/common" "github.com/yonahd/kor/pkg/filters" @@ -82,7 +83,7 @@ func retrieveNoNamespaceDiff(clientset kubernetes.Interface, apiExtClient apiext return noNamespaceDiff, clearedResourceList } -func retrieveNamespaceDiffs(clientset kubernetes.Interface, namespace string, resourceList []string, filterOpts *filters.Options, opts common.Opts) []ResourceDiff { +func retrieveNamespaceDiffs(clientset kubernetes.Interface, gatewayClient gatewayclientset.Interface, namespace string, resourceList []string, filterOpts *filters.Options, opts common.Opts) []ResourceDiff { var allDiffs []ResourceDiff for _, resource := range resourceList { var diffResult ResourceDiff @@ -122,6 +123,8 @@ func retrieveNamespaceDiffs(clientset kubernetes.Interface, namespace string, re diffResult = getUnusedNetworkPolicies(clientset, namespace, filterOpts, opts) case "rolebinding": diffResult = getUnusedRoleBindings(clientset, namespace, filterOpts, opts) + case "gateway": + diffResult = getUnusedGateways(clientset, gatewayClient, namespace, filterOpts, opts) default: fmt.Printf("resource type %q is not supported\n", resource) } @@ -130,7 +133,7 @@ func retrieveNamespaceDiffs(clientset kubernetes.Interface, namespace string, re return allDiffs } -func GetUnusedMulti(resourceNames string, filterOpts *filters.Options, clientset kubernetes.Interface, apiExtClient apiextensionsclientset.Interface, dynamicClient dynamic.Interface, outputFormat string, opts common.Opts) (string, error) { +func GetUnusedMulti(resourceNames string, filterOpts *filters.Options, clientset kubernetes.Interface, apiExtClient apiextensionsclientset.Interface, dynamicClient dynamic.Interface, gatewayClient gatewayclientset.Interface, outputFormat string, opts common.Opts) (string, error) { resourceList := strings.Split(resourceNames, ",") namespaces := filterOpts.Namespaces(clientset) resources := make(map[string]map[string][]ResourceInfo) @@ -160,7 +163,7 @@ func GetUnusedMulti(resourceNames string, filterOpts *filters.Options, clientset } for _, namespace := range namespaces { - allDiffs := retrieveNamespaceDiffs(clientset, namespace, resourceList, filterOpts, opts) + allDiffs := retrieveNamespaceDiffs(clientset, gatewayClient, namespace, resourceList, filterOpts, opts) if opts.GroupBy == "namespace" { resources[namespace] = make(map[string][]ResourceInfo) } diff --git a/pkg/kor/multi_test.go b/pkg/kor/multi_test.go index de297a09..f5def84c 100644 --- a/pkg/kor/multi_test.go +++ b/pkg/kor/multi_test.go @@ -9,6 +9,7 @@ import ( corev1 "k8s.io/api/core/v1" v1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/kubernetes/fake" + gatewayfake "sigs.k8s.io/gateway-api/pkg/client/clientset/versioned/fake" "github.com/yonahd/kor/pkg/common" "github.com/yonahd/kor/pkg/filters" @@ -58,10 +59,11 @@ func createTestMultiResources(t *testing.T) *fake.Clientset { func TestRetrieveNamespaceDiff(t *testing.T) { clientset := createTestMultiResources(t) + gatewayClientset := gatewayfake.NewSimpleClientset() resourceList := []string{"cm", "pdb", "deployment"} filterOpts := &filters.Options{} - namespaceDiff := retrieveNamespaceDiffs(clientset, testNamespace, resourceList, filterOpts, common.Opts{}) + namespaceDiff := retrieveNamespaceDiffs(clientset, gatewayClientset, testNamespace, resourceList, filterOpts, common.Opts{}) if len(namespaceDiff) != 3 { t.Fatalf("Expected 3 diffs, got %d", len(namespaceDiff)) @@ -83,6 +85,7 @@ func TestRetrieveNamespaceDiff(t *testing.T) { func TestGetUnusedMulti(t *testing.T) { clientset := createTestMultiResources(t) + gatewayClientset := gatewayfake.NewSimpleClientset() resourceList := "cm,pdb,deployment" opts := common.Opts{ @@ -94,7 +97,7 @@ func TestGetUnusedMulti(t *testing.T) { GroupBy: "namespace", } - output, err := GetUnusedMulti(resourceList, &filters.Options{}, clientset, nil, nil, "json", opts) + output, err := GetUnusedMulti(resourceList, &filters.Options{}, clientset, nil, nil, gatewayClientset, "json", opts) if err != nil { t.Fatalf("Error calling GetUnusedMulti: %v", err) @@ -178,7 +181,7 @@ func TestGetUnusedMultiWithMultipleResources(t *testing.T) { GroupBy: "namespace", } - output, err := GetUnusedMulti(resourceList, &filters.Options{}, clientset, nil, nil, "json", opts) + output, err := GetUnusedMulti(resourceList, &filters.Options{}, clientset, nil, nil, gatewayfake.NewSimpleClientset(), "json", opts) if err != nil { t.Fatalf("Error calling GetUnusedMulti: %v", err)