diff --git a/go.mod b/go.mod index 3281d1e..4040a30 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,77 @@ module github.com/openshift/controller-runtime-common go 1.24.0 + +require ( + github.com/go-logr/logr v1.4.3 + github.com/onsi/ginkgo/v2 v2.28.1 + github.com/onsi/gomega v1.39.1 + github.com/openshift/api v0.0.0-20260130140113-71e91db96ffc + github.com/openshift/library-go v0.0.0-20260203175009-3067285722b9 + k8s.io/apimachinery v0.34.3 + k8s.io/client-go v0.34.3 + k8s.io/utils v0.0.0-20260108192941-914a6e750570 + sigs.k8s.io/controller-runtime v0.22.5 +) + +require ( + github.com/Masterminds/semver/v3 v3.4.0 // indirect + github.com/beorn7/perks v1.0.1 // 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/v5 v5.9.11 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // 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-task/slim-sprig/v3 v3.0.0 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/google/btree v1.1.3 // indirect + github.com/google/gnostic-models v0.7.0 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 // indirect + github.com/google/uuid v1.6.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/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.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/prometheus/client_golang v1.23.2 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.66.1 // indirect + github.com/prometheus/procfs v0.16.1 // indirect + github.com/spf13/pflag v1.0.9 // indirect + github.com/x448/float16 v0.8.4 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.27.0 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/mod v0.32.0 // indirect + golang.org/x/net v0.49.0 // indirect + golang.org/x/oauth2 v0.30.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.40.0 // indirect + golang.org/x/term v0.39.0 // indirect + golang.org/x/text v0.33.0 // indirect + golang.org/x/time v0.9.0 // indirect + golang.org/x/tools v0.41.0 // indirect + gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect + google.golang.org/protobuf v1.36.8 // indirect + gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + k8s.io/api v0.34.3 // indirect + k8s.io/apiextensions-apiserver v0.34.3 // indirect + k8s.io/apiserver v0.34.3 // indirect + k8s.io/klog/v2 v2.130.1 // indirect + k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // 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/yaml v1.6.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..d581482 --- /dev/null +++ b/go.sum @@ -0,0 +1,232 @@ +github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +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/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +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 v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k= +github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ= +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/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs= +github.com/gkampitakis/ciinfo v0.3.2/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo= +github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZdC4M= +github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk= +github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01KS3zGE= +github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/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-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= +github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +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/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= +github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +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= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/vP9vJGqPwcdqsWjOt+V8J7+bTc= +github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE= +github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +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/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= +github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI= +github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE= +github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28= +github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg= +github.com/openshift/api v0.0.0-20260130140113-71e91db96ffc h1:CpPSvA2T+gYMmAIU8haCM00Ch+LBuXhusbKnSuPj6mo= +github.com/openshift/api v0.0.0-20260130140113-71e91db96ffc/go.mod h1:d5uzF0YN2nQQFA0jIEWzzOZ+edmo6wzlGLvx5Fhz4uY= +github.com/openshift/library-go v0.0.0-20260203175009-3067285722b9 h1:RJP39fxlIyoPPZB65mr1KdCdLwFbMsMXhsF37A2N+qA= +github.com/openshift/library-go v0.0.0-20260203175009-3067285722b9/go.mod h1:DCRz1EgdayEmr9b6KXKDL+DWBN0rGHu/VYADeHzPoOk= +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/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/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +go.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.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/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= +golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= +golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= +golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +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/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= +golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= +gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= +google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= +google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo= +gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/api v0.34.3 h1:D12sTP257/jSH2vHV2EDYrb16bS7ULlHpdNdNhEw2S4= +k8s.io/api v0.34.3/go.mod h1:PyVQBF886Q5RSQZOim7DybQjAbVs8g7gwJNhGtY5MBk= +k8s.io/apiextensions-apiserver v0.34.3 h1:p10fGlkDY09eWKOTeUSioxwLukJnm+KuDZdrW71y40g= +k8s.io/apiextensions-apiserver v0.34.3/go.mod h1:aujxvqGFRdb/cmXYfcRTeppN7S2XV/t7WMEc64zB5A0= +k8s.io/apimachinery v0.34.3 h1:/TB+SFEiQvN9HPldtlWOTp0hWbJ+fjU+wkxysf/aQnE= +k8s.io/apimachinery v0.34.3/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= +k8s.io/apiserver v0.34.3 h1:uGH1qpDvSiYG4HVFqc6A3L4CKiX+aBWDrrsxHYK0Bdo= +k8s.io/apiserver v0.34.3/go.mod h1:QPnnahMO5C2m3lm6fPW3+JmyQbvHZQ8uudAu/493P2w= +k8s.io/client-go v0.34.3 h1:wtYtpzy/OPNYf7WyNBTj3iUA0XaBHVqhv4Iv3tbrF5A= +k8s.io/client-go v0.34.3/go.mod h1:OxxeYagaP9Kdf78UrKLa3YZixMCfP6bgPwPwNBQBzpM= +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-20260108192941-914a6e750570 h1:JT4W8lsdrGENg9W+YwwdLJxklIuKWdRm+BC+xt33FOY= +k8s.io/utils v0.0.0-20260108192941-914a6e750570/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= +sigs.k8s.io/controller-runtime v0.22.5 h1:v3nfSUMowX/2WMp27J9slwGFyAt7IV0YwBxAkrUr0GE= +sigs.k8s.io/controller-runtime v0.22.5/go.mod h1:pc5SoYWnWI6I+cBHYYdZ7B6YHZVY5xNfll88JB+vniI= +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/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/pkg/testutils/envtest/envtest.go b/pkg/testutils/envtest/envtest.go new file mode 100644 index 0000000..714c2ff --- /dev/null +++ b/pkg/testutils/envtest/envtest.go @@ -0,0 +1,73 @@ +/* +Copyright 2026 Red Hat, Inc. + +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 envtest provides utilities for working with the controller-runtime envtest package. +package envtest + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// GetGoModuleDirectory returns the directory path for a go module. +// It uses 'go list -m' to find the module directory, which will be in the +// module cache if the module is not vendored. +// +// Example: +// +// moduleDir, err := GetGoModuleDirectory("github.com/openshift/api") +// if err != nil { +// return err +// } +func GetGoModuleDirectory(ctx context.Context, module string) (string, error) { + cmd := exec.CommandContext(ctx, "go", "list", "-m", "-f", "{{.Dir}}", module) + output, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("failed to get module directory for %s: %w", module, err) + } + moduleDir := strings.TrimSpace(string(output)) + if moduleDir == "" { + return "", fmt.Errorf("empty module directory returned for %s", module) + } + return moduleDir, nil +} + +// GetCRDManifestsPath returns the full path to the CRD manifests directory within a go module. +// It combines GetGoModuleDirectory with the provided path segments. +// +// Example: +// +// crdPath, err := GetCRDManifestsPath(ctx, "github.com/openshift/api", "config", "v1", "zz_generated.crd-manifests") +// if err != nil { +// return err +// } +func GetCRDManifestsPath(ctx context.Context, module string, pathSegments ...string) (string, error) { + moduleDir, err := GetGoModuleDirectory(ctx, module) + if err != nil { + return "", err + } + + path := filepath.Join(append([]string{moduleDir}, pathSegments...)...) + if _, err := os.Stat(path); err != nil { + return "", fmt.Errorf("path %s does not exist: %w", path, err) + } + + return path, nil +} diff --git a/pkg/tls/controller.go b/pkg/tls/controller.go new file mode 100644 index 0000000..672277e --- /dev/null +++ b/pkg/tls/controller.go @@ -0,0 +1,141 @@ +/* +Copyright 2026 Red Hat, Inc. + +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 tls + +import ( + "context" + "fmt" + "reflect" + + "github.com/go-logr/logr" + configv1 "github.com/openshift/api/config/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/predicate" + "sigs.k8s.io/controller-runtime/pkg/reconcile" +) + +// SecurityProfileWatcher watches the APIServer object for TLS profile changes +// and triggers a graceful shutdown when the profile changes. +type SecurityProfileWatcher struct { + client.Client + + // InitialTLSProfileSpec is the TLS profile spec that was configured when the operator started. + InitialTLSProfileSpec configv1.TLSProfileSpec + + // OnProfileChange is a function that will be called when the TLS profile changes. + // It receives the old (initial) and new (current) TLS profile specs. + // This allows the caller to make decisions based on the actual profile changes. + // + // The most common use case for this callback is + // to trigger a graceful shutdown of the operator + // to make it pick up the new configuration. + // + // Example: + // + // // Create a context that can be cancelled when there is a need to shut down the manager. + // ctx, cancel := context.WithCancel(ctrl.SetupSignalHandler()) + // defer cancel() + // + // watcher := &SecurityProfileWatcher{ + // OnProfileChange: func(old, new configv1.TLSProfileSpec) { + // logger.Infof("TLS profile has changed, initiating a shutdown to reload it. %q: %+v, %q: %+v", + // "old profile", old, + // "new profile", new, + // ) + // // Cancel the outer context to trigger a graceful shutdown of the manager. + // cancel() + // }, + // } + OnProfileChange func(oldTLSProfileSpec, newTLSProfileSpec configv1.TLSProfileSpec) +} + +// SetupWithManager sets up the controller with the Manager. +func (r *SecurityProfileWatcher) SetupWithManager(mgr ctrl.Manager) error { + if err := ctrl.NewControllerManagedBy(mgr). + Named("tlssecurityprofilewatcher"). + For(&configv1.APIServer{}, builder.WithPredicates( + predicate.Funcs{ + // Only watch the "cluster" APIServer object. + CreateFunc: func(e event.CreateEvent) bool { + return e.Object.GetName() == APIServerName + }, + UpdateFunc: func(e event.UpdateEvent) bool { + return e.ObjectNew.GetName() == APIServerName + }, + DeleteFunc: func(e event.DeleteEvent) bool { + return e.Object.GetName() == APIServerName + }, + GenericFunc: func(e event.GenericEvent) bool { + return e.Object.GetName() == APIServerName + }, + }, + )). + // Override the default log constructor as it makes the logs very chatty. + WithLogConstructor(func(_ *reconcile.Request) logr.Logger { + return mgr.GetLogger().WithValues( + "controller", "tlssecurityprofilewatcher", + ) + }). + Complete(r); err != nil { + return fmt.Errorf("could not set up controller for TLS security profile watcher: %w", err) + } + + return nil +} + +// Reconcile watches for changes to the APIServer TLS profile and triggers a shutdown +// when the profile changes from the initial configuration. +func (r *SecurityProfileWatcher) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + logger := log.FromContext(ctx, "name", req.Name) + + logger.V(1).Info("Reconciling APIServer TLS profile") + defer logger.V(1).Info("Finished reconciling APIServer TLS profile") + + // Fetch the APIServer object. + apiServer := &configv1.APIServer{} + if err := r.Get(ctx, req.NamespacedName, apiServer); err != nil { + if apierrors.IsNotFound(err) { + // If the APIServer object is not found, we don't need to do anything. + // This could happen if the object was deleted. + return ctrl.Result{}, nil + } + + return ctrl.Result{}, fmt.Errorf("failed to get APIServer %s: %w", req.NamespacedName.String(), err) + } + + // Get the current TLS profile spec. + currentTLSProfileSpec, err := GetTLSProfileSpec(apiServer.Spec.TLSSecurityProfile) + if err != nil { + return ctrl.Result{}, fmt.Errorf("failed to get TLS profile from APIServer %s: %w", req.NamespacedName.String(), err) + } + + // Compare the current TLS profile spec with the initial one. + if tlsProfileChanged := !reflect.DeepEqual(r.InitialTLSProfileSpec, currentTLSProfileSpec); tlsProfileChanged { + // TLS profile has changed, invoke the callback if it is set. + if r.OnProfileChange != nil { + r.OnProfileChange(r.InitialTLSProfileSpec, currentTLSProfileSpec) + } + } + + // No need to requeue, as the callback will handle further actions. + return ctrl.Result{}, nil +} diff --git a/pkg/tls/controller_test.go b/pkg/tls/controller_test.go new file mode 100644 index 0000000..adce41b --- /dev/null +++ b/pkg/tls/controller_test.go @@ -0,0 +1,294 @@ +/* +Copyright 2026 Red Hat, Inc. + +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 tls + +import ( + "context" + "sync" + "sync/atomic" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + configv1 "github.com/openshift/api/config/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/manager" +) + +var _ = Describe("SecurityProfileWatcher controller", func() { + var ( + mgrCancel context.CancelFunc + mgrDone chan struct{} + mgr manager.Manager + apiServer *configv1.APIServer + callbackOnce sync.Once + callbackCnt atomic.Int32 + oldProfileSpec configv1.TLSProfileSpec + newProfileSpec configv1.TLSProfileSpec + profileChangeMu sync.Mutex + ) + + BeforeEach(func() { + var err error + + // Create the APIServer object. + apiServer = &configv1.APIServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: APIServerName, + }, + Spec: configv1.APIServerSpec{ + TLSSecurityProfile: &configv1.TLSSecurityProfile{ + Type: configv1.TLSProfileIntermediateType, + }, + }, + } + Expect(k8sClient.Create(ctx, apiServer)).To(Succeed()) + + // Create a new manager for each test. + mgr, err = ctrl.NewManager(cfg, managerOptions) + Expect(err).NotTo(HaveOccurred()) + + // Reset callback tracking. + callbackOnce = sync.Once{} + callbackCnt.Store(0) + profileChangeMu = sync.Mutex{} + oldProfileSpec = configv1.TLSProfileSpec{} + newProfileSpec = configv1.TLSProfileSpec{} + }) + + AfterEach(func() { + // Stop the manager if it's running. + if mgrCancel != nil { + mgrCancel() + <-mgrDone + } + + // Clean up the APIServer object. + Expect(k8sClient.Delete(ctx, apiServer)).To(Succeed()) + }) + + startManager := func(initialProfile configv1.TLSProfileSpec) { + var mgrCtx context.Context + mgrCtx, mgrCancel = context.WithCancel(ctx) + mgrDone = make(chan struct{}) + + // Set up the TLS security profile watcher controller. + watcher := &SecurityProfileWatcher{ + Client: mgr.GetClient(), + InitialTLSProfileSpec: initialProfile, + OnProfileChange: func(oldTLSProfileSpec, newTLSProfileSpec configv1.TLSProfileSpec) { + // Capture the profiles for verification in tests. + profileChangeMu.Lock() + oldProfileSpec = oldTLSProfileSpec + newProfileSpec = newTLSProfileSpec + profileChangeMu.Unlock() + + // Use sync.Once to ensure we only count the first call, + // similar to how context.CancelFunc is idempotent. + callbackOnce.Do(func() { + callbackCnt.Add(1) + }) + // Always cancel the context (this is idempotent). + mgrCancel() + }, + } + Expect(watcher.SetupWithManager(mgr)).To(Succeed()) + + // Start the manager in a goroutine. + go func() { + defer GinkgoRecover() + defer close(mgrDone) + err := mgr.Start(mgrCtx) + Expect(err).NotTo(HaveOccurred()) + }() + + // Wait for the manager to be ready. + Eventually(func() bool { + return mgr.GetCache().WaitForCacheSync(mgrCtx) + }).Should(BeTrue()) + } + + Context("when the TLS profile does not change", func() { + It("should not invoke the callback", func() { + // Start with the intermediate profile (same as what's configured). + initialProfile, err := GetTLSProfileSpec(apiServer.Spec.TLSSecurityProfile) + Expect(err).NotTo(HaveOccurred()) + startManager(initialProfile) + + // Wait a bit and verify callback was not invoked. + Consistently(callbackCnt.Load).Should(Equal(int32(0)), "callback count should be 0 (callback not invoked)") + }) + + It("should not invoke the callback when switching to custom profile with identical settings", func() { + // Start with the intermediate profile. + initialProfile, err := GetTLSProfileSpec(apiServer.Spec.TLSSecurityProfile) + Expect(err).NotTo(HaveOccurred()) + startManager(initialProfile) + + // Get the intermediate profile spec to replicate it exactly. + intermediateSpec := *configv1.TLSProfiles[configv1.TLSProfileIntermediateType] + + // Update the APIServer to use a custom profile with identical settings. + apiServer.Spec.TLSSecurityProfile = &configv1.TLSSecurityProfile{ + Type: configv1.TLSProfileCustomType, + Custom: &configv1.CustomTLSProfile{ + TLSProfileSpec: intermediateSpec, + }, + } + Expect(k8sClient.Update(ctx, apiServer)).To(Succeed()) + + // Verify callback was NOT invoked since settings are identical. + Consistently(callbackCnt.Load).Should(Equal(int32(0)), "callback count should be 0 (callback not invoked for identical settings)") + }) + + It("should not invoke the callback when switching from custom profile to predefined profile with identical settings", func() { + // Get the intermediate profile spec to replicate it exactly. + intermediateSpec := *configv1.TLSProfiles[configv1.TLSProfileIntermediateType] + + // Update the APIServer to use a custom profile with identical settings to intermediate. + apiServer.Spec.TLSSecurityProfile = &configv1.TLSSecurityProfile{ + Type: configv1.TLSProfileCustomType, + Custom: &configv1.CustomTLSProfile{ + TLSProfileSpec: intermediateSpec, + }, + } + Expect(k8sClient.Update(ctx, apiServer)).To(Succeed()) + + // Start with the custom profile. + initialProfile, err := GetTLSProfileSpec(apiServer.Spec.TLSSecurityProfile) + Expect(err).NotTo(HaveOccurred()) + startManager(initialProfile) + + // Switch to the intermediate profile (which has identical settings). + apiServer.Spec.TLSSecurityProfile = &configv1.TLSSecurityProfile{ + Type: configv1.TLSProfileIntermediateType, + } + Expect(k8sClient.Update(ctx, apiServer)).To(Succeed()) + + // Verify callback was NOT invoked since settings are identical. + Consistently(callbackCnt.Load).Should(Equal(int32(0)), "callback count should be 0 (callback not invoked for identical settings)") + }) + + }) + + Context("when the TLS profile changes", func() { + It("should invoke the callback when MinTLSVersion changes", func() { + // Start with the intermediate profile. + initialProfile, err := GetTLSProfileSpec(apiServer.Spec.TLSSecurityProfile) + Expect(err).NotTo(HaveOccurred()) + startManager(initialProfile) + + // Update the APIServer to use the Modern profile (which has TLS 1.3). + apiServer.Spec.TLSSecurityProfile = &configv1.TLSSecurityProfile{ + Type: configv1.TLSProfileModernType, + } + Expect(k8sClient.Update(ctx, apiServer)).To(Succeed()) + + // Verify callback was invoked. + Eventually(callbackCnt.Load).Should(Equal(int32(1)), "callback count should be 1 (callback invoked)") + + // Verify the callback received the correct profiles. + profileChangeMu.Lock() + defer profileChangeMu.Unlock() + Expect(oldProfileSpec).To(Equal(initialProfile), "callback should receive the initial profile as old") + modernProfile, err := GetTLSProfileSpec(apiServer.Spec.TLSSecurityProfile) + Expect(err).NotTo(HaveOccurred()) + Expect(newProfileSpec).To(Equal(modernProfile), "callback should receive the current profile as new") + }) + + It("should invoke the callback when switching to custom profile with different TLS settings", func() { + // Start with the intermediate profile. + initialProfile, err := GetTLSProfileSpec(apiServer.Spec.TLSSecurityProfile) + Expect(err).NotTo(HaveOccurred()) + startManager(initialProfile) + + // Define the custom profile we'll switch to. + customSpec := configv1.TLSProfileSpec{ + Ciphers: []string{"TLS_AES_128_GCM_SHA256", "TLS_AES_256_GCM_SHA384"}, + MinTLSVersion: configv1.VersionTLS13, + } + + // Update the APIServer to use a custom profile. + apiServer.Spec.TLSSecurityProfile = &configv1.TLSSecurityProfile{ + Type: configv1.TLSProfileCustomType, + Custom: &configv1.CustomTLSProfile{ + TLSProfileSpec: customSpec, + }, + } + Expect(k8sClient.Update(ctx, apiServer)).To(Succeed()) + + // Verify callback was invoked. + Eventually(callbackCnt.Load).Should(Equal(int32(1)), "callback count should be 1 (callback invoked)") + + // Verify the callback received the correct profiles. + profileChangeMu.Lock() + defer profileChangeMu.Unlock() + Expect(oldProfileSpec).To(Equal(initialProfile), "callback should receive the initial profile as old") + Expect(newProfileSpec).To(Equal(customSpec), "callback should receive the custom profile as new") + }) + + It("should invoke the callback when switching from custom to predefined profile with different TLS settings", func() { + // Update the APIServer to use a custom profile first. + apiServer.Spec.TLSSecurityProfile = &configv1.TLSSecurityProfile{ + Type: configv1.TLSProfileCustomType, + Custom: &configv1.CustomTLSProfile{ + TLSProfileSpec: configv1.TLSProfileSpec{ + Ciphers: []string{"TLS_AES_128_GCM_SHA256"}, + MinTLSVersion: configv1.VersionTLS13, + }, + }, + } + Expect(k8sClient.Update(ctx, apiServer)).To(Succeed()) + + // Start with the custom profile. + initialProfile, err := GetTLSProfileSpec(apiServer.Spec.TLSSecurityProfile) + Expect(err).NotTo(HaveOccurred()) + startManager(initialProfile) + + // Switch back to the intermediate profile. + apiServer.Spec.TLSSecurityProfile = &configv1.TLSSecurityProfile{ + Type: configv1.TLSProfileIntermediateType, + } + Expect(k8sClient.Update(ctx, apiServer)).To(Succeed()) + + // Verify callback was invoked. + Eventually(callbackCnt.Load).Should(Equal(int32(1)), "callback count should be 1 (callback invoked)") + }) + }) + + Context("when the profile is nil initially", func() { + It("should use the default profile and invoke the callback when changes are detected", func() { + // Update APIServer to have nil profile. + apiServer.Spec.TLSSecurityProfile = nil + Expect(k8sClient.Update(ctx, apiServer)).To(Succeed()) + + // Start with the default (nil -> intermediate) profile. + initialProfile, err := GetTLSProfileSpec(nil) + Expect(err).NotTo(HaveOccurred()) + startManager(initialProfile) + + // Update the APIServer to use the Modern profile. + apiServer.Spec.TLSSecurityProfile = &configv1.TLSSecurityProfile{ + Type: configv1.TLSProfileModernType, + } + Expect(k8sClient.Update(ctx, apiServer)).To(Succeed()) + + // Verify callback was invoked. + Eventually(callbackCnt.Load).Should(Equal(int32(1)), "callback count should be 1 (callback invoked)") + }) + }) +}) diff --git a/pkg/tls/suite_test.go b/pkg/tls/suite_test.go new file mode 100644 index 0000000..78f30ce --- /dev/null +++ b/pkg/tls/suite_test.go @@ -0,0 +1,107 @@ +/* +Copyright 2026 Red Hat, Inc. + +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 tls + +import ( + "context" + "os" + "path/filepath" + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + configv1 "github.com/openshift/api/config/v1" + testutilsenvtest "github.com/openshift/controller-runtime-common/pkg/testutils/envtest" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/rest" + "k8s.io/utils/ptr" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/config" + "sigs.k8s.io/controller-runtime/pkg/envtest" + "sigs.k8s.io/controller-runtime/pkg/envtest/komega" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + "sigs.k8s.io/controller-runtime/pkg/metrics/server" +) + +var ( + cfg *rest.Config + k8sClient client.Client + testEnv *envtest.Environment + testScheme *runtime.Scheme + ctx = context.Background() + managerOptions ctrl.Options +) + +func TestSuite(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "TLS Suite") +} + +var _ = BeforeSuite(func() { + logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true))) + + By("bootstrapping test environment") + configV1CRDPath, errPath := testutilsenvtest.GetCRDManifestsPath(ctx, "github.com/openshift/api", "config", "v1", "zz_generated.crd-manifests") + Expect(errPath).NotTo(HaveOccurred()) + + testEnv = &envtest.Environment{ + CRDDirectoryPaths: []string{ + configV1CRDPath, + }, + ErrorIfCRDPathMissing: true, + + // Automatically download envtest binaries (etcd, kube-apiserver) if not present. + // Uses OpenShift's envtest releases which include OpenShift-specific patches. + DownloadBinaryAssets: true, + BinaryAssetsDirectory: filepath.Join(os.TempDir(), "kubebuilder-envtest"), + DownloadBinaryAssetsIndexURL: "https://raw.githubusercontent.com/openshift/api/master/envtest-releases.yaml", + } + + testScheme = scheme.Scheme + Expect(configv1.Install(testScheme)).To(Succeed()) + + managerOptions = ctrl.Options{ + Scheme: testScheme, + Metrics: server.Options{ + BindAddress: "0", + }, + Controller: config.Controller{ + SkipNameValidation: ptr.To(true), + }, + } + + var err error + cfg, err = testEnv.Start() + Expect(err).NotTo(HaveOccurred()) + Expect(cfg).NotTo(BeNil()) + + k8sClient, err = client.New(cfg, client.Options{Scheme: testScheme}) + Expect(err).NotTo(HaveOccurred()) + Expect(k8sClient).NotTo(BeNil()) + + komega.SetClient(k8sClient) + komega.SetContext(ctx) +}) + +var _ = AfterSuite(func() { + By("tearing down the test environment") + err := testEnv.Stop() + Expect(err).NotTo(HaveOccurred()) +}) diff --git a/pkg/tls/tls.go b/pkg/tls/tls.go new file mode 100644 index 0000000..6b33bd1 --- /dev/null +++ b/pkg/tls/tls.go @@ -0,0 +1,155 @@ +/* +Copyright 2026 Red Hat, Inc. + +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 tls provides utilities for working with OpenShift TLS profiles. +package tls + +import ( + "context" + "crypto/tls" + "errors" + "fmt" + + configv1 "github.com/openshift/api/config/v1" + libgocrypto "github.com/openshift/library-go/pkg/crypto" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +const ( + // APIServerName is the name of the APIServer resource in the cluster. + APIServerName = "cluster" +) + +var ( + // ErrCustomProfileNil is returned when a custom TLS profile is specified but the Custom field is nil. + ErrCustomProfileNil = errors.New("custom TLS profile specified but Custom field is nil") + + // DefaultTLSCiphers are the default TLS ciphers for API servers. + DefaultTLSCiphers = configv1.TLSProfiles[configv1.TLSProfileIntermediateType].Ciphers //nolint:gochecknoglobals + // DefaultMinTLSVersion is the default minimum TLS version for API servers. + DefaultMinTLSVersion = configv1.TLSProfiles[configv1.TLSProfileIntermediateType].MinTLSVersion //nolint:gochecknoglobals +) + +// FetchAPIServerTLSProfile fetches the TLS profile spec configured in APIServer. +// If no profile is configured, the default profile is returned. +func FetchAPIServerTLSProfile(ctx context.Context, k8sClient client.Client) (configv1.TLSProfileSpec, error) { + apiServer := &configv1.APIServer{} + key := client.ObjectKey{Name: APIServerName} + + if err := k8sClient.Get(ctx, key, apiServer); err != nil { + return configv1.TLSProfileSpec{}, fmt.Errorf("failed to get APIServer %q: %w", key.String(), err) + } + + profile, err := GetTLSProfileSpec(apiServer.Spec.TLSSecurityProfile) + if err != nil { + return configv1.TLSProfileSpec{}, fmt.Errorf("failed to get TLS profile from APIServer %q: %w", key.String(), err) + } + + return profile, nil +} + +// GetTLSProfileSpec returns TLSProfileSpec for the given profile. +// If no profile is configured, the default profile is returned. +func GetTLSProfileSpec(profile *configv1.TLSSecurityProfile) (configv1.TLSProfileSpec, error) { + // Define the default profile (at the time of writing, this is the intermediate profile). + defaultProfile := *configv1.TLSProfiles[configv1.TLSProfileIntermediateType] + // If the profile is nil or the type is empty, return the default profile. + if profile == nil || profile.Type == "" { + return defaultProfile, nil + } + + // Get the profile type. + profileType := profile.Type + + // If the profile type is not custom, return the profile from the map. + if profileType != configv1.TLSProfileCustomType { + if tlsConfig, ok := configv1.TLSProfiles[profileType]; ok { + return *tlsConfig, nil + } + + // If the profile type is not found, return the default profile. + return defaultProfile, nil + } + + if profile.Custom == nil { + // If the custom profile is nil, return an error. + return configv1.TLSProfileSpec{}, ErrCustomProfileNil + } + + // Return the custom profile spec. + return profile.Custom.TLSProfileSpec, nil +} + +// NewTLSConfigFromProfile returns a function that configures a tls.Config based on the provided TLSProfileSpec, +// along with any cipher names from the profile that are not supported by the library-go crypto package. +// The returned function is intended to be used with controller-runtime's TLSOpts. +// +// Note: CipherSuites are only set when MinVersion is below TLS 1.3, as Go's TLS 1.3 implementation +// does not allow configuring cipher suites - all TLS 1.3 ciphers are always enabled. +// See: https://github.com/golang/go/issues/29349 +func NewTLSConfigFromProfile(profile configv1.TLSProfileSpec) (tlsConfig func(*tls.Config), unsupportedCiphers []string) { + minVersion := libgocrypto.TLSVersionOrDie(string(profile.MinTLSVersion)) + cipherSuites, unsupportedCiphers := cipherCodes(profile.Ciphers) + + return func(tlsConf *tls.Config) { + tlsConf.MinVersion = minVersion + // TODO: add curve preferences from profile once https://github.com/openshift/api/pull/2583 merges. + // tlsConf.CurvePreferences <<<<<< profile.Curves + + // TLS 1.3 cipher suites are not configurable in Go (https://github.com/golang/go/issues/29349), so only set CipherSuites accordingly. + // TODO: revisit this once we get an answer on the best way to handle this here: + // https://docs.google.com/document/d/1cMc9E8psHfnoK06ntR8kHSWB8d3rMtmldhnmM4nImjs/edit?disco=AAABu_nPcYg + if minVersion != tls.VersionTLS13 { + tlsConf.CipherSuites = cipherSuites + } + }, unsupportedCiphers +} + +// cipherCode returns the TLS cipher code for an OpenSSL or IANA cipher name. +// Returns 0 if the cipher is not supported. +func cipherCode(cipher string) uint16 { + // First try as IANA name directly. + if code, err := libgocrypto.CipherSuite(cipher); err == nil { + return code + } + + // Try converting from OpenSSL name to IANA name. + ianaCiphers := libgocrypto.OpenSSLToIANACipherSuites([]string{cipher}) + if len(ianaCiphers) == 1 { + if code, err := libgocrypto.CipherSuite(ianaCiphers[0]); err == nil { + return code + } + } + + // Return 0 if the cipher is not supported. + return 0 +} + +// cipherCodes converts a list of cipher names (OpenSSL or IANA format) to their uint16 codes. +// Returns the converted codes and a list of any unsupported cipher names. +func cipherCodes(ciphers []string) (codes []uint16, unsupportedCiphers []string) { + for _, cipher := range ciphers { + code := cipherCode(cipher) + if code == 0 { + unsupportedCiphers = append(unsupportedCiphers, cipher) + continue + } + + codes = append(codes, code) + } + + return codes, unsupportedCiphers +} diff --git a/pkg/tls/tls_test.go b/pkg/tls/tls_test.go new file mode 100644 index 0000000..973350e --- /dev/null +++ b/pkg/tls/tls_test.go @@ -0,0 +1,294 @@ +/* +Copyright 2026 Red Hat, Inc. + +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 tls + +import ( + "crypto/tls" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + configv1 "github.com/openshift/api/config/v1" +) + +var _ = Describe("GetTLSProfileSpec", func() { + Context("when profile is nil", func() { + It("should return the default profile", func() { + profile, err := GetTLSProfileSpec(nil) + Expect(err).NotTo(HaveOccurred()) + Expect(profile).To(Equal(*configv1.TLSProfiles[configv1.TLSProfileIntermediateType])) + }) + }) + + Context("when profile type is empty", func() { + It("should return the default profile", func() { + profile, err := GetTLSProfileSpec(&configv1.TLSSecurityProfile{}) + Expect(err).NotTo(HaveOccurred()) + Expect(profile).To(Equal(*configv1.TLSProfiles[configv1.TLSProfileIntermediateType])) + }) + }) + + Context("when profile type is Intermediate", func() { + It("should return the intermediate profile", func() { + profile, err := GetTLSProfileSpec(&configv1.TLSSecurityProfile{ + Type: configv1.TLSProfileIntermediateType, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(profile).To(Equal(*configv1.TLSProfiles[configv1.TLSProfileIntermediateType])) + }) + }) + + Context("when profile type is Modern", func() { + It("should return the modern profile", func() { + profile, err := GetTLSProfileSpec(&configv1.TLSSecurityProfile{ + Type: configv1.TLSProfileModernType, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(profile).To(Equal(*configv1.TLSProfiles[configv1.TLSProfileModernType])) + }) + }) + + Context("when profile type is Old", func() { + It("should return the old profile", func() { + profile, err := GetTLSProfileSpec(&configv1.TLSSecurityProfile{ + Type: configv1.TLSProfileOldType, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(profile).To(Equal(*configv1.TLSProfiles[configv1.TLSProfileOldType])) + }) + }) + + Context("when profile type is Custom", func() { + It("should return the custom profile spec", func() { + customSpec := configv1.TLSProfileSpec{ + Ciphers: []string{"ECDHE-ECDSA-CHACHA20-POLY1305", "ECDHE-RSA-CHACHA20-POLY1305"}, + MinTLSVersion: configv1.VersionTLS13, + } + profile, err := GetTLSProfileSpec(&configv1.TLSSecurityProfile{ + Type: configv1.TLSProfileCustomType, + Custom: &configv1.CustomTLSProfile{ + TLSProfileSpec: customSpec, + }, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(profile).To(Equal(customSpec)) + }) + + It("should return an error when Custom field is nil", func() { + _, err := GetTLSProfileSpec(&configv1.TLSSecurityProfile{ + Type: configv1.TLSProfileCustomType, + Custom: nil, + }) + Expect(err).To(MatchError(ErrCustomProfileNil)) + }) + }) + + Context("when profile type is unknown", func() { + It("should return the default profile", func() { + profile, err := GetTLSProfileSpec(&configv1.TLSSecurityProfile{ + Type: configv1.TLSProfileType("UnknownType"), + }) + Expect(err).NotTo(HaveOccurred()) + Expect(profile).To(Equal(*configv1.TLSProfiles[configv1.TLSProfileIntermediateType])) + }) + }) +}) + +var _ = Describe("cipherCode", func() { + Context("when cipher is an IANA name", func() { + It("should return the correct cipher code", func() { + Expect(cipherCode("TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256")).To(Equal(tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256)) + Expect(cipherCode("TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384")).To(Equal(tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384)) + Expect(cipherCode("TLS_AES_128_GCM_SHA256")).To(Equal(tls.TLS_AES_128_GCM_SHA256)) + }) + }) + + Context("when cipher is an OpenSSL name", func() { + It("should return the correct cipher code", func() { + Expect(cipherCode("ECDHE-RSA-AES128-GCM-SHA256")).To(Equal(tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256)) + Expect(cipherCode("ECDHE-ECDSA-AES256-GCM-SHA384")).To(Equal(tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384)) + Expect(cipherCode("ECDHE-ECDSA-CHACHA20-POLY1305")).To(Equal(tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256)) + }) + }) + + Context("when cipher is not supported", func() { + It("should return 0", func() { + Expect(cipherCode("UNSUPPORTED-CIPHER")).To(Equal(uint16(0))) + Expect(cipherCode("")).To(Equal(uint16(0))) + Expect(cipherCode("DHE-RSA-AES128-GCM-SHA256")).To(Equal(uint16(0))) // DHE not supported by Go + }) + }) +}) + +var _ = Describe("cipherCodes", func() { + Context("when all ciphers are valid", func() { + It("should return all cipher codes with no unsupported ciphers", func() { + ciphers := []string{ + "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256", + "ECDHE-ECDSA-AES256-GCM-SHA384", + } + codes, unsupported := cipherCodes(ciphers) + Expect(codes).To(HaveLen(2)) + Expect(codes).To(ContainElement(tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256)) + Expect(codes).To(ContainElement(tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384)) + Expect(unsupported).To(BeEmpty()) + }) + }) + + Context("when some ciphers are invalid", func() { + It("should return valid cipher codes and list unsupported ciphers", func() { + ciphers := []string{ + "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256", + "UNSUPPORTED-CIPHER", + "ECDHE-ECDSA-AES256-GCM-SHA384", + "ANOTHER-INVALID", + } + codes, unsupported := cipherCodes(ciphers) + Expect(codes).To(HaveLen(2)) + Expect(codes).To(ContainElement(tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256)) + Expect(codes).To(ContainElement(tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384)) + Expect(unsupported).To(ConsistOf("UNSUPPORTED-CIPHER", "ANOTHER-INVALID")) + }) + }) + + Context("when all ciphers are invalid", func() { + It("should return empty codes and all ciphers as unsupported", func() { + ciphers := []string{"INVALID-1", "INVALID-2"} + codes, unsupported := cipherCodes(ciphers) + Expect(codes).To(BeEmpty()) + Expect(unsupported).To(ConsistOf("INVALID-1", "INVALID-2")) + }) + }) + + Context("when cipher list is empty", func() { + It("should return empty slices", func() { + codes, unsupported := cipherCodes([]string{}) + Expect(codes).To(BeEmpty()) + Expect(unsupported).To(BeEmpty()) + }) + }) + + Context("when cipher list is nil", func() { + It("should return empty slices", func() { + codes, unsupported := cipherCodes(nil) + Expect(codes).To(BeEmpty()) + Expect(unsupported).To(BeEmpty()) + }) + }) +}) + +var _ = Describe("NewTLSConfigFromProfile", func() { + Context("when MinTLSVersion is TLS 1.2", func() { + It("should set MinVersion and CipherSuites", func() { + profile := configv1.TLSProfileSpec{ + MinTLSVersion: configv1.VersionTLS12, + Ciphers: []string{ + "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256", + "ECDHE-ECDSA-AES256-GCM-SHA384", + }, + } + + tlsConfigFn, unsupported := NewTLSConfigFromProfile(profile) + Expect(unsupported).To(BeEmpty()) + + tlsConf := &tls.Config{} + tlsConfigFn(tlsConf) + + Expect(tlsConf.MinVersion).To(Equal(uint16(tls.VersionTLS12))) + Expect(tlsConf.CipherSuites).To(HaveLen(2)) + Expect(tlsConf.CipherSuites).To(ContainElement(tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256)) + Expect(tlsConf.CipherSuites).To(ContainElement(tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384)) + }) + }) + + Context("when MinTLSVersion is TLS 1.3", func() { + It("should set MinVersion but NOT set CipherSuites (TLS 1.3 ciphers are not configurable)", func() { + profile := configv1.TLSProfileSpec{ + MinTLSVersion: configv1.VersionTLS13, + Ciphers: []string{ + "TLS_AES_128_GCM_SHA256", + "TLS_AES_256_GCM_SHA384", + }, + } + + tlsConfigFn, unsupported := NewTLSConfigFromProfile(profile) + Expect(unsupported).To(BeEmpty()) + + tlsConf := &tls.Config{} + tlsConfigFn(tlsConf) + + Expect(tlsConf.MinVersion).To(Equal(uint16(tls.VersionTLS13))) + // CipherSuites should NOT be set for TLS 1.3 + Expect(tlsConf.CipherSuites).To(BeNil()) + }) + }) + + Context("when profile contains unsupported ciphers", func() { + It("should return unsupported ciphers list", func() { + profile := configv1.TLSProfileSpec{ + MinTLSVersion: configv1.VersionTLS12, + Ciphers: []string{ + "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256", + "DHE-RSA-AES128-GCM-SHA256", // Not supported by Go + "INVALID-CIPHER", + }, + } + + tlsConfigFn, unsupported := NewTLSConfigFromProfile(profile) + Expect(unsupported).To(ConsistOf("DHE-RSA-AES128-GCM-SHA256", "INVALID-CIPHER")) + + tlsConf := &tls.Config{} + tlsConfigFn(tlsConf) + + Expect(tlsConf.MinVersion).To(Equal(uint16(tls.VersionTLS12))) + Expect(tlsConf.CipherSuites).To(HaveLen(1)) + Expect(tlsConf.CipherSuites).To(ContainElement(tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256)) + }) + }) + + Context("when using the Intermediate profile", func() { + It("should configure TLS correctly", func() { + profile := *configv1.TLSProfiles[configv1.TLSProfileIntermediateType] + + tlsConfigFn, unsupported := NewTLSConfigFromProfile(profile) + + tlsConf := &tls.Config{} + tlsConfigFn(tlsConf) + + Expect(tlsConf.MinVersion).To(Equal(uint16(tls.VersionTLS12))) + // Intermediate profile uses TLS 1.2, so CipherSuites should be set + Expect(tlsConf.CipherSuites).NotTo(BeEmpty()) + // Some ciphers in the Intermediate profile may not be supported by Go + // (e.g., DHE ciphers), so we just check that we have some ciphers + Expect(len(tlsConf.CipherSuites) + len(unsupported)).To(Equal(len(profile.Ciphers))) + }) + }) + + Context("when using the Modern profile", func() { + It("should configure TLS correctly with TLS 1.3", func() { + profile := *configv1.TLSProfiles[configv1.TLSProfileModernType] + + tlsConfigFn, _ := NewTLSConfigFromProfile(profile) + + tlsConf := &tls.Config{} + tlsConfigFn(tlsConf) + + Expect(tlsConf.MinVersion).To(Equal(uint16(tls.VersionTLS13))) + // Modern profile uses TLS 1.3, so CipherSuites should NOT be set + Expect(tlsConf.CipherSuites).To(BeNil()) + }) + }) +})