From 73b81c03b5037797a956f72fe10f4f453db05b86 Mon Sep 17 00:00:00 2001 From: Ian Miller Date: Thu, 28 May 2026 13:56:39 +0100 Subject: [PATCH 1/8] build: add cert-manager API dependency Add github.com/cert-manager/cert-manager for typed access to Certificate and ClusterIssuer resources. Ref: RHAIENG-4901 Assisted-By: Claude (Anthropic AI) Signed-off-by: Ian Miller --- kagenti-operator/go.mod | 46 ++++++++++--------- kagenti-operator/go.sum | 99 +++++++++++++++++++++-------------------- 2 files changed, 74 insertions(+), 71 deletions(-) diff --git a/kagenti-operator/go.mod b/kagenti-operator/go.mod index c02ff2b2..62edbc0c 100644 --- a/kagenti-operator/go.mod +++ b/kagenti-operator/go.mod @@ -16,7 +16,7 @@ require ( k8s.io/apiextensions-apiserver v0.35.4 k8s.io/apimachinery v0.35.4 k8s.io/client-go v0.35.4 - k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 + k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 sigs.k8s.io/controller-runtime v0.23.3 sigs.k8s.io/yaml v1.6.0 ) @@ -25,27 +25,29 @@ require ( cel.dev/expr v0.25.1 // indirect github.com/Masterminds/semver/v3 v3.4.0 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect - github.com/antlr4-go/antlr/v4 v4.13.0 // indirect + github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/blang/semver/v4 v4.0.0 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect + github.com/cert-manager/cert-manager v1.20.2 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/emicklei/go-restful/v3 v3.12.2 // indirect - github.com/evanphx/json-patch v4.12.0+incompatible // indirect + github.com/emicklei/go-restful/v3 v3.13.0 // indirect + github.com/evanphx/json-patch v5.9.0+incompatible // indirect github.com/evanphx/json-patch/v5 v5.9.11 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-logr/zapr v1.3.0 // indirect - github.com/go-openapi/jsonpointer v0.21.0 // indirect - github.com/go-openapi/jsonreference v0.20.2 // indirect - github.com/go-openapi/swag v0.23.0 // indirect + github.com/go-openapi/jsonpointer v0.22.4 // indirect + github.com/go-openapi/jsonreference v0.21.4 // indirect + github.com/go-openapi/swag v0.23.1 // indirect + github.com/go-openapi/swag/jsonname v0.25.4 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/google/btree v1.1.3 // indirect github.com/google/cel-go v0.26.0 // indirect - github.com/google/gnostic-models v0.7.0 // indirect + github.com/google/gnostic-models v0.7.1 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 // indirect github.com/google/uuid v1.6.0 // indirect @@ -53,16 +55,16 @@ require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/mailru/easyjson v0.7.7 // indirect + github.com/mailru/easyjson v0.9.1 // indirect 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/pmezard/go-difflib v1.0.0 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/common v0.66.1 // indirect - github.com/prometheus/procfs v0.16.1 // indirect - github.com/spf13/cobra v1.10.0 // indirect - github.com/spf13/pflag v1.0.9 // indirect - github.com/stoewer/go-strcase v1.3.0 // indirect + github.com/prometheus/procfs v0.17.0 // indirect + github.com/spf13/cobra v1.10.2 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/stoewer/go-strcase v1.3.1 // indirect github.com/x448/float16 v0.8.4 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect @@ -74,10 +76,10 @@ require ( go.opentelemetry.io/otel/trace v1.44.0 // indirect go.opentelemetry.io/proto/otlp v1.10.0 // indirect go.uber.org/multierr v1.11.0 // indirect - go.uber.org/zap v1.27.0 // indirect + go.uber.org/zap v1.27.1 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect + golang.org/x/exp v0.0.0-20250718183923-645b1fa84792 // indirect golang.org/x/mod v0.35.0 // indirect golang.org/x/net v0.55.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect @@ -85,9 +87,9 @@ require ( golang.org/x/sys v0.45.0 // indirect golang.org/x/term v0.43.0 // indirect golang.org/x/text v0.37.0 // indirect - golang.org/x/time v0.9.0 // indirect + golang.org/x/time v0.14.0 // indirect golang.org/x/tools v0.44.0 // indirect - gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect + gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect google.golang.org/grpc v1.81.1 // indirect @@ -97,10 +99,10 @@ require ( gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/apiserver v0.35.4 // indirect k8s.io/component-base v0.35.4 // indirect - k8s.io/klog/v2 v2.130.1 // indirect - k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect - sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2 // indirect + k8s.io/klog/v2 v2.140.0 // indirect + k8s.io/kube-openapi v0.0.0-20260127142750-a19766b6e2d4 // indirect + sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.33.0 // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect sigs.k8s.io/randfill v1.0.0 // indirect - sigs.k8s.io/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.2 // indirect ) diff --git a/kagenti-operator/go.sum b/kagenti-operator/go.sum index b97c471f..282e76df 100644 --- a/kagenti-operator/go.sum +++ b/kagenti-operator/go.sum @@ -4,26 +4,27 @@ github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1 github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= -github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI= -github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g= +github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= +github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/cert-manager/cert-manager v1.20.2 h1:CimnY00nLqB2lmxhoSuEC4GDMFDK7JCXqyjwMM9ndIQ= +github.com/cert-manager/cert-manager v1.20.2/go.mod h1:1g/+a/WK5zWH/dXPZa3dMD3aJQJNRXQu+PN17C6WrOw= 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= 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.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= -github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= -github.com/evanphx/json-patch v4.12.0+incompatible h1:4onqiflcdA9EOZ4RxV643DvftH5pOlLGNtQ5lPWQu84= -github.com/evanphx/json-patch v4.12.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= +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/evanphx/json-patch v5.9.0+incompatible h1:fBXyNpNMuTTDdquAq/uisOr2lShz4oaXpDTX2bLe7ls= +github.com/evanphx/json-patch v5.9.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= @@ -47,14 +48,16 @@ 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-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= -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/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-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4= +github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80= +github.com/go-openapi/jsonreference v0.21.4 h1:24qaE2y9bx/q3uRK/qN+TDwbok1NhbSmGjjySRCHtC8= +github.com/go-openapi/jsonreference v0.21.4/go.mod h1:rIENPTjDbLpzQmQWCj5kKj3ZlmEh+EFVbz3RTUh30/4= +github.com/go-openapi/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZU= +github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0= +github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI= +github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag= +github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls= +github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= @@ -65,8 +68,8 @@ github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= github.com/google/cel-go v0.26.0 h1:DPGjXackMpJWH680oGY4lZhYjIameYmR+/6RBdDGmaI= github.com/google/cel-go v0.26.0/go.mod h1:A9O8OU9rdvrK5MQyrqfIxo1a0u4g3sF8KB6PUIaryMM= -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/gnostic-models v0.7.1 h1:SisTfuFKJSKM5CPZkffwi6coztzzeYUhc3v4yxLWH8c= +github.com/google/gnostic-models v0.7.1/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= 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= @@ -88,17 +91,14 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= 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= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= -github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= -github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8= +github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo= github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE= @@ -117,28 +117,29 @@ github.com/onsi/gomega v1.40.0 h1:Vtol0e1MghCD2ZVIilPDIg44XSL9l2QAn8ZNaljWcJc= github.com/onsi/gomega v1.40.0/go.mod h1:M/Uqpu/8qTjtzCLUA2zJHX9Iilrau25x1PdoSRbWh5A= 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/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= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= -github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= -github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0= +github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/spf13/cobra v1.10.0 h1:a5/WeUlSDCvV5a45ljW2ZFtV0bTDpkfSAj3uqB6Sc+0= -github.com/spf13/cobra v1.10.0/go.mod h1:9dhySC7dnTtEiqzmqfkLj47BslqLCUPMXjG2lj/NgoE= -github.com/spf13/pflag v1.0.8/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo= github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= -github.com/stoewer/go-strcase v1.3.0 h1:g0eASXYtp+yvN9fK8sH94oCIk0fau9uV1/ZdJ0AVEzs= -github.com/stoewer/go-strcase v1.3.0/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo= +github.com/stoewer/go-strcase v1.3.1 h1:iS0MdW+kVTxgMoE1LAZyMiYJFKlOzLooE4MxjirtkAs= +github.com/stoewer/go-strcase v1.3.1/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo= 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= @@ -184,14 +185,14 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= -go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= +go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= -golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= +golang.org/x/exp v0.0.0-20250718183923-645b1fa84792 h1:R9PFI6EUdfVKgwKjZef7QIwGcBKu86OEFpJ9nUEP2l4= +golang.org/x/exp v0.0.0-20250718183923-645b1fa84792/go.mod h1:A+z0yzpGtvnG90cToK5n2tu8UJVP2XUATh+r+sfOOOc= golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= @@ -206,12 +207,12 @@ golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= -golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= -golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= -gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= -gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= +gomodules.xyz/jsonpatch/v2 v2.5.0 h1:JELs8RLM12qJGXU4u/TO3V25KW8GreMKl9pdkk14RM0= +gomodules.xyz/jsonpatch/v2 v2.5.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8= @@ -244,21 +245,21 @@ k8s.io/client-go v0.35.4 h1:DN6fyaGuzK64UvnKO5fOA6ymSjvfGAnCAHAR0C66kD8= k8s.io/client-go v0.35.4/go.mod h1:2Pg9WpsS4NeOpoYTfHHfMxBG8zFMSAUi4O/qoiJC3nY= k8s.io/component-base v0.35.4 h1:6n1tNJ87johN0Hif0Fs8K2GMthsaUwMqCebUDLYyv7U= k8s.io/component-base v0.35.4/go.mod h1:qaDJgz5c1KYKla9occFmlJEfPpkuA55s90G509R+PeY= -k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= -k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= -k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 h1:Y3gxNAuB0OBLImH611+UDZcmKS3g6CthxToOb37KgwE= -k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ= -k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck= -k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= -sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2 h1:jpcvIRr3GLoUoEKRkHKSmGjxb6lWwrBlJsXc+eUYQHM= -sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2/go.mod h1:Ve9uj1L+deCXFrPOk1LpFXqTg7LCFzFso6PA48q/XZw= +k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc= +k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0= +k8s.io/kube-openapi v0.0.0-20260127142750-a19766b6e2d4 h1:HhDfevmPS+OalTjQRKbTHppRIz01AWi8s45TMXStgYY= +k8s.io/kube-openapi v0.0.0-20260127142750-a19766b6e2d4/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ= +k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 h1:AZYQSJemyQB5eRxqcPky+/7EdBj0xi3g0ZcxxJ7vbWU= +k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= +sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.33.0 h1:qPrZsv1cwQiFeieFlRqT627fVZ+tyfou/+S5S0H5ua0= +sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.33.0/go.mod h1:Ve9uj1L+deCXFrPOk1LpFXqTg7LCFzFso6PA48q/XZw= sigs.k8s.io/controller-runtime v0.23.3 h1:VjB/vhoPoA9l1kEKZHBMnQF33tdCLQKJtydy4iqwZ80= sigs.k8s.io/controller-runtime v0.23.3/go.mod h1:B6COOxKptp+YaUT5q4l6LqUJTRpizbgf9KSRNdQGns0= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= 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/v6 v6.3.2-0.20260122202528-d9cc6641c482 h1:2WOzJpHUBVrrkDjU4KBT8n5LDcj824eX0I5UKcgeRUs= -sigs.k8s.io/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/structured-merge-diff/v6 v6.3.2 h1:kwVWMx5yS1CrnFWA/2QHyRVJ8jM6dBA80uLmm0wJkk8= +sigs.k8s.io/structured-merge-diff/v6 v6.3.2/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= From 0a8ac79eec469a5866432afc85810df2fadcbf22 Mon Sep 17 00:00:00 2001 From: Ian Miller Date: Thu, 28 May 2026 13:56:46 +0100 Subject: [PATCH 2/8] feat: add cacerts helpers for fingerprint verification and secret assembly Pure functions with no Kubernetes dependency: - verifyCAFingerprint: SHA256 comparison of root vs intermediate CA - buildCacertsData: assembles Istio cacerts secret key mapping Includes unit tests for matching/mismatching fingerprints, invalid PEM/DER, correct key mapping, and cert-chain concatenation. Ref: RHAIENG-4901 Assisted-By: Claude (Anthropic AI) Signed-off-by: Ian Miller --- .../controller/sharedtrust_helpers.go | 65 ++++++++++ .../controller/sharedtrust_helpers_test.go | 122 ++++++++++++++++++ 2 files changed, 187 insertions(+) create mode 100644 kagenti-operator/internal/controller/sharedtrust_helpers.go create mode 100644 kagenti-operator/internal/controller/sharedtrust_helpers_test.go diff --git a/kagenti-operator/internal/controller/sharedtrust_helpers.go b/kagenti-operator/internal/controller/sharedtrust_helpers.go new file mode 100644 index 00000000..095b3c3f --- /dev/null +++ b/kagenti-operator/internal/controller/sharedtrust_helpers.go @@ -0,0 +1,65 @@ +/* +Copyright 2026. + +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 ( + "crypto/sha256" + "crypto/x509" + "encoding/pem" + "fmt" +) + +func verifyCAFingerprint(rootCertPEM, intermediateCACertPEM []byte) (bool, error) { + rootFP, err := certFingerprint(rootCertPEM) + if err != nil { + return false, fmt.Errorf("parsing root CA: %w", err) + } + + intFP, err := certFingerprint(intermediateCACertPEM) + if err != nil { + return false, fmt.Errorf("parsing intermediate CA: %w", err) + } + + return rootFP == intFP, nil +} + +func certFingerprint(certPEM []byte) ([sha256.Size]byte, error) { + block, _ := pem.Decode(certPEM) + if block == nil { + return [sha256.Size]byte{}, fmt.Errorf("no PEM block found") + } + + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return [sha256.Size]byte{}, fmt.Errorf("parsing certificate: %w", err) + } + + return sha256.Sum256(cert.Raw), nil +} + +func buildCacertsData(tlsCrt, tlsKey, caCrt []byte) map[string][]byte { + certChain := make([]byte, 0, len(tlsCrt)+len(caCrt)) + certChain = append(certChain, tlsCrt...) + certChain = append(certChain, caCrt...) + + return map[string][]byte{ + "ca-cert.pem": tlsCrt, + "ca-key.pem": tlsKey, + "root-cert.pem": caCrt, + "cert-chain.pem": certChain, + } +} diff --git a/kagenti-operator/internal/controller/sharedtrust_helpers_test.go b/kagenti-operator/internal/controller/sharedtrust_helpers_test.go new file mode 100644 index 00000000..2aead569 --- /dev/null +++ b/kagenti-operator/internal/controller/sharedtrust_helpers_test.go @@ -0,0 +1,122 @@ +/* +Copyright 2026. + +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 ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "testing" + "time" +) + +func generateSelfSignedCert(t *testing.T, cn string) (certPEM []byte) { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("generating key: %v", err) + } + + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: cn}, + NotBefore: time.Now(), + NotAfter: time.Now().Add(time.Hour), + IsCA: true, + KeyUsage: x509.KeyUsageCertSign, + } + + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) + if err != nil { + t.Fatalf("creating certificate: %v", err) + } + + return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) +} + +func TestVerifyCAFingerprint(t *testing.T) { + certA := generateSelfSignedCert(t, "root-a") + certB := generateSelfSignedCert(t, "root-b") + + t.Run("matching fingerprints", func(t *testing.T) { + match, err := verifyCAFingerprint(certA, certA) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !match { + t.Fatal("expected match for identical certs") + } + }) + + t.Run("mismatching fingerprints", func(t *testing.T) { + match, err := verifyCAFingerprint(certA, certB) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if match { + t.Fatal("expected mismatch for different certs") + } + }) + + t.Run("invalid PEM", func(t *testing.T) { + _, err := verifyCAFingerprint([]byte("not-pem"), certA) + if err == nil { + t.Fatal("expected error for invalid PEM") + } + }) + + t.Run("invalid certificate DER", func(t *testing.T) { + badPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: []byte("bad-der")}) + _, err := verifyCAFingerprint(badPEM, certA) + if err == nil { + t.Fatal("expected error for invalid DER") + } + }) +} + +func TestBuildCacertsData(t *testing.T) { + tlsCrt := []byte("intermediate-cert\n") + tlsKey := []byte("intermediate-key\n") + caCrt := []byte("root-ca-cert\n") + + data := buildCacertsData(tlsCrt, tlsKey, caCrt) + + if string(data["ca-cert.pem"]) != string(tlsCrt) { + t.Errorf("ca-cert.pem: got %q, want %q", data["ca-cert.pem"], tlsCrt) + } + + if string(data["ca-key.pem"]) != string(tlsKey) { + t.Errorf("ca-key.pem: got %q, want %q", data["ca-key.pem"], tlsKey) + } + + if string(data["root-cert.pem"]) != string(caCrt) { + t.Errorf("root-cert.pem: got %q, want %q", data["root-cert.pem"], caCrt) + } + + expectedChain := string(tlsCrt) + string(caCrt) + if string(data["cert-chain.pem"]) != expectedChain { + t.Errorf("cert-chain.pem: got %q, want %q", data["cert-chain.pem"], expectedChain) + } + + if len(data) != 4 { + t.Errorf("expected 4 keys, got %d", len(data)) + } +} From c883d074bd61ae9c4ce436013f83bf89b782f4c1 Mon Sep 17 00:00:00 2001 From: Ian Miller Date: Thu, 28 May 2026 13:56:56 +0100 Subject: [PATCH 3/8] feat: add SharedTrust controller for cacerts reconciliation Event-driven controller watching cert-manager Certificate objects to reconcile cacerts secrets for Istio mTLS. Creates cacerts in istio-system and openshift-ingress with the key format istiod expects. Triggers rollout restarts of istiod and ztunnel when cert data changes. Verifies root CA fingerprints match intermediate issuers; deletes stale intermediates to force re-issuance on mismatch. Registers conditionally via CertManagerCRDExists, following the TektonConfig pattern. Ref: RHAIENG-4901 Assisted-By: Claude (Anthropic AI) Signed-off-by: Ian Miller --- kagenti-operator/cmd/main.go | 12 + kagenti-operator/config/rbac/role.yaml | 11 + .../controller/sharedtrust_controller.go | 398 ++++++++++++++++++ 3 files changed, 421 insertions(+) create mode 100644 kagenti-operator/internal/controller/sharedtrust_controller.go diff --git a/kagenti-operator/cmd/main.go b/kagenti-operator/cmd/main.go index 921ba27f..e3224650 100644 --- a/kagenti-operator/cmd/main.go +++ b/kagenti-operator/cmd/main.go @@ -42,6 +42,7 @@ import ( metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" "sigs.k8s.io/controller-runtime/pkg/webhook" + cmv1 "github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1" "github.com/spiffe/go-spiffe/v2/workloadapi" agentv1alpha1 "github.com/kagenti/operator/api/v1alpha1" @@ -68,6 +69,7 @@ func init() { utilruntime.Must(agentv1alpha1.AddToScheme(scheme)) utilruntime.Must(mlflow.AddToScheme(scheme)) utilruntime.Must(tekton.AddToScheme(scheme)) + utilruntime.Must(cmv1.AddToScheme(scheme)) // +kubebuilder:scaffold:scheme } @@ -488,6 +490,16 @@ func main() { } } + if controller.CertManagerCRDExists(mgr.GetConfig()) { + if err = (&controller.SharedTrustReconciler{ + Client: mgr.GetClient(), + Recorder: mgr.GetEventRecorderFor("shared-trust-controller"), + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "SharedTrust") + os.Exit(1) + } + } + if err = webhookv1alpha1.SetupAgentCardWebhookWithManager(mgr); err != nil { setupLog.Error(err, "unable to create webhook", "webhook", "AgentCard") os.Exit(1) diff --git a/kagenti-operator/config/rbac/role.yaml b/kagenti-operator/config/rbac/role.yaml index 44a8d399..0d52d40b 100644 --- a/kagenti-operator/config/rbac/role.yaml +++ b/kagenti-operator/config/rbac/role.yaml @@ -11,6 +11,7 @@ rules: - secrets verbs: - create + - delete - get - list - patch @@ -106,6 +107,7 @@ rules: - apiGroups: - apps resources: + - daemonsets - deployments - statefulsets verbs: @@ -121,6 +123,15 @@ rules: - statefulsets/finalizers verbs: - update +- apiGroups: + - cert-manager.io + resources: + - certificates + - clusterissuers + verbs: + - get + - list + - watch - apiGroups: - mlflow.opendatahub.io resources: diff --git a/kagenti-operator/internal/controller/sharedtrust_controller.go b/kagenti-operator/internal/controller/sharedtrust_controller.go new file mode 100644 index 00000000..ba104a17 --- /dev/null +++ b/kagenti-operator/internal/controller/sharedtrust_controller.go @@ -0,0 +1,398 @@ +/* +Copyright 2026. + +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" + "fmt" + "time" + + cmv1 "github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1" + cmmeta "github.com/cert-manager/cert-manager/pkg/apis/meta/v1" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/discovery" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/record" + "k8s.io/client-go/util/retry" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/reconcile" +) + +const ( + RootCACertName = "istio-mesh-root-ca" + RootCASecretName = "istio-mesh-root-ca-secret" + RootCANamespace = "cert-manager" + + IstioSystemCertName = "istio-cacerts-default" + IstioSystemSecretName = "istio-cacerts-default-cert" + IstioSystemNamespace = "istio-system" + + OpenShiftIngressCertName = "istio-cacerts-openshift-gateway" + OpenShiftIngressSecretName = "istio-cacerts-og-cert" + OpenShiftIngressNamespace = "openshift-ingress" + + CacertsSecretName = "cacerts" + + IstiodDeployment = "istiod" + IstiodOpenShiftIngressDeployment = "istiod-openshift-gateway" + ZtunnelDaemonSet = "ztunnel" + ZtunnelNamespace = "istio-ztunnel" + + StaleConfigMapName = "istio-ca-root-cert" + + requeuePrecondition = 5 * time.Minute + requeueReadiness = 30 * time.Second +) + +var ( + sharedTrustLogger = ctrl.Log.WithName("controller").WithName("SharedTrust") + + managedCertificates = []types.NamespacedName{ + {Name: RootCACertName, Namespace: RootCANamespace}, + {Name: IstioSystemCertName, Namespace: IstioSystemNamespace}, + {Name: OpenShiftIngressCertName, Namespace: OpenShiftIngressNamespace}, + } + + staleConfigMapNamespaces = []string{ + "kagenti-system", "gateway-system", "keycloak", + "mcp-system", "istio-system", "istio-ztunnel", + } + + intermediateConfigs = []struct { + SecretName string + Namespace string + }{ + {IstioSystemSecretName, IstioSystemNamespace}, + {OpenShiftIngressSecretName, OpenShiftIngressNamespace}, + } +) + +// +kubebuilder:rbac:groups=cert-manager.io,resources=certificates,verbs=get;list;watch +// +kubebuilder:rbac:groups=cert-manager.io,resources=clusterissuers,verbs=get;list;watch +// +kubebuilder:rbac:groups=core,resources=secrets,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=core,resources=configmaps,verbs=get;list;watch;delete +// +kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;list;watch;update;patch +// +kubebuilder:rbac:groups=apps,resources=daemonsets,verbs=get;list;watch;update;patch +// +kubebuilder:rbac:groups=core,resources=events,verbs=create;patch + +type SharedTrustReconciler struct { + client.Client + Recorder record.EventRecorder +} + +func (r *SharedTrustReconciler) Reconcile(ctx context.Context, _ ctrl.Request) (ctrl.Result, error) { + logger := log.FromContext(ctx) + logger.V(1).Info("Reconciling SharedTrust") + + if result, err := r.checkCertificates(ctx); err != nil || result.RequeueAfter > 0 { + return result, err + } + + if result, err := r.verifyFingerprints(ctx); err != nil || result.RequeueAfter > 0 || result.Requeue { + return result, err + } + + needsRestart, err := r.reconcileCacertsSecrets(ctx) + if err != nil { + return ctrl.Result{}, err + } + + if needsRestart { + if err := r.restartWorkloads(ctx); err != nil { + return ctrl.Result{}, err + } + } + + logger.Info("SharedTrust reconciliation complete", "restarted", needsRestart) + return ctrl.Result{}, nil +} + +func (r *SharedTrustReconciler) checkCertificates(ctx context.Context) (ctrl.Result, error) { + logger := log.FromContext(ctx) + + for _, nn := range managedCertificates { + cert := &cmv1.Certificate{} + if err := r.Get(ctx, nn, cert); err != nil { + if apierrors.IsNotFound(err) { + logger.Info("Certificate not found, requeueing", "certificate", nn) + r.Recorder.Eventf(cert, corev1.EventTypeWarning, "CertificateMissing", + "Certificate %s/%s not found", nn.Namespace, nn.Name) + return ctrl.Result{RequeueAfter: requeuePrecondition}, nil + } + return ctrl.Result{}, fmt.Errorf("getting certificate %s: %w", nn, err) + } + + ready := false + for _, cond := range cert.Status.Conditions { + if cond.Type == cmv1.CertificateConditionReady && cond.Status == cmmeta.ConditionTrue { + ready = true + break + } + } + + if !ready { + logger.Info("Certificate not ready, requeueing", "certificate", nn) + r.Recorder.Eventf(cert, corev1.EventTypeWarning, "CertificateNotReady", + "Certificate %s/%s is not Ready", nn.Namespace, nn.Name) + return ctrl.Result{RequeueAfter: requeueReadiness}, nil + } + } + return ctrl.Result{}, nil +} + +func (r *SharedTrustReconciler) verifyFingerprints(ctx context.Context) (ctrl.Result, error) { + logger := log.FromContext(ctx) + + rootSecret := &corev1.Secret{} + if err := r.Get(ctx, types.NamespacedName{Name: RootCASecretName, Namespace: RootCANamespace}, rootSecret); err != nil { + return ctrl.Result{}, fmt.Errorf("getting root CA secret: %w", err) + } + + rootCertPEM := rootSecret.Data["tls.crt"] + + for _, ic := range intermediateConfigs { + intSecret := &corev1.Secret{} + nn := types.NamespacedName{Name: ic.SecretName, Namespace: ic.Namespace} + if err := r.Get(ctx, nn, intSecret); err != nil { + return ctrl.Result{}, fmt.Errorf("getting intermediate secret %s: %w", nn, err) + } + + match, err := verifyCAFingerprint(rootCertPEM, intSecret.Data["ca.crt"]) + if err != nil { + return ctrl.Result{}, fmt.Errorf("verifying fingerprint for %s: %w", nn, err) + } + + if !match { + logger.Info("Fingerprint mismatch, deleting intermediate secret for re-issuance", + "secret", nn) + r.Recorder.Eventf(intSecret, corev1.EventTypeWarning, "FingerprintMismatch", + "Root CA fingerprint mismatch for %s/%s — deleting to force re-issuance", + nn.Namespace, nn.Name) + if err := r.Delete(ctx, intSecret); err != nil { + return ctrl.Result{}, fmt.Errorf("deleting mismatched secret %s: %w", nn, err) + } + return ctrl.Result{Requeue: true}, nil + } + } + + return ctrl.Result{}, nil +} + +func (r *SharedTrustReconciler) reconcileCacertsSecrets(ctx context.Context) (bool, error) { + anyChanged := false + + for _, ic := range intermediateConfigs { + intSecret := &corev1.Secret{} + nn := types.NamespacedName{Name: ic.SecretName, Namespace: ic.Namespace} + if err := r.Get(ctx, nn, intSecret); err != nil { + return false, fmt.Errorf("getting intermediate secret %s: %w", nn, err) + } + + desired := buildCacertsData(intSecret.Data["tls.crt"], intSecret.Data["tls.key"], intSecret.Data["ca.crt"]) + + cacertsNN := types.NamespacedName{Name: CacertsSecretName, Namespace: ic.Namespace} + existing := &corev1.Secret{} + err := r.Get(ctx, cacertsNN, existing) + + switch { + case apierrors.IsNotFound(err): + secret := &corev1.Secret{} + secret.Name = CacertsSecretName + secret.Namespace = ic.Namespace + secret.Type = corev1.SecretTypeOpaque + secret.Data = desired + if err := r.Create(ctx, secret); err != nil { + return false, fmt.Errorf("creating cacerts secret in %s: %w", ic.Namespace, err) + } + anyChanged = true + + case err != nil: + return false, fmt.Errorf("getting cacerts secret in %s: %w", ic.Namespace, err) + + default: + if !secretDataEqual(existing.Data, desired) { + existing.Data = desired + if err := r.Update(ctx, existing); err != nil { + return false, fmt.Errorf("updating cacerts secret in %s: %w", ic.Namespace, err) + } + anyChanged = true + } + } + } + + return anyChanged, nil +} + +func secretDataEqual(a, b map[string][]byte) bool { + if len(a) != len(b) { + return false + } + for k, v := range a { + if bv, ok := b[k]; !ok || string(v) != string(bv) { + return false + } + } + return true +} + +func (r *SharedTrustReconciler) restartWorkloads(ctx context.Context) error { + logger := log.FromContext(ctx) + + r.deleteStaleConfigMaps(ctx) + + restartAnnotation := map[string]string{ + "kubectl.kubernetes.io/restartedAt": time.Now().Format(time.RFC3339), + } + + deployments := []types.NamespacedName{ + {Name: IstiodDeployment, Namespace: IstioSystemNamespace}, + {Name: IstiodOpenShiftIngressDeployment, Namespace: OpenShiftIngressNamespace}, + } + + for _, nn := range deployments { + if err := r.rolloutRestartDeployment(ctx, nn, restartAnnotation); err != nil { + if apierrors.IsNotFound(err) { + logger.Info("Deployment not found, skipping restart", "deployment", nn) + continue + } + return err + } + logger.Info("Triggered deployment restart", "deployment", nn) + } + + ztunnelNN := types.NamespacedName{Name: ZtunnelDaemonSet, Namespace: ZtunnelNamespace} + if err := r.rolloutRestartDaemonSet(ctx, ztunnelNN, restartAnnotation); err != nil { + if apierrors.IsNotFound(err) { + logger.Info("DaemonSet not found, skipping restart", "daemonset", ztunnelNN) + return nil + } + return err + } + logger.Info("Triggered daemonset restart", "daemonset", ztunnelNN) + + return nil +} + +func (r *SharedTrustReconciler) deleteStaleConfigMaps(ctx context.Context) { + logger := log.FromContext(ctx) + for _, ns := range staleConfigMapNamespaces { + cm := &corev1.ConfigMap{} + nn := types.NamespacedName{Name: StaleConfigMapName, Namespace: ns} + if err := r.Get(ctx, nn, cm); err != nil { + continue + } + if err := r.Delete(ctx, cm); err != nil && !apierrors.IsNotFound(err) { + logger.Error(err, "Failed to delete stale ConfigMap", "configmap", nn) + } + } +} + +func (r *SharedTrustReconciler) rolloutRestartDeployment(ctx context.Context, nn types.NamespacedName, annotations map[string]string) error { + return retry.RetryOnConflict(retry.DefaultRetry, func() error { + deploy := &appsv1.Deployment{} + if err := r.Get(ctx, nn, deploy); err != nil { + return err + } + if deploy.Spec.Template.Annotations == nil { + deploy.Spec.Template.Annotations = make(map[string]string) + } + for k, v := range annotations { + deploy.Spec.Template.Annotations[k] = v + } + return r.Update(ctx, deploy) + }) +} + +func (r *SharedTrustReconciler) rolloutRestartDaemonSet(ctx context.Context, nn types.NamespacedName, annotations map[string]string) error { + return retry.RetryOnConflict(retry.DefaultRetry, func() error { + ds := &appsv1.DaemonSet{} + if err := r.Get(ctx, nn, ds); err != nil { + return err + } + if ds.Spec.Template.Annotations == nil { + ds.Spec.Template.Annotations = make(map[string]string) + } + for k, v := range annotations { + ds.Spec.Template.Annotations[k] = v + } + return r.Update(ctx, ds) + }) +} + +func (r *SharedTrustReconciler) SetupWithManager(mgr ctrl.Manager) error { + mapToCertificateReconcile := handler.EnqueueRequestsFromMapFunc( + func(_ context.Context, obj client.Object) []reconcile.Request { + for _, mc := range managedCertificates { + if obj.GetName() == mc.Name && obj.GetNamespace() == mc.Namespace { + return []reconcile.Request{{ + NamespacedName: types.NamespacedName{ + Name: "shared-trust", + Namespace: IstioSystemNamespace, + }, + }} + } + } + return nil + }, + ) + + return ctrl.NewControllerManagedBy(mgr). + Named("SharedTrust"). + Watches(&cmv1.Certificate{}, mapToCertificateReconcile). + Complete(r) +} + +func CertManagerCRDExists(cfg *rest.Config) bool { + dc, err := discovery.NewDiscoveryClientForConfig(cfg) + if err != nil { + sharedTrustLogger.Error(err, "Failed to create discovery client for cert-manager check") + return false + } + + for attempt := range 3 { + if attempt > 0 { + delay := time.Duration(attempt) * 5 * time.Second + sharedTrustLogger.Info("Retrying cert-manager CRD discovery", "attempt", attempt+1, "delay", delay) + time.Sleep(delay) + } + + resources, err := dc.ServerResourcesForGroupVersion("cert-manager.io/v1") + if err != nil { + sharedTrustLogger.Info("cert-manager CRDs not found", "attempt", attempt+1, "error", err) + continue + } + + for _, r := range resources.APIResources { + if r.Kind == "Certificate" { + sharedTrustLogger.Info("cert-manager CRDs detected: SharedTrust controller will start") + return true + } + } + + sharedTrustLogger.Info("cert-manager.io/v1 group exists but Certificate kind not found") + return false + } + + sharedTrustLogger.Info("cert-manager CRDs not found after retries: SharedTrust controller will not start") + return false +} From b6d437c98c7aaf424740b7edab8107de28bc5bd2 Mon Sep 17 00:00:00 2001 From: Ian Miller Date: Thu, 28 May 2026 13:57:01 +0100 Subject: [PATCH 4/8] test: add unit tests for SharedTrust controller Tests with fake client covering certificates missing, not ready, fingerprint mismatch, happy path cacerts creation, data change update, and no-restart when unchanged. Ref: RHAIENG-4901 Assisted-By: Claude (Anthropic AI) Signed-off-by: Ian Miller --- .../controller/sharedtrust_controller_test.go | 412 ++++++++++++++++++ 1 file changed, 412 insertions(+) create mode 100644 kagenti-operator/internal/controller/sharedtrust_controller_test.go diff --git a/kagenti-operator/internal/controller/sharedtrust_controller_test.go b/kagenti-operator/internal/controller/sharedtrust_controller_test.go new file mode 100644 index 00000000..52d84f0b --- /dev/null +++ b/kagenti-operator/internal/controller/sharedtrust_controller_test.go @@ -0,0 +1,412 @@ +/* +Copyright 2026. + +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" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "testing" + "time" + + cmv1 "github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1" + cmmeta "github.com/cert-manager/cert-manager/pkg/apis/meta/v1" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/tools/record" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func newTestScheme(t *testing.T) *runtime.Scheme { + t.Helper() + s := runtime.NewScheme() + for _, add := range []func(*runtime.Scheme) error{ + corev1.AddToScheme, + appsv1.AddToScheme, + cmv1.AddToScheme, + } { + if err := add(s); err != nil { + t.Fatalf("adding scheme: %v", err) + } + } + return s +} + +type testPKI struct { + RootCertPEM []byte + RootKeyPEM []byte + + IntCertPEM []byte + IntKeyPEM []byte + IntCAPEM []byte // == RootCertPEM (the CA that signed the intermediate) +} + +func generateTestPKI(t *testing.T) testPKI { + t.Helper() + + rootKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("generating root key: %v", err) + } + + rootTmpl := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "test-root-ca"}, + NotBefore: time.Now(), + NotAfter: time.Now().Add(time.Hour), + IsCA: true, + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, + BasicConstraintsValid: true, + } + + rootDER, err := x509.CreateCertificate(rand.Reader, rootTmpl, rootTmpl, &rootKey.PublicKey, rootKey) + if err != nil { + t.Fatalf("creating root cert: %v", err) + } + rootCert, _ := x509.ParseCertificate(rootDER) + rootCertPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: rootDER}) + + rootKeyDER, _ := x509.MarshalECPrivateKey(rootKey) + rootKeyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: rootKeyDER}) + + intKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("generating intermediate key: %v", err) + } + + intTmpl := &x509.Certificate{ + SerialNumber: big.NewInt(2), + Subject: pkix.Name{CommonName: "test-intermediate"}, + NotBefore: time.Now(), + NotAfter: time.Now().Add(time.Hour), + IsCA: true, + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, + BasicConstraintsValid: true, + } + + intDER, err := x509.CreateCertificate(rand.Reader, intTmpl, rootCert, &intKey.PublicKey, rootKey) + if err != nil { + t.Fatalf("creating intermediate cert: %v", err) + } + intCertPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: intDER}) + + intKeyDER, _ := x509.MarshalECPrivateKey(intKey) + intKeyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: intKeyDER}) + + return testPKI{ + RootCertPEM: rootCertPEM, + RootKeyPEM: rootKeyPEM, + IntCertPEM: intCertPEM, + IntKeyPEM: intKeyPEM, + IntCAPEM: rootCertPEM, + } +} + +func readyCertificate(name, namespace string) *cmv1.Certificate { + return &cmv1.Certificate{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace}, + Status: cmv1.CertificateStatus{ + Conditions: []cmv1.CertificateCondition{ + { + Type: cmv1.CertificateConditionReady, + Status: cmmeta.ConditionTrue, + }, + }, + }, + } +} + +func notReadyCertificate(name, namespace string) *cmv1.Certificate { + return &cmv1.Certificate{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace}, + Status: cmv1.CertificateStatus{ + Conditions: []cmv1.CertificateCondition{ + { + Type: cmv1.CertificateConditionReady, + Status: cmmeta.ConditionFalse, + }, + }, + }, + } +} + +func intermediateSecret(name, namespace string, pki testPKI) *corev1.Secret { + return &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace}, + Data: map[string][]byte{ + "tls.crt": pki.IntCertPEM, + "tls.key": pki.IntKeyPEM, + "ca.crt": pki.IntCAPEM, + }, + } +} + +func rootCASecret(pki testPKI) *corev1.Secret { + return &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: RootCASecretName, Namespace: RootCANamespace}, + Data: map[string][]byte{ + "tls.crt": pki.RootCertPEM, + "tls.key": pki.RootKeyPEM, + }, + } +} + +func testNamespace(name string) *corev1.Namespace { + return &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: name}} +} + +func newReconciler(t *testing.T, objs ...runtime.Object) *SharedTrustReconciler { + t.Helper() + scheme := newTestScheme(t) + clientObjs := make([]runtime.Object, 0, len(objs)) + clientObjs = append(clientObjs, objs...) + + cb := fake.NewClientBuilder().WithScheme(scheme).WithRuntimeObjects(clientObjs...) + return &SharedTrustReconciler{ + Client: cb.Build(), + Recorder: record.NewFakeRecorder(10), + } +} + +var testReq = ctrl.Request{NamespacedName: types.NamespacedName{Name: "shared-trust", Namespace: IstioSystemNamespace}} + +func TestReconcile_CertificatesMissing(t *testing.T) { + r := newReconciler(t, + testNamespace(RootCANamespace), + testNamespace(IstioSystemNamespace), + testNamespace(OpenShiftIngressNamespace), + ) + + result, err := r.Reconcile(context.Background(), testReq) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.RequeueAfter != requeuePrecondition { + t.Errorf("expected requeue after %v, got %v", requeuePrecondition, result.RequeueAfter) + } +} + +func TestReconcile_CertificateNotReady(t *testing.T) { + r := newReconciler(t, + testNamespace(RootCANamespace), + testNamespace(IstioSystemNamespace), + testNamespace(OpenShiftIngressNamespace), + readyCertificate(RootCACertName, RootCANamespace), + notReadyCertificate(IstioSystemCertName, IstioSystemNamespace), + readyCertificate(OpenShiftIngressCertName, OpenShiftIngressNamespace), + ) + + result, err := r.Reconcile(context.Background(), testReq) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.RequeueAfter != requeueReadiness { + t.Errorf("expected requeue after %v, got %v", requeueReadiness, result.RequeueAfter) + } +} + +func TestReconcile_FingerprintMismatch(t *testing.T) { + pki := generateTestPKI(t) + otherRootCert := generateSelfSignedCert(t, "other-root") + + r := newReconciler(t, + testNamespace(RootCANamespace), + testNamespace(IstioSystemNamespace), + testNamespace(OpenShiftIngressNamespace), + readyCertificate(RootCACertName, RootCANamespace), + readyCertificate(IstioSystemCertName, IstioSystemNamespace), + readyCertificate(OpenShiftIngressCertName, OpenShiftIngressNamespace), + rootCASecret(pki), + // Default intermediate has mismatched ca.crt + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: IstioSystemSecretName, Namespace: IstioSystemNamespace}, + Data: map[string][]byte{ + "tls.crt": pki.IntCertPEM, + "tls.key": pki.IntKeyPEM, + "ca.crt": otherRootCert, // wrong root + }, + }, + intermediateSecret(OpenShiftIngressSecretName, OpenShiftIngressNamespace, pki), + ) + + result, err := r.Reconcile(context.Background(), testReq) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !result.Requeue { + t.Error("expected Requeue=true after fingerprint mismatch") + } + + // Verify the mismatched secret was deleted + secret := &corev1.Secret{} + err = r.Get(context.Background(), types.NamespacedName{Name: IstioSystemSecretName, Namespace: IstioSystemNamespace}, secret) + if err == nil { + t.Error("expected mismatched secret to be deleted") + } +} + +func TestReconcile_HappyPath_CreatesCacerts(t *testing.T) { + pki := generateTestPKI(t) + + r := newReconciler(t, + testNamespace(RootCANamespace), + testNamespace(IstioSystemNamespace), + testNamespace(OpenShiftIngressNamespace), + readyCertificate(RootCACertName, RootCANamespace), + readyCertificate(IstioSystemCertName, IstioSystemNamespace), + readyCertificate(OpenShiftIngressCertName, OpenShiftIngressNamespace), + rootCASecret(pki), + intermediateSecret(IstioSystemSecretName, IstioSystemNamespace, pki), + intermediateSecret(OpenShiftIngressSecretName, OpenShiftIngressNamespace, pki), + ) + + result, err := r.Reconcile(context.Background(), testReq) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.RequeueAfter != 0 || result.Requeue { + t.Errorf("expected no requeue, got %+v", result) + } + + for _, ns := range []string{IstioSystemNamespace, OpenShiftIngressNamespace} { + secret := &corev1.Secret{} + err := r.Get(context.Background(), types.NamespacedName{Name: CacertsSecretName, Namespace: ns}, secret) + if err != nil { + t.Fatalf("cacerts not created in %s: %v", ns, err) + } + + for _, key := range []string{"ca-cert.pem", "ca-key.pem", "root-cert.pem", "cert-chain.pem"} { + if _, ok := secret.Data[key]; !ok { + t.Errorf("cacerts in %s missing key %q", ns, key) + } + } + + expectedChain := string(pki.IntCertPEM) + string(pki.IntCAPEM) + if string(secret.Data["cert-chain.pem"]) != expectedChain { + t.Error("cert-chain.pem is not concatenation of intermediate + root") + } + } +} + +func TestReconcile_UpdatesCacertsOnChange(t *testing.T) { + pki := generateTestPKI(t) + + existingCacerts := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: CacertsSecretName, Namespace: IstioSystemNamespace}, + Data: map[string][]byte{ + "ca-cert.pem": []byte("old-data"), + "ca-key.pem": []byte("old-key"), + "root-cert.pem": []byte("old-root"), + "cert-chain.pem": []byte("old-chain"), + }, + } + + r := newReconciler(t, + testNamespace(RootCANamespace), + testNamespace(IstioSystemNamespace), + testNamespace(OpenShiftIngressNamespace), + readyCertificate(RootCACertName, RootCANamespace), + readyCertificate(IstioSystemCertName, IstioSystemNamespace), + readyCertificate(OpenShiftIngressCertName, OpenShiftIngressNamespace), + rootCASecret(pki), + intermediateSecret(IstioSystemSecretName, IstioSystemNamespace, pki), + intermediateSecret(OpenShiftIngressSecretName, OpenShiftIngressNamespace, pki), + existingCacerts, + ) + + result, err := r.Reconcile(context.Background(), testReq) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Requeue || result.RequeueAfter != 0 { + t.Errorf("unexpected requeue: %+v", result) + } + + updated := &corev1.Secret{} + if err := r.Get(context.Background(), types.NamespacedName{Name: CacertsSecretName, Namespace: IstioSystemNamespace}, updated); err != nil { + t.Fatalf("getting updated cacerts: %v", err) + } + + if string(updated.Data["ca-cert.pem"]) == "old-data" { + t.Error("cacerts data was not updated") + } +} + +func TestReconcile_NoRestartWhenUnchanged(t *testing.T) { + pki := generateTestPKI(t) + expectedData := buildCacertsData(pki.IntCertPEM, pki.IntKeyPEM, pki.IntCAPEM) + + existingCacertsDefault := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: CacertsSecretName, Namespace: IstioSystemNamespace}, + Data: expectedData, + } + + ogData := buildCacertsData(pki.IntCertPEM, pki.IntKeyPEM, pki.IntCAPEM) + existingCacertsOG := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: CacertsSecretName, Namespace: OpenShiftIngressNamespace}, + Data: ogData, + } + + deploy := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{Name: IstiodDeployment, Namespace: IstioSystemNamespace}, + Spec: appsv1.DeploymentSpec{ + Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": "istiod"}}, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"app": "istiod"}}, + Spec: corev1.PodSpec{Containers: []corev1.Container{{Name: "istiod", Image: "istiod:latest"}}}, + }, + }, + } + + r := newReconciler(t, + testNamespace(RootCANamespace), + testNamespace(IstioSystemNamespace), + testNamespace(OpenShiftIngressNamespace), + readyCertificate(RootCACertName, RootCANamespace), + readyCertificate(IstioSystemCertName, IstioSystemNamespace), + readyCertificate(OpenShiftIngressCertName, OpenShiftIngressNamespace), + rootCASecret(pki), + intermediateSecret(IstioSystemSecretName, IstioSystemNamespace, pki), + intermediateSecret(OpenShiftIngressSecretName, OpenShiftIngressNamespace, pki), + existingCacertsDefault, + existingCacertsOG, + deploy, + ) + + _, err := r.Reconcile(context.Background(), testReq) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Verify deployment was NOT restarted (no annotation) + d := &appsv1.Deployment{} + if err := r.Get(context.Background(), types.NamespacedName{Name: IstiodDeployment, Namespace: IstioSystemNamespace}, d); err != nil { + t.Fatalf("getting deployment: %v", err) + } + if _, ok := d.Spec.Template.Annotations["kubectl.kubernetes.io/restartedAt"]; ok { + t.Error("deployment should NOT be restarted when cacerts unchanged") + } +} From b4d7a92562517cd614a44aa029eb6141c91c3847 Mon Sep 17 00:00:00 2001 From: Ian Miller Date: Thu, 28 May 2026 13:57:08 +0100 Subject: [PATCH 5/8] test: add e2e tests for SharedTrust controller End-to-end tests on Kind cluster with cert-manager covering cacerts creation, rebuild after intermediate deletion, restart annotations on istiod and ztunnel, and graceful handling of missing workloads. Ref: RHAIENG-4901 Assisted-By: Claude (Anthropic AI) Signed-off-by: Ian Miller --- kagenti-operator/test/e2e/sharedtrust_test.go | 411 ++++++++++++++++++ 1 file changed, 411 insertions(+) create mode 100644 kagenti-operator/test/e2e/sharedtrust_test.go diff --git a/kagenti-operator/test/e2e/sharedtrust_test.go b/kagenti-operator/test/e2e/sharedtrust_test.go new file mode 100644 index 00000000..7de8dc9d --- /dev/null +++ b/kagenti-operator/test/e2e/sharedtrust_test.go @@ -0,0 +1,411 @@ +/* +Copyright 2026. + +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 e2e + +import ( + "fmt" + "os/exec" + "strings" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/kagenti/operator/test/utils" +) + +var _ = Describe("SharedTrust E2E", Ordered, func() { + const controllerNamespace = "kagenti-operator-system" + + BeforeAll(func() { + Expect(utils.DeployController(controllerNamespace, projectImage)).To(Succeed(), "Failed to deploy controller") + + By("waiting for controller-manager to be ready") + Eventually(func(g Gomega) { + cmd := exec.Command("kubectl", "get", "pods", "-l", "control-plane=controller-manager", + "-n", controllerNamespace, + "-o", "go-template={{ range .items }}{{ if not .metadata.deletionTimestamp }}{{ .status.phase }}{{ end }}{{ end }}") + output, err := utils.Run(cmd) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(output).To(ContainSubstring("Running")) + }, 2*time.Minute, 2*time.Second).Should(Succeed()) + + By("creating required namespaces") + for _, ns := range []string{"cert-manager", "istio-system", "openshift-ingress", "istio-ztunnel"} { + cmd := exec.Command("kubectl", "create", "ns", ns, "--dry-run=client", "-o", "yaml") + yaml, err := utils.Run(cmd) + Expect(err).NotTo(HaveOccurred()) + _, err = utils.KubectlApplyStdin(yaml, "") + Expect(err).NotTo(HaveOccurred()) + } + + By("creating SelfSigned ClusterIssuer") + _, err := utils.KubectlApplyStdin(selfSignedClusterIssuerYAML(), "") + Expect(err).NotTo(HaveOccurred()) + + By("creating root CA Certificate") + _, err = utils.KubectlApplyStdin(rootCACertificateYAML(), "") + Expect(err).NotTo(HaveOccurred()) + + By("creating root CA ClusterIssuer") + _, err = utils.KubectlApplyStdin(rootCAClusterIssuerYAML(), "") + Expect(err).NotTo(HaveOccurred()) + + By("waiting for root CA secret") + Eventually(func(g Gomega) { + cmd := exec.Command("kubectl", "get", "secret", "istio-mesh-root-ca-secret", + "-n", "cert-manager", "-o", "jsonpath={.data.tls\\.crt}") + output, err := utils.Run(cmd) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(output).NotTo(BeEmpty()) + }, 2*time.Minute, 2*time.Second).Should(Succeed()) + + By("creating intermediate Certificates") + _, err = utils.KubectlApplyStdin(intermediateCertificateYAML("istio-cacerts-default", "istio-cacerts-default-cert", "istio-system"), "") + Expect(err).NotTo(HaveOccurred()) + _, err = utils.KubectlApplyStdin(intermediateCertificateYAML("istio-cacerts-openshift-gateway", "istio-cacerts-og-cert", "openshift-ingress"), "") + Expect(err).NotTo(HaveOccurred()) + + By("waiting for intermediate secrets to be created") + for _, s := range []struct{ name, ns string }{ + {"istio-cacerts-default-cert", "istio-system"}, + {"istio-cacerts-og-cert", "openshift-ingress"}, + } { + Eventually(func(g Gomega) { + cmd := exec.Command("kubectl", "get", "secret", s.name, + "-n", s.ns, "-o", "jsonpath={.data.tls\\.crt}") + output, err := utils.Run(cmd) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(output).NotTo(BeEmpty()) + }, 2*time.Minute, 2*time.Second).Should(Succeed()) + } + }) + + AfterAll(func() { + By("cleaning up Certificate resources") + for _, args := range [][]string{ + {"delete", "certificate", "istio-mesh-root-ca", "-n", "cert-manager", "--ignore-not-found"}, + {"delete", "certificate", "istio-cacerts-default", "-n", "istio-system", "--ignore-not-found"}, + {"delete", "certificate", "istio-cacerts-openshift-gateway", "-n", "openshift-ingress", "--ignore-not-found"}, + {"delete", "clusterissuer", "istio-mesh-root-ca-issuer", "--ignore-not-found"}, + {"delete", "clusterissuer", "selfsigned-issuer", "--ignore-not-found"}, + } { + cmd := exec.Command("kubectl", args...) + _, _ = utils.Run(cmd) + } + + By("cleaning up secrets and namespaces") + for _, ns := range []string{"istio-system", "openshift-ingress", "istio-ztunnel"} { + cmd := exec.Command("kubectl", "delete", "secret", "cacerts", "-n", ns, "--ignore-not-found") + _, _ = utils.Run(cmd) + } + + utils.UndeployController() + + By("removing manager namespace") + cmd := exec.Command("kubectl", "delete", "ns", controllerNamespace, "--ignore-not-found") + _, _ = utils.Run(cmd) + }) + + AfterEach(func() { + if CurrentSpecReport().Failed() { + cmd := exec.Command("kubectl", "logs", "-l", "control-plane=controller-manager", + "-n", controllerNamespace, "--tail=200") + logs, err := utils.Run(cmd) + if err == nil { + _, _ = fmt.Fprintf(GinkgoWriter, "Controller logs:\n%s\n", logs) + } + + for _, ns := range []string{"cert-manager", "istio-system", "openshift-ingress"} { + cmd = exec.Command("kubectl", "get", "events", "-n", ns, "--sort-by=.lastTimestamp") + events, err := utils.Run(cmd) + if err == nil { + _, _ = fmt.Fprintf(GinkgoWriter, "Events in %s:\n%s\n", ns, events) + } + } + } + }) + + SetDefaultEventuallyTimeout(3 * time.Minute) + SetDefaultEventuallyPollingInterval(2 * time.Second) + + It("should create cacerts Secrets in both namespaces", func() { + for _, ns := range []string{"istio-system", "openshift-ingress"} { + Eventually(func(g Gomega) { + cmd := exec.Command("kubectl", "get", "secret", "cacerts", "-n", ns, + "-o", "jsonpath={.data}") + output, err := utils.Run(cmd) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(output).NotTo(BeEmpty()) + for _, key := range []string{"ca-cert.pem", "ca-key.pem", "root-cert.pem", "cert-chain.pem"} { + g.Expect(output).To(ContainSubstring(key), + fmt.Sprintf("cacerts in %s missing key %s", ns, key)) + } + }).Should(Succeed(), fmt.Sprintf("cacerts not created in %s", ns)) + } + }) + + It("should rebuild cacerts after intermediate secret deletion", func() { + By("recording current cacerts data") + cmd := exec.Command("kubectl", "get", "secret", "cacerts", "-n", "istio-system", + "-o", "jsonpath={.metadata.resourceVersion}") + origVersion, err := utils.Run(cmd) + Expect(err).NotTo(HaveOccurred()) + + By("deleting intermediate secret to trigger re-issuance") + cmd = exec.Command("kubectl", "delete", "secret", "istio-cacerts-default-cert", "-n", "istio-system") + _, err = utils.Run(cmd) + Expect(err).NotTo(HaveOccurred()) + + By("waiting for cert-manager to re-issue the intermediate") + Eventually(func(g Gomega) { + cmd := exec.Command("kubectl", "get", "secret", "istio-cacerts-default-cert", + "-n", "istio-system", "-o", "jsonpath={.data.tls\\.crt}") + output, err := utils.Run(cmd) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(output).NotTo(BeEmpty()) + }).Should(Succeed()) + + By("verifying cacerts was rebuilt (resourceVersion changed)") + Eventually(func(g Gomega) { + cmd := exec.Command("kubectl", "get", "secret", "cacerts", "-n", "istio-system", + "-o", "jsonpath={.metadata.resourceVersion}") + newVersion, err := utils.Run(cmd) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(strings.TrimSpace(newVersion)).NotTo(Equal(strings.TrimSpace(origVersion)), + "cacerts resourceVersion should change after rebuild") + }).Should(Succeed()) + }) + + It("should apply restart annotation on istiod and ztunnel after cert change", func() { + By("deploying a dummy istiod Deployment in istio-system") + _, err := utils.KubectlApplyStdin(dummyIstiodDeploymentYAML(), "") + Expect(err).NotTo(HaveOccurred()) + Expect(utils.WaitForDeploymentReady("istiod", "istio-system", 2*time.Minute)).To(Succeed()) + + By("deploying a dummy ztunnel DaemonSet in istio-ztunnel") + _, err = utils.KubectlApplyStdin(dummyZtunnelDaemonSetYAML(), "") + Expect(err).NotTo(HaveOccurred()) + Eventually(func(g Gomega) { + cmd := exec.Command("kubectl", "get", "daemonset", "ztunnel", "-n", "istio-ztunnel", + "-o", "jsonpath={.status.numberReady}") + output, err := utils.Run(cmd) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(strings.TrimSpace(output)).NotTo(Equal("0"), "ztunnel should have ready pods") + }, 2*time.Minute, 2*time.Second).Should(Succeed()) + + By("recording current restart annotation values (may be empty or from prior reconcile)") + istiodAnnotationBefore, _ := utils.KubectlGetJsonpath("deployment", "istiod", "istio-system", + "{.spec.template.metadata.annotations.kubectl\\.kubernetes\\.io/restartedAt}") + ztunnelAnnotationBefore, _ := utils.KubectlGetJsonpath("daemonset", "ztunnel", "istio-ztunnel", + "{.spec.template.metadata.annotations.kubectl\\.kubernetes\\.io/restartedAt}") + + By("deleting cacerts so reconciler must recreate on next trigger") + cmd := exec.Command("kubectl", "delete", "secret", "cacerts", "-n", "istio-system", "--ignore-not-found") + _, _ = utils.Run(cmd) + cmd = exec.Command("kubectl", "delete", "secret", "cacerts", "-n", "openshift-ingress", "--ignore-not-found") + _, _ = utils.Run(cmd) + + By("deleting intermediate TLS secret to trigger Certificate re-issuance and reconciliation") + cmd = exec.Command("kubectl", "delete", "secret", "istio-cacerts-default-cert", "-n", "istio-system") + _, err = utils.Run(cmd) + Expect(err).NotTo(HaveOccurred()) + + By("waiting for cert-manager to re-issue the intermediate") + Eventually(func(g Gomega) { + cmd := exec.Command("kubectl", "get", "secret", "istio-cacerts-default-cert", + "-n", "istio-system", "-o", "jsonpath={.data.tls\\.crt}") + output, err := utils.Run(cmd) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(output).NotTo(BeEmpty()) + }, 2*time.Minute, 2*time.Second).Should(Succeed()) + + By("waiting for restart annotation to change on istiod") + Eventually(func(g Gomega) { + output, err := utils.KubectlGetJsonpath("deployment", "istiod", "istio-system", + "{.spec.template.metadata.annotations.kubectl\\.kubernetes\\.io/restartedAt}") + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(strings.TrimSpace(output)).NotTo(BeEmpty(), + "istiod should have restartedAt annotation") + g.Expect(strings.TrimSpace(output)).NotTo(Equal(strings.TrimSpace(istiodAnnotationBefore)), + "istiod restartedAt annotation should change after cert rotation") + }).Should(Succeed()) + + By("waiting for restart annotation to change on ztunnel") + Eventually(func(g Gomega) { + output, err := utils.KubectlGetJsonpath("daemonset", "ztunnel", "istio-ztunnel", + "{.spec.template.metadata.annotations.kubectl\\.kubernetes\\.io/restartedAt}") + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(strings.TrimSpace(output)).NotTo(BeEmpty(), + "ztunnel should have restartedAt annotation") + g.Expect(strings.TrimSpace(output)).NotTo(Equal(strings.TrimSpace(ztunnelAnnotationBefore)), + "ztunnel restartedAt annotation should change after cert rotation") + }).Should(Succeed()) + + By("cleaning up dummy workloads") + cmd = exec.Command("kubectl", "delete", "deployment", "istiod", "-n", "istio-system", "--ignore-not-found") + _, _ = utils.Run(cmd) + cmd = exec.Command("kubectl", "delete", "daemonset", "ztunnel", "-n", "istio-ztunnel", "--ignore-not-found") + _, _ = utils.Run(cmd) + }) + + It("should handle missing istiod/ztunnel gracefully", func() { + By("verifying controller is still running (no crash from missing workloads)") + Eventually(func(g Gomega) { + cmd := exec.Command("kubectl", "get", "pods", "-l", "control-plane=controller-manager", + "-n", controllerNamespace, + "-o", "go-template={{ range .items }}{{ if not .metadata.deletionTimestamp }}{{ .status.phase }}{{ end }}{{ end }}") + output, err := utils.Run(cmd) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(output).To(ContainSubstring("Running")) + }, 30*time.Second, 2*time.Second).Should(Succeed()) + + By("checking controller logs for graceful skip messages") + cmd := exec.Command("kubectl", "logs", "-l", "control-plane=controller-manager", + "-n", controllerNamespace, "--tail=200") + logs, err := utils.Run(cmd) + Expect(err).NotTo(HaveOccurred()) + Expect(logs).To(ContainSubstring("not found, skipping restart"), + "controller should log skip messages for missing workloads") + }) +}) + +func selfSignedClusterIssuerYAML() string { + return `apiVersion: cert-manager.io/v1 +kind: ClusterIssuer +metadata: + name: selfsigned-issuer +spec: + selfSigned: {} +` +} + +func rootCACertificateYAML() string { + return `apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: istio-mesh-root-ca + namespace: cert-manager +spec: + isCA: true + commonName: istio-mesh-root-ca + secretName: istio-mesh-root-ca-secret + duration: 87600h + renewBefore: 720h + privateKey: + algorithm: ECDSA + size: 256 + issuerRef: + name: selfsigned-issuer + kind: ClusterIssuer + group: cert-manager.io +` +} + +func rootCAClusterIssuerYAML() string { + return `apiVersion: cert-manager.io/v1 +kind: ClusterIssuer +metadata: + name: istio-mesh-root-ca-issuer +spec: + ca: + secretName: istio-mesh-root-ca-secret +` +} + +func intermediateCertificateYAML(name, secretName, namespace string) string { + return fmt.Sprintf(`apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: %s + namespace: %s +spec: + isCA: true + commonName: %s + secretName: %s + duration: 8760h + renewBefore: 720h + privateKey: + algorithm: ECDSA + size: 256 + issuerRef: + name: istio-mesh-root-ca-issuer + kind: ClusterIssuer + group: cert-manager.io +`, name, namespace, name, secretName) +} + +func dummyIstiodDeploymentYAML() string { + return `apiVersion: apps/v1 +kind: Deployment +metadata: + name: istiod + namespace: istio-system +spec: + replicas: 1 + selector: + matchLabels: + app: istiod + template: + metadata: + labels: + app: istiod + spec: + securityContext: + runAsNonRoot: true + runAsUser: 65534 + containers: + - name: discovery + image: registry.k8s.io/pause:3.9 + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: ["ALL"] + seccompProfile: + type: RuntimeDefault +` +} + +func dummyZtunnelDaemonSetYAML() string { + return `apiVersion: apps/v1 +kind: DaemonSet +metadata: + name: ztunnel + namespace: istio-ztunnel +spec: + selector: + matchLabels: + app: ztunnel + template: + metadata: + labels: + app: ztunnel + spec: + securityContext: + runAsNonRoot: true + runAsUser: 65534 + containers: + - name: ztunnel + image: registry.k8s.io/pause:3.9 + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: ["ALL"] + seccompProfile: + type: RuntimeDefault +` +} From 64252a2b770dc313124d6007ce44a16b5e277283 Mon Sep 17 00:00:00 2001 From: Ian Miller Date: Thu, 28 May 2026 14:21:09 +0100 Subject: [PATCH 6/8] fix: resolve golangci-lint errors in SharedTrust controller - Fix gofmt alignment in const block - Replace deprecated result.Requeue with RequeueAfter - Suppress deprecated GetEventRecorderFor (repo-wide, not just ours) - Break long lines in e2e test to stay under 120 chars Ref: RHAIENG-4901 Assisted-By: Claude (Anthropic AI) Signed-off-by: Ian Miller --- kagenti-operator/cmd/main.go | 2 +- kagenti-operator/go.mod | 4 ++-- kagenti-operator/go.sum | 2 ++ .../internal/controller/sharedtrust_controller.go | 10 +++++----- .../internal/controller/sharedtrust_controller_test.go | 8 ++++---- kagenti-operator/test/e2e/sharedtrust_test.go | 8 ++++++-- 6 files changed, 20 insertions(+), 14 deletions(-) diff --git a/kagenti-operator/cmd/main.go b/kagenti-operator/cmd/main.go index e3224650..a140b173 100644 --- a/kagenti-operator/cmd/main.go +++ b/kagenti-operator/cmd/main.go @@ -493,7 +493,7 @@ func main() { if controller.CertManagerCRDExists(mgr.GetConfig()) { if err = (&controller.SharedTrustReconciler{ Client: mgr.GetClient(), - Recorder: mgr.GetEventRecorderFor("shared-trust-controller"), + Recorder: mgr.GetEventRecorderFor("shared-trust-controller"), //nolint:staticcheck }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "unable to create controller", "controller", "SharedTrust") os.Exit(1) diff --git a/kagenti-operator/go.mod b/kagenti-operator/go.mod index 62edbc0c..a5d769b2 100644 --- a/kagenti-operator/go.mod +++ b/kagenti-operator/go.mod @@ -5,6 +5,7 @@ go 1.25.0 godebug default=go1.23 require ( + github.com/cert-manager/cert-manager v1.20.2 github.com/fsnotify/fsnotify v1.9.0 github.com/go-logr/logr v1.4.3 github.com/onsi/ginkgo/v2 v2.28.3 @@ -29,11 +30,9 @@ require ( github.com/beorn7/perks v1.0.1 // indirect github.com/blang/semver/v4 v4.0.0 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect - github.com/cert-manager/cert-manager v1.20.2 // indirect github.com/cespare/xxhash/v2 v2.3.0 // 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/evanphx/json-patch v5.9.0+incompatible // indirect github.com/evanphx/json-patch/v5 v5.9.11 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect @@ -102,6 +101,7 @@ require ( k8s.io/klog/v2 v2.140.0 // indirect k8s.io/kube-openapi v0.0.0-20260127142750-a19766b6e2d4 // indirect sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.33.0 // indirect + sigs.k8s.io/gateway-api v1.5.0 // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect sigs.k8s.io/randfill v1.0.0 // indirect sigs.k8s.io/structured-merge-diff/v6 v6.3.2 // indirect diff --git a/kagenti-operator/go.sum b/kagenti-operator/go.sum index 282e76df..6694c60a 100644 --- a/kagenti-operator/go.sum +++ b/kagenti-operator/go.sum @@ -255,6 +255,8 @@ sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.33.0 h1:qPrZsv1cwQiFe sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.33.0/go.mod h1:Ve9uj1L+deCXFrPOk1LpFXqTg7LCFzFso6PA48q/XZw= sigs.k8s.io/controller-runtime v0.23.3 h1:VjB/vhoPoA9l1kEKZHBMnQF33tdCLQKJtydy4iqwZ80= sigs.k8s.io/controller-runtime v0.23.3/go.mod h1:B6COOxKptp+YaUT5q4l6LqUJTRpizbgf9KSRNdQGns0= +sigs.k8s.io/gateway-api v1.5.0 h1:duoo14Ky/fJXpjpmyMISE2RTBGnfCg8zICfTYLTnBJA= +sigs.k8s.io/gateway-api v1.5.0/go.mod h1:GvCETiaMAlLym5CovLxGjS0NysqFk3+Yuq3/rh6QL2o= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= diff --git a/kagenti-operator/internal/controller/sharedtrust_controller.go b/kagenti-operator/internal/controller/sharedtrust_controller.go index ba104a17..510c01b7 100644 --- a/kagenti-operator/internal/controller/sharedtrust_controller.go +++ b/kagenti-operator/internal/controller/sharedtrust_controller.go @@ -53,10 +53,10 @@ const ( CacertsSecretName = "cacerts" - IstiodDeployment = "istiod" + IstiodDeployment = "istiod" IstiodOpenShiftIngressDeployment = "istiod-openshift-gateway" - ZtunnelDaemonSet = "ztunnel" - ZtunnelNamespace = "istio-ztunnel" + ZtunnelDaemonSet = "ztunnel" + ZtunnelNamespace = "istio-ztunnel" StaleConfigMapName = "istio-ca-root-cert" @@ -108,7 +108,7 @@ func (r *SharedTrustReconciler) Reconcile(ctx context.Context, _ ctrl.Request) ( return result, err } - if result, err := r.verifyFingerprints(ctx); err != nil || result.RequeueAfter > 0 || result.Requeue { + if result, err := r.verifyFingerprints(ctx); err != nil || result.RequeueAfter > 0 { return result, err } @@ -191,7 +191,7 @@ func (r *SharedTrustReconciler) verifyFingerprints(ctx context.Context) (ctrl.Re if err := r.Delete(ctx, intSecret); err != nil { return ctrl.Result{}, fmt.Errorf("deleting mismatched secret %s: %w", nn, err) } - return ctrl.Result{Requeue: true}, nil + return ctrl.Result{RequeueAfter: time.Second}, nil } } diff --git a/kagenti-operator/internal/controller/sharedtrust_controller_test.go b/kagenti-operator/internal/controller/sharedtrust_controller_test.go index 52d84f0b..2bb941e1 100644 --- a/kagenti-operator/internal/controller/sharedtrust_controller_test.go +++ b/kagenti-operator/internal/controller/sharedtrust_controller_test.go @@ -256,8 +256,8 @@ func TestReconcile_FingerprintMismatch(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if !result.Requeue { - t.Error("expected Requeue=true after fingerprint mismatch") + if result.RequeueAfter == 0 { + t.Error("expected RequeueAfter > 0 after fingerprint mismatch") } // Verify the mismatched secret was deleted @@ -287,7 +287,7 @@ func TestReconcile_HappyPath_CreatesCacerts(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if result.RequeueAfter != 0 || result.Requeue { + if result.RequeueAfter != 0 { t.Errorf("expected no requeue, got %+v", result) } @@ -341,7 +341,7 @@ func TestReconcile_UpdatesCacertsOnChange(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if result.Requeue || result.RequeueAfter != 0 { + if result.RequeueAfter != 0 { t.Errorf("unexpected requeue: %+v", result) } diff --git a/kagenti-operator/test/e2e/sharedtrust_test.go b/kagenti-operator/test/e2e/sharedtrust_test.go index 7de8dc9d..f1d62c37 100644 --- a/kagenti-operator/test/e2e/sharedtrust_test.go +++ b/kagenti-operator/test/e2e/sharedtrust_test.go @@ -75,9 +75,13 @@ var _ = Describe("SharedTrust E2E", Ordered, func() { }, 2*time.Minute, 2*time.Second).Should(Succeed()) By("creating intermediate Certificates") - _, err = utils.KubectlApplyStdin(intermediateCertificateYAML("istio-cacerts-default", "istio-cacerts-default-cert", "istio-system"), "") + defaultCertYAML := intermediateCertificateYAML( + "istio-cacerts-default", "istio-cacerts-default-cert", "istio-system") + _, err = utils.KubectlApplyStdin(defaultCertYAML, "") Expect(err).NotTo(HaveOccurred()) - _, err = utils.KubectlApplyStdin(intermediateCertificateYAML("istio-cacerts-openshift-gateway", "istio-cacerts-og-cert", "openshift-ingress"), "") + gatewayCertYAML := intermediateCertificateYAML( + "istio-cacerts-openshift-gateway", "istio-cacerts-og-cert", "openshift-ingress") + _, err = utils.KubectlApplyStdin(gatewayCertYAML, "") Expect(err).NotTo(HaveOccurred()) By("waiting for intermediate secrets to be created") From f092f0a747466f925e9792a30bbda19892bad7ca Mon Sep 17 00:00:00 2001 From: Ian Miller Date: Tue, 2 Jun 2026 12:20:43 +0100 Subject: [PATCH 7/8] fix: address PR review feedback for SharedTrust controller - Add Secret watch on cacerts to self-heal from manual drift - Replace time.Sleep in CertManagerCRDExists with wait.PollUntilContextTimeout for cleaner cancellation semantics during operator shutdown - Document certFingerprint first-PEM-block assumption - Document hardcoded config values match RHOAI/OSSM3 layout Ref: RHAIENG-4901 Assisted-By: Claude (Anthropic AI) Signed-off-by: Ian Miller --- .../controller/sharedtrust_controller.go | 52 ++++++++++++++----- .../controller/sharedtrust_helpers.go | 3 ++ 2 files changed, 42 insertions(+), 13 deletions(-) diff --git a/kagenti-operator/internal/controller/sharedtrust_controller.go b/kagenti-operator/internal/controller/sharedtrust_controller.go index 510c01b7..17d406a3 100644 --- a/kagenti-operator/internal/controller/sharedtrust_controller.go +++ b/kagenti-operator/internal/controller/sharedtrust_controller.go @@ -27,6 +27,7 @@ import ( corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/wait" "k8s.io/client-go/discovery" "k8s.io/client-go/rest" "k8s.io/client-go/tools/record" @@ -78,6 +79,8 @@ var ( "mcp-system", "istio-system", "istio-ztunnel", } + // intermediateConfigs matches the standard RHOAI/OSSM3 layout. + // Values are intentionally hardcoded to match setup-kagenti.sh. intermediateConfigs = []struct { SecretName string Namespace string @@ -356,9 +359,29 @@ func (r *SharedTrustReconciler) SetupWithManager(mgr ctrl.Manager) error { }, ) + mapCacertsSecretToReconcile := handler.EnqueueRequestsFromMapFunc( + func(_ context.Context, obj client.Object) []reconcile.Request { + if obj.GetName() != CacertsSecretName { + return nil + } + for _, ic := range intermediateConfigs { + if obj.GetNamespace() == ic.Namespace { + return []reconcile.Request{{ + NamespacedName: types.NamespacedName{ + Name: "shared-trust", + Namespace: IstioSystemNamespace, + }, + }} + } + } + return nil + }, + ) + return ctrl.NewControllerManagedBy(mgr). Named("SharedTrust"). Watches(&cmv1.Certificate{}, mapToCertificateReconcile). + Watches(&corev1.Secret{}, mapCacertsSecretToReconcile). Complete(r) } @@ -369,30 +392,33 @@ func CertManagerCRDExists(cfg *rest.Config) bool { return false } - for attempt := range 3 { - if attempt > 0 { - delay := time.Duration(attempt) * 5 * time.Second - sharedTrustLogger.Info("Retrying cert-manager CRD discovery", "attempt", attempt+1, "delay", delay) - time.Sleep(delay) - } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + var found bool + err = wait.PollUntilContextTimeout(ctx, 5*time.Second, 30*time.Second, true, func(ctx context.Context) (bool, error) { resources, err := dc.ServerResourcesForGroupVersion("cert-manager.io/v1") if err != nil { - sharedTrustLogger.Info("cert-manager CRDs not found", "attempt", attempt+1, "error", err) - continue + sharedTrustLogger.Info("cert-manager CRDs not found, retrying", "error", err) + return false, nil } for _, r := range resources.APIResources { if r.Kind == "Certificate" { - sharedTrustLogger.Info("cert-manager CRDs detected: SharedTrust controller will start") - return true + found = true + return true, nil } } sharedTrustLogger.Info("cert-manager.io/v1 group exists but Certificate kind not found") - return false + return true, nil + }) + + if found { + sharedTrustLogger.Info("cert-manager CRDs detected: SharedTrust controller will start") + } else { + sharedTrustLogger.Info("cert-manager CRDs not found after retries: SharedTrust controller will not start") } - sharedTrustLogger.Info("cert-manager CRDs not found after retries: SharedTrust controller will not start") - return false + return found } diff --git a/kagenti-operator/internal/controller/sharedtrust_helpers.go b/kagenti-operator/internal/controller/sharedtrust_helpers.go index 095b3c3f..b2e8d301 100644 --- a/kagenti-operator/internal/controller/sharedtrust_helpers.go +++ b/kagenti-operator/internal/controller/sharedtrust_helpers.go @@ -37,6 +37,9 @@ func verifyCAFingerprint(rootCertPEM, intermediateCACertPEM []byte) (bool, error return rootFP == intFP, nil } +// certFingerprint returns the SHA256 of the first certificate's raw DER. +// Only the first PEM block is decoded; this matches cert-manager's layout +// where ca.crt contains a single issuing CA certificate. func certFingerprint(certPEM []byte) ([sha256.Size]byte, error) { block, _ := pem.Decode(certPEM) if block == nil { From 7d51a3313619c881786de264da763f3f77e3e965 Mon Sep 17 00:00:00 2001 From: Ian Miller Date: Tue, 2 Jun 2026 14:49:00 +0100 Subject: [PATCH 8/8] fix: use kubectl wait for namespace termination in e2e harness Replace polling loop with kubectl wait --for=delete when a namespace is still Terminating during re-deploy. With cert-manager installed, namespace finalizers take longer during teardown between test suites, causing a race where namespace creation silently fails. Also increase ensureNamespaceReady timeout from 60s to 120s. Ref: RHAIENG-4901 Assisted-By: Claude (Anthropic AI) Signed-off-by: Ian Miller --- .../controller/sharedtrust_controller.go | 2 +- kagenti-operator/test/utils/utils.go | 49 +++++++++++-------- 2 files changed, 30 insertions(+), 21 deletions(-) diff --git a/kagenti-operator/internal/controller/sharedtrust_controller.go b/kagenti-operator/internal/controller/sharedtrust_controller.go index 17d406a3..340ac909 100644 --- a/kagenti-operator/internal/controller/sharedtrust_controller.go +++ b/kagenti-operator/internal/controller/sharedtrust_controller.go @@ -396,7 +396,7 @@ func CertManagerCRDExists(cfg *rest.Config) bool { defer cancel() var found bool - err = wait.PollUntilContextTimeout(ctx, 5*time.Second, 30*time.Second, true, func(ctx context.Context) (bool, error) { + _ = wait.PollUntilContextTimeout(ctx, 5*time.Second, 30*time.Second, true, func(ctx context.Context) (bool, error) { resources, err := dc.ServerResourcesForGroupVersion("cert-manager.io/v1") if err != nil { sharedTrustLogger.Info("cert-manager CRDs not found, retrying", "error", err) diff --git a/kagenti-operator/test/utils/utils.go b/kagenti-operator/test/utils/utils.go index 2cc6b5a2..af4b84c0 100644 --- a/kagenti-operator/test/utils/utils.go +++ b/kagenti-operator/test/utils/utils.go @@ -477,7 +477,7 @@ func RestoreControllerArgs(namespace, deploy string, origArgs []string) error { // DeployController installs CRDs and deploys the controller-manager. func DeployController(namespace, img string) error { By("creating manager namespace") - if err := ensureNamespaceReady(namespace, 60*time.Second); err != nil { + if err := ensureNamespaceReady(namespace, 120*time.Second); err != nil { return fmt.Errorf("namespace %s not ready: %w", namespace, err) } @@ -507,27 +507,36 @@ func DeployController(namespace, img string) error { // ensureNamespaceReady creates the namespace, waiting for any Terminating // instance to be fully deleted first. A previous test's cleanup may have -// triggered namespace deletion that hasn't finished yet. +// triggered namespace deletion that hasn't finished yet. With cert-manager +// installed, namespace finalizers take longer during teardown. func ensureNamespaceReady(namespace string, timeout time.Duration) error { - deadline := time.Now().Add(timeout) - for time.Now().Before(deadline) { - cmd := exec.Command("kubectl", "get", "ns", namespace, "-o", "jsonpath={.status.phase}") - output, err := Run(cmd) - if err != nil { - // Namespace doesn't exist — create it - createCmd := exec.Command("kubectl", "create", "ns", namespace) - _, createErr := Run(createCmd) - return createErr - } - phase := strings.TrimSpace(output) - if phase == "Active" { - return nil - } - // Namespace is Terminating — wait for it to be fully deleted - By(fmt.Sprintf("waiting for namespace %s to finish terminating (phase=%s)", namespace, phase)) - time.Sleep(2 * time.Second) + cmd := exec.Command("kubectl", "get", "ns", namespace, "-o", "jsonpath={.status.phase}") + output, err := Run(cmd) + if err != nil { + // Namespace doesn't exist — create it + createCmd := exec.Command("kubectl", "create", "ns", namespace) + _, createErr := Run(createCmd) + return createErr } - return fmt.Errorf("timed out waiting for namespace %s to finish terminating", namespace) + + phase := strings.TrimSpace(output) + if phase == "Active" { + return nil + } + + // Namespace is Terminating — wait for it to be fully deleted before recreating. + By(fmt.Sprintf("waiting for namespace %s to finish terminating (phase=%s)", namespace, phase)) + cmd = exec.Command("kubectl", "wait", "--for=delete", + fmt.Sprintf("namespace/%s", namespace), + fmt.Sprintf("--timeout=%ds", int(timeout.Seconds())), + ) + if _, err := Run(cmd); err != nil { + return fmt.Errorf("timed out waiting for namespace %s to finish terminating: %w", namespace, err) + } + + createCmd := exec.Command("kubectl", "create", "ns", namespace) + _, createErr := Run(createCmd) + return createErr } // EnsureCertManagerWebhookReady waits for the cert-manager webhook to be responsive.