diff --git a/go.mod b/go.mod index 757bbbe04..74bd7897f 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/BurntSushi/toml v1.5.0 github.com/Masterminds/semver/v3 v3.3.1 github.com/blang/semver/v4 v4.0.0 + github.com/cert-manager/cert-manager v1.17.1 github.com/containerd/containerd v1.7.27 github.com/containers/image/v5 v5.35.0 github.com/fsnotify/fsnotify v1.9.0 @@ -45,7 +46,7 @@ require ( require ( k8s.io/component-helpers v0.32.3 // indirect - k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f // indirect + k8s.io/kube-openapi v0.0.0-20241212222426-2c72e554b1e7 // indirect ) require ( @@ -61,7 +62,7 @@ require ( github.com/Microsoft/hcsshim v0.12.9 // indirect github.com/VividCortex/ewma v1.2.0 // indirect github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d // indirect - github.com/antlr4-go/antlr/v4 v4.13.0 // indirect + github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect @@ -96,7 +97,7 @@ require ( github.com/evanphx/json-patch v5.9.0+incompatible // indirect github.com/evanphx/json-patch/v5 v5.9.11 // indirect github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f // indirect - github.com/fatih/color v1.15.0 // indirect + github.com/fatih/color v1.16.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fxamacker/cbor/v2 v2.7.0 // indirect github.com/go-errors/errors v1.4.2 // indirect @@ -128,7 +129,7 @@ require ( github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/mux v1.8.1 // indirect - github.com/gorilla/websocket v1.5.0 // indirect + github.com/gorilla/websocket v1.5.3 // indirect github.com/gosuri/uitable v0.0.4 // indirect github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1 // indirect @@ -218,7 +219,7 @@ require ( go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 // indirect go.opentelemetry.io/otel v1.34.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.32.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0 // indirect go.opentelemetry.io/otel/metric v1.34.0 // indirect go.opentelemetry.io/otel/sdk v1.34.0 // indirect go.opentelemetry.io/otel/trace v1.34.0 // indirect @@ -243,7 +244,8 @@ require ( k8s.io/controller-manager v0.32.3 // indirect k8s.io/kubectl v0.32.3 // indirect oras.land/oras-go v1.2.5 // indirect - sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.0 // indirect + sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.1 // indirect + sigs.k8s.io/gateway-api v1.1.0 // indirect sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect sigs.k8s.io/kustomize/api v0.18.0 // indirect sigs.k8s.io/kustomize/kyaml v0.18.1 // indirect diff --git a/go.sum b/go.sum index 1f5e6cf91..134d05b8c 100644 --- a/go.sum +++ b/go.sum @@ -34,8 +34,8 @@ github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d h1:licZJFw2RwpH github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d/go.mod h1:asat636LX7Bqt5lYEZ27JNDcqxfjdBQuJ/MM4CN/Lzo= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= -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/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= @@ -51,6 +51,8 @@ github.com/bshuster-repo/logrus-logstash-hook v1.0.0/go.mod h1:zsTqEiSzDgAa/8GZR github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/cert-manager/cert-manager v1.17.1 h1:Aig+lWMoLsmpGd9TOlTvO4t0Ah3D+/vGB37x/f+ZKt0= +github.com/cert-manager/cert-manager v1.17.1/go.mod h1:zeG4D+AdzqA7hFMNpYCJgcQ2VOfFNBa+Jzm3kAwiDU4= 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/chai2010/gettext-go v1.0.2 h1:1Lwwip6Q2QGsAdl/ZKPCwTe9fe0CjlUbqj5bFNSjIRk= @@ -140,8 +142,8 @@ github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjT github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f h1:Wl78ApPPB2Wvf/TIe2xdyJxTlb6obmF18d8QdkxNDu4= github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f/go.mod h1:OSYXu++VVOHnXeitef/D8n/6y4QV8uLHSFXX4NeXMGc= -github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs= -github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw= +github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= +github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/foxcpp/go-mockdns v1.1.0 h1:jI0rD8M0wuYAxL7r/ynTrCQQq0BVqfB99Vgk7DlmewI= @@ -260,8 +262,8 @@ github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyE github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= -github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= -github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gosuri/uitable v0.0.4 h1:IG2xLKRvErL3uhY6e1BylFzG+aJiwQviDDTfOKeKTpY= github.com/gosuri/uitable v0.0.4/go.mod h1:tKR86bXuXPZazfOTG1FIzvjIdXzd0mo4Vtn16vt0PJo= github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 h1:+ngKgrYPPJrOjhax5N+uePQ0Fh1Z7PheYoUI/0nzkPA= @@ -340,8 +342,8 @@ github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxU github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A= github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= -github.com/miekg/dns v1.1.58 h1:ca2Hdkz+cDg/7eNF6V56jjzuZ4aCAE+DbVkILdQWG/4= -github.com/miekg/dns v1.1.58/go.mod h1:Ypv+3b/KadlvW9vJfXOTf300O4UqaHFzFCuHz+rPkBY= +github.com/miekg/dns v1.1.62 h1:cN8OuEF1/x5Rq6Np+h1epln8OiyPWV+lROx9LxcGgIQ= +github.com/miekg/dns v1.1.62/go.mod h1:mvDlcItzm+br7MToIKqkglaGhlFMHJ9DTNNWONWXbNQ= github.com/miekg/pkcs11 v1.1.1 h1:Ugu9pdy6vAYku5DEpVWVFPYnzV+bxB+iRdbuFSu7TvU= github.com/miekg/pkcs11 v1.1.1/go.mod h1:XsNlhZGX73bx86s2hdc/FuaLm2CPZJemRLMA+WTFxgs= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= @@ -528,12 +530,12 @@ github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9dec github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.etcd.io/bbolt v1.4.0 h1:TU77id3TnN/zKr7CO/uk+fBCwF2jGcMuw2B/FMAzYIk= go.etcd.io/bbolt v1.4.0/go.mod h1:AsD+OCi/qPN1giOX1aiLAha3o1U8rAz65bvN4j0sRuk= -go.etcd.io/etcd/api/v3 v3.5.16 h1:WvmyJVbjWqK4R1E+B12RRHz3bRGy9XVfh++MgbN+6n0= -go.etcd.io/etcd/api/v3 v3.5.16/go.mod h1:1P4SlIP/VwkDmGo3OlOD7faPeP8KDIFhqvciH5EfN28= -go.etcd.io/etcd/client/pkg/v3 v3.5.16 h1:ZgY48uH6UvB+/7R9Yf4x574uCO3jIx0TRDyetSfId3Q= -go.etcd.io/etcd/client/pkg/v3 v3.5.16/go.mod h1:V8acl8pcEK0Y2g19YlOV9m9ssUe6MgiDSobSoaBAM0E= -go.etcd.io/etcd/client/v3 v3.5.16 h1:sSmVYOAHeC9doqi0gv7v86oY/BTld0SEFGaxsU9eRhE= -go.etcd.io/etcd/client/v3 v3.5.16/go.mod h1:X+rExSGkyqxvu276cr2OwPLBaeqFu1cIl4vmRjAD/50= +go.etcd.io/etcd/api/v3 v3.5.17 h1:cQB8eb8bxwuxOilBpMJAEo8fAONyrdXTHUNcMd8yT1w= +go.etcd.io/etcd/api/v3 v3.5.17/go.mod h1:d1hvkRuXkts6PmaYk2Vrgqbv7H4ADfAKhyJqHNLJCB4= +go.etcd.io/etcd/client/pkg/v3 v3.5.17 h1:XxnDXAWq2pnxqx76ljWwiQ9jylbpC4rvkAeRVOUKKVw= +go.etcd.io/etcd/client/pkg/v3 v3.5.17/go.mod h1:4DqK1TKacp/86nJk4FLQqo6Mn2vvQFBmruW3pP14H/w= +go.etcd.io/etcd/client/v3 v3.5.17 h1:o48sINNeWz5+pjy/Z0+HKpj/xSnBkuVhVvXkjEXbqZY= +go.etcd.io/etcd/client/v3 v3.5.17/go.mod h1:j2d4eXTHWkT2ClBgnnEPm/Wuu7jsqku41v9DZ3OtjQo= go.mongodb.org/mongo-driver v1.14.0 h1:P98w8egYRjYe3XDjxhYJagTokP/H6HzlsnojRgZRd80= go.mongodb.org/mongo-driver v1.14.0/go.mod h1:Vzb0Mk/pa7e6cWw85R4F/endUC3u0U9jGcNU603k65c= go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= @@ -560,8 +562,8 @@ go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.32.0 h1:t/Q go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.32.0/go.mod h1:Rl61tySSdcOJWoEgYZVtmnKdA0GeKrSqkHC1t+91CH8= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 h1:Vh5HayB/0HHfOQA7Ctx69E/Y/DcQSMPpKANYVMQ7fBA= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0/go.mod h1:cpgtDBaqD/6ok/UG0jT15/uKjAY8mRA53diogHBg3UI= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.32.0 h1:9kV11HXBHZAvuPUZxmMWrH8hZn/6UnHX4K0mu36vNsU= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.32.0/go.mod h1:JyA0FHXe22E1NeNiHmVp7kFHglnexDQ7uRWDiiJ1hKQ= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0 h1:5pojmb1U1AogINhN3SurB+zm/nIcusopeBNp42f45QM= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0/go.mod h1:57gTHJSE5S1tqg+EKsLPlTWhpHMsWlVmer+LA926XiA= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.33.0 h1:wpMfgF8E1rkrT1Z6meFh1NDtownE9Ii3n3X2GJYjsaU= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.33.0/go.mod h1:wAy0T/dUbs468uOlkT31xjvqQgEVXv58BRFWEgn5v/0= go.opentelemetry.io/otel/exporters/prometheus v0.54.0 h1:rFwzp68QMgtzu9PgP3jm9XaMICI6TsofWWPcBDKwlsU= @@ -795,8 +797,8 @@ k8s.io/controller-manager v0.32.3 h1:jBxZnQ24k6IMeWLyxWZmpa3QVS7ww+osAIzaUY/jqyc k8s.io/controller-manager v0.32.3/go.mod h1:out1L3DZjE/p7JG0MoMMIaQGWIkt3c+pKaswqSHgKsI= 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-20241105132330-32ad38e42d3f h1:GA7//TjRY9yWGy1poLzYYJJ4JRdzg3+O6e8I+e+8T5Y= -k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f/go.mod h1:R/HEjbvWI0qdfb8viZUeVZm0X6IZnxAydC7YU42CMw4= +k8s.io/kube-openapi v0.0.0-20241212222426-2c72e554b1e7 h1:hcha5B1kVACrLujCKLbr8XWMxCxzQx42DY8QKYJrDLg= +k8s.io/kube-openapi v0.0.0-20241212222426-2c72e554b1e7/go.mod h1:GewRfANuJ70iYzvn+i4lezLDAFzvjxZYK1gn1lWcfas= k8s.io/kubectl v0.32.3 h1:VMi584rbboso+yjfv0d8uBHwwxbC438LKq+dXd5tOAI= k8s.io/kubectl v0.32.3/go.mod h1:6Euv2aso5GKzo/UVMacV6C7miuyevpfI91SvBvV9Zdg= k8s.io/kubernetes v1.32.3 h1:2A58BlNME8NwsMawmnM6InYo3Jf35Nw5G79q46kXwoA= @@ -807,10 +809,12 @@ oras.land/oras-go v1.2.5 h1:XpYuAwAb0DfQsunIyMfeET92emK8km3W4yEzZvUbsTo= oras.land/oras-go v1.2.5/go.mod h1:PuAwRShRZCsZb7g8Ar3jKKQR/2A/qN+pkYxIOd/FAoo= oras.land/oras-go/v2 v2.5.0 h1:o8Me9kLY74Vp5uw07QXPiitjsw7qNXi8Twd+19Zf02c= oras.land/oras-go/v2 v2.5.0/go.mod h1:z4eisnLP530vwIOUOJeBIj0aGI0L1C3d53atvCBqZHg= -sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.0 h1:CPT0ExVicCzcpeN4baWEV2ko2Z/AsiZgEdwgcfwLgMo= -sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.0/go.mod h1:Ve9uj1L+deCXFrPOk1LpFXqTg7LCFzFso6PA48q/XZw= +sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.1 h1:uOuSLOMBWkJH0TWa9X6l+mj5nZdm6Ay6Bli8HL8rNfk= +sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.1/go.mod h1:Ve9uj1L+deCXFrPOk1LpFXqTg7LCFzFso6PA48q/XZw= sigs.k8s.io/controller-runtime v0.20.4 h1:X3c+Odnxz+iPTRobG4tp092+CvBU9UK0t/bRf+n0DGU= sigs.k8s.io/controller-runtime v0.20.4/go.mod h1:xg2XB0K5ShQzAgsoujxuKN4LNXR2LfwwHsPj7Iaw+XY= +sigs.k8s.io/gateway-api v1.1.0 h1:DsLDXCi6jR+Xz8/xd0Z1PYl2Pn0TyaFMOPPZIj4inDM= +sigs.k8s.io/gateway-api v1.1.0/go.mod h1:ZH4lHrL2sDi0FHZ9jjneb8kKnGzFWyrTya35sWUTrRs= sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE= sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/kustomize/api v0.18.0 h1:hTzp67k+3NEVInwz5BHyzc9rGxIauoXferXyjv5lWPo= diff --git a/internal/operator-controller/features/features.go b/internal/operator-controller/features/features.go index e8faa07f0..a999d610e 100644 --- a/internal/operator-controller/features/features.go +++ b/internal/operator-controller/features/features.go @@ -13,6 +13,7 @@ const ( // Ex: SomeFeature featuregate.Feature = "SomeFeature" PreflightPermissions featuregate.Feature = "PreflightPermissions" SingleOwnNamespaceInstallSupport featuregate.Feature = "SingleOwnNamespaceInstallSupport" + WebhookSupport featuregate.Feature = "WebhookSupport" ) var operatorControllerFeatureGates = map[featuregate.Feature]featuregate.FeatureSpec{ @@ -32,6 +33,15 @@ var operatorControllerFeatureGates = map[featuregate.Feature]featuregate.Feature PreRelease: featuregate.Alpha, LockToDefault: false, }, + + // WebhookSupport enables support for installing + // registry+v1 cluster extensions that include validating, + // mutating, and/or conversion webhooks + WebhookSupport: { + Default: false, + PreRelease: featuregate.Alpha, + LockToDefault: false, + }, } var OperatorControllerFeatureGate featuregate.MutableFeatureGate = featuregate.NewFeatureGate() diff --git a/internal/operator-controller/rukpak/convert/registryv1.go b/internal/operator-controller/rukpak/convert/registryv1.go index 1417239fd..aa17a08e2 100644 --- a/internal/operator-controller/rukpak/convert/registryv1.go +++ b/internal/operator-controller/rukpak/convert/registryv1.go @@ -20,8 +20,10 @@ import ( "github.com/operator-framework/api/pkg/operators/v1alpha1" "github.com/operator-framework/operator-registry/alpha/property" + "github.com/operator-framework/operator-controller/internal/operator-controller/features" registry "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/operator-registry" "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/render" + "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/render/certproviders" "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/render/generators" "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/render/validators" ) @@ -220,6 +222,10 @@ var PlainConverter = Converter{ generators.BundleCRDGenerator, generators.BundleAdditionalResourcesGenerator, generators.BundleCSVDeploymentGenerator, + generators.BundleValidatingWebhookResourceGenerator, + generators.BundleMutatingWebhookResourceGenerator, + generators.BundleWebhookServiceResourceGenerator, + generators.CertProviderResourceGenerator, }, }, } @@ -257,11 +263,16 @@ func (c Converter) Convert(rv1 render.RegistryV1, installNamespace string, targe return nil, fmt.Errorf("apiServiceDefintions are not supported") } - if len(rv1.CSV.Spec.WebhookDefinitions) > 0 { + if !features.OperatorControllerFeatureGate.Enabled(features.WebhookSupport) && len(rv1.CSV.Spec.WebhookDefinitions) > 0 { return nil, fmt.Errorf("webhookDefinitions are not supported") } - objs, err := c.BundleRenderer.Render(rv1, installNamespace, render.WithTargetNamespaces(targetNamespaces...)) + objs, err := c.BundleRenderer.Render( + rv1, + installNamespace, + render.WithTargetNamespaces(targetNamespaces...), + render.WithCertificateProvider(certproviders.CertManagerCertificateProvider{}), + ) if err != nil { return nil, err } diff --git a/internal/operator-controller/rukpak/convert/registryv1_test.go b/internal/operator-controller/rukpak/convert/registryv1_test.go index dffb15cb4..49296ae56 100644 --- a/internal/operator-controller/rukpak/convert/registryv1_test.go +++ b/internal/operator-controller/rukpak/convert/registryv1_test.go @@ -18,12 +18,14 @@ import ( "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" + featuregatetesting "k8s.io/component-base/featuregate/testing" "k8s.io/utils/ptr" "sigs.k8s.io/controller-runtime/pkg/client" "github.com/operator-framework/api/pkg/operators/v1alpha1" "github.com/operator-framework/operator-registry/alpha/property" + "github.com/operator-framework/operator-controller/internal/operator-controller/features" "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/convert" "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/render" "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/render/validators" @@ -560,6 +562,52 @@ func TestRegistryV1SuiteGenerateNoWebhooks(t *testing.T) { require.Nil(t, plainBundle) } +func TestRegistryV1SuiteGenerateWebhooks_WebhookSupportFGEnabled(t *testing.T) { + featuregatetesting.SetFeatureGateDuringTest(t, features.OperatorControllerFeatureGate, features.WebhookSupport, true) + t.Log("RegistryV1 Suite Convert") + t.Log("It should generate objects successfully based on target namespaces") + + t.Log("It should enforce limitations") + t.Log("It should allow bundles with webhooks") + t.Log("By creating a registry v1 bundle") + registryv1Bundle := render.RegistryV1{ + PackageName: "testPkg", + CRDs: []apiextensionsv1.CustomResourceDefinition{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "fake-webhook.package-with-webhooks.io", + }, + }, + }, + CSV: MakeCSV( + WithName("testCSV"), + WithInstallModeSupportFor(v1alpha1.InstallModeTypeAllNamespaces), + WithOwnedCRDs( + v1alpha1.CRDDescription{ + Name: "fake-webhook.package-with-webhooks.io", + }, + ), + WithStrategyDeploymentSpecs( + v1alpha1.StrategyDeploymentSpec{ + Name: "some-deployment", + }, + ), + WithWebhookDefinitions( + v1alpha1.WebhookDescription{ + Type: v1alpha1.ConversionWebhook, + ConversionCRDs: []string{"fake-webhook.package-with-webhooks.io"}, + DeploymentName: "some-deployment", + }, + ), + ), + } + + t.Log("By converting to plain") + plainBundle, err := convert.PlainConverter.Convert(registryv1Bundle, installNamespace, []string{metav1.NamespaceAll}) + require.NoError(t, err) + require.NotNil(t, plainBundle) +} + func TestRegistryV1SuiteGenerateNoAPISerciceDefinitions(t *testing.T) { t.Log("RegistryV1 Suite Convert") t.Log("It should generate objects successfully based on target namespaces") diff --git a/internal/operator-controller/rukpak/render/certprovider.go b/internal/operator-controller/rukpak/render/certprovider.go new file mode 100644 index 000000000..f3920a4c7 --- /dev/null +++ b/internal/operator-controller/rukpak/render/certprovider.go @@ -0,0 +1,78 @@ +package render + +import ( + "strings" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/util" +) + +// CertificateProvider encapsulate the creation and modification of object for certificate provisioning +// in Kubernetes by vendors such as CertManager or the OpenshiftServiceCA operator +type CertificateProvider interface { + InjectCABundle(obj client.Object, cfg CertificateProvisionerConfig) error + AdditionalObjects(cfg CertificateProvisionerConfig) ([]unstructured.Unstructured, error) + GetCertSecretInfo(cfg CertificateProvisionerConfig) CertSecretInfo +} + +// CertSecretInfo contains describes the certificate secret resource information such as name and +// certificate and private key keys +type CertSecretInfo struct { + SecretName string + CertificateKey string + PrivateKeyKey string +} + +// CertificateProvisionerConfig contains the necessary information for a CertificateProvider +// to correctly generate and modify object for certificate injection and automation +type CertificateProvisionerConfig struct { + WebhookServiceName string + CertName string + Namespace string + CertProvider CertificateProvider +} + +// CertificateProvisioner uses a CertificateProvider to modify and generate objects based on its +// CertificateProvisionerConfig +type CertificateProvisioner CertificateProvisionerConfig + +func (c CertificateProvisioner) InjectCABundle(obj client.Object) error { + if c.CertProvider == nil { + return nil + } + return c.CertProvider.InjectCABundle(obj, CertificateProvisionerConfig(c)) +} + +func (c CertificateProvisioner) AdditionalObjects() ([]unstructured.Unstructured, error) { + if c.CertProvider == nil { + return nil, nil + } + return c.CertProvider.AdditionalObjects(CertificateProvisionerConfig(c)) +} + +func (c CertificateProvisioner) GetCertSecretInfo() *CertSecretInfo { + if c.CertProvider == nil { + return nil + } + info := c.CertProvider.GetCertSecretInfo(CertificateProvisionerConfig(c)) + return &info +} + +func CertProvisionerFor(deploymentName string, opts Options) CertificateProvisioner { + // maintaining parity with OLMv0 naming + // See https://github.com/operator-framework/operator-lifecycle-manager/blob/658a6a60de8315f055f54aa7e50771ee4daa8983/pkg/controller/install/webhook.go#L254 + webhookServiceName := util.ObjectNameForBaseAndSuffix(strings.ReplaceAll(deploymentName, ".", "-"), "service") + + // maintaining parity with cert secret name in OLMv0 + // See https://github.com/operator-framework/operator-lifecycle-manager/blob/658a6a60de8315f055f54aa7e50771ee4daa8983/pkg/controller/install/certresources.go#L151 + certName := util.ObjectNameForBaseAndSuffix(webhookServiceName, "cert") + + return CertificateProvisioner{ + CertProvider: opts.CertificateProvider, + WebhookServiceName: webhookServiceName, + Namespace: opts.InstallNamespace, + CertName: certName, + } +} diff --git a/internal/operator-controller/rukpak/render/certprovider_test.go b/internal/operator-controller/rukpak/render/certprovider_test.go new file mode 100644 index 000000000..3005cfd73 --- /dev/null +++ b/internal/operator-controller/rukpak/render/certprovider_test.go @@ -0,0 +1,122 @@ +package render_test + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/render" + . "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/util/testing" +) + +func Test_CertificateProvisioner_WithoutCertProvider(t *testing.T) { + provisioner := &render.CertificateProvisioner{ + WebhookServiceName: "webhook", + CertName: "cert", + Namespace: "namespace", + CertProvider: nil, + } + + require.NoError(t, provisioner.InjectCABundle(&corev1.Secret{})) + require.Nil(t, provisioner.GetCertSecretInfo()) + + objs, err := provisioner.AdditionalObjects() + require.Nil(t, objs) + require.NoError(t, err) +} + +func Test_CertificateProvisioner_WithCertProvider(t *testing.T) { + fakeProvider := &FakeCertProvider{ + InjectCABundleFn: func(obj client.Object, cfg render.CertificateProvisionerConfig) error { + obj.SetName("some-name") + return nil + }, + AdditionalObjectsFn: func(cfg render.CertificateProvisionerConfig) ([]unstructured.Unstructured, error) { + return []unstructured.Unstructured{*ToUnstructuredT(t, &corev1.Secret{ + TypeMeta: metav1.TypeMeta{Kind: "Secret", APIVersion: corev1.SchemeGroupVersion.String()}, + })}, nil + }, + GetCertSecretInfoFn: func(cfg render.CertificateProvisionerConfig) render.CertSecretInfo { + return render.CertSecretInfo{ + SecretName: "some-secret", + PrivateKeyKey: "some-key", + CertificateKey: "another-key", + } + }, + } + provisioner := &render.CertificateProvisioner{ + WebhookServiceName: "webhook", + CertName: "cert", + Namespace: "namespace", + CertProvider: fakeProvider, + } + + svc := &corev1.Service{} + require.NoError(t, provisioner.InjectCABundle(svc)) + require.Equal(t, "some-name", svc.GetName()) + + objs, err := provisioner.AdditionalObjects() + require.NoError(t, err) + require.Equal(t, []unstructured.Unstructured{*ToUnstructuredT(t, &corev1.Secret{ + TypeMeta: metav1.TypeMeta{Kind: "Secret", APIVersion: corev1.SchemeGroupVersion.String()}, + })}, objs) + + require.Equal(t, &render.CertSecretInfo{ + SecretName: "some-secret", + PrivateKeyKey: "some-key", + CertificateKey: "another-key", + }, provisioner.GetCertSecretInfo()) +} + +func Test_CertificateProvisioner_Errors(t *testing.T) { + fakeProvider := &FakeCertProvider{ + InjectCABundleFn: func(obj client.Object, cfg render.CertificateProvisionerConfig) error { + return fmt.Errorf("some error") + }, + AdditionalObjectsFn: func(cfg render.CertificateProvisionerConfig) ([]unstructured.Unstructured, error) { + return nil, fmt.Errorf("some other error") + }, + } + provisioner := &render.CertificateProvisioner{ + WebhookServiceName: "webhook", + CertName: "cert", + Namespace: "namespace", + CertProvider: fakeProvider, + } + + err := provisioner.InjectCABundle(&corev1.Service{}) + require.Error(t, err) + require.Contains(t, err.Error(), "some error") + + objs, err := provisioner.AdditionalObjects() + require.Error(t, err) + require.Contains(t, err.Error(), "some other error") + require.Nil(t, objs) +} + +func Test_CertProvisionerFor(t *testing.T) { + fakeProvider := &FakeCertProvider{} + prov := render.CertProvisionerFor("my.deployment.thing", render.Options{ + InstallNamespace: "my-namespace", + CertificateProvider: fakeProvider, + }) + + require.Equal(t, prov.CertProvider, fakeProvider) + require.Equal(t, "my-deployment-thing-service", prov.WebhookServiceName) + require.Equal(t, "my-deployment-thing-service-cert", prov.CertName) + require.Equal(t, "my-namespace", prov.Namespace) +} + +func Test_CertProvisionerFor_ExtraLargeName_MoreThan63Chars(t *testing.T) { + prov := render.CertProvisionerFor("my.object.thing.has.a.really.really.really.really.really.long.name", render.Options{}) + + require.Len(t, prov.WebhookServiceName, 63) + require.Len(t, prov.CertName, 63) + require.Equal(t, "my-object-thing-has-a-really-really-really-really-reall-service", prov.WebhookServiceName) + require.Equal(t, "my-object-thing-has-a-really-really-really-really-reall-se-cert", prov.CertName) +} diff --git a/internal/operator-controller/rukpak/render/certproviders/certmanager.go b/internal/operator-controller/rukpak/render/certproviders/certmanager.go new file mode 100644 index 000000000..f8b7d06a1 --- /dev/null +++ b/internal/operator-controller/rukpak/render/certproviders/certmanager.go @@ -0,0 +1,111 @@ +package certproviders + +import ( + "errors" + "fmt" + + certmanagerv1 "github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1" + certmanagermetav1 "github.com/cert-manager/cert-manager/pkg/apis/meta/v1" + admissionregistrationv1 "k8s.io/api/admissionregistration/v1" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/render" + "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/util" +) + +const ( + certManagerInjectCAAnnotation = "cert-manager.io/inject-ca-from" +) + +var _ render.CertificateProvider = (*CertManagerCertificateProvider)(nil) + +type CertManagerCertificateProvider struct{} + +func (p CertManagerCertificateProvider) InjectCABundle(obj client.Object, cfg render.CertificateProvisionerConfig) error { + switch obj.(type) { + case *admissionregistrationv1.ValidatingWebhookConfiguration: + p.addCAInjectionAnnotation(obj, cfg.Namespace, cfg.CertName) + case *admissionregistrationv1.MutatingWebhookConfiguration: + p.addCAInjectionAnnotation(obj, cfg.Namespace, cfg.CertName) + case *apiextensionsv1.CustomResourceDefinition: + p.addCAInjectionAnnotation(obj, cfg.Namespace, cfg.CertName) + } + return nil +} + +func (p CertManagerCertificateProvider) GetCertSecretInfo(cfg render.CertificateProvisionerConfig) render.CertSecretInfo { + return render.CertSecretInfo{ + SecretName: cfg.CertName, + PrivateKeyKey: "tls.key", + CertificateKey: "tls.crt", + } +} + +func (p CertManagerCertificateProvider) AdditionalObjects(cfg render.CertificateProvisionerConfig) ([]unstructured.Unstructured, error) { + var ( + objs []unstructured.Unstructured + errs []error + ) + + issuer := &certmanagerv1.Issuer{ + TypeMeta: metav1.TypeMeta{ + APIVersion: certmanagerv1.SchemeGroupVersion.String(), + Kind: "Issuer", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: util.ObjectNameForBaseAndSuffix(cfg.CertName, "selfsigned-issuer"), + Namespace: cfg.Namespace, + }, + Spec: certmanagerv1.IssuerSpec{ + IssuerConfig: certmanagerv1.IssuerConfig{ + SelfSigned: &certmanagerv1.SelfSignedIssuer{}, + }, + }, + } + issuerObj, err := util.ToUnstructured(issuer) + if err != nil { + errs = append(errs, err) + } else { + objs = append(objs, *issuerObj) + } + + certificate := &certmanagerv1.Certificate{ + TypeMeta: metav1.TypeMeta{ + APIVersion: certmanagerv1.SchemeGroupVersion.String(), + Kind: "Certificate", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: cfg.CertName, + Namespace: cfg.Namespace, + }, + Spec: certmanagerv1.CertificateSpec{ + SecretName: cfg.CertName, + Usages: []certmanagerv1.KeyUsage{certmanagerv1.UsageServerAuth}, + DNSNames: []string{fmt.Sprintf("%s.%s.svc", cfg.WebhookServiceName, cfg.Namespace)}, + IssuerRef: certmanagermetav1.ObjectReference{ + Name: issuer.GetName(), + }, + }, + } + certObj, err := util.ToUnstructured(certificate) + if err != nil { + errs = append(errs, err) + } else { + objs = append(objs, *certObj) + } + + if len(errs) > 0 { + return nil, errors.Join(errs...) + } + return objs, nil +} + +func (p CertManagerCertificateProvider) addCAInjectionAnnotation(obj client.Object, certNamespace string, certName string) { + injectionAnnotation := map[string]string{ + certManagerInjectCAAnnotation: fmt.Sprintf("%s/%s", certNamespace, certName), + } + obj.SetAnnotations(util.MergeMaps(obj.GetAnnotations(), injectionAnnotation)) +} diff --git a/internal/operator-controller/rukpak/render/certproviders/certmanager_test.go b/internal/operator-controller/rukpak/render/certproviders/certmanager_test.go new file mode 100644 index 000000000..08570d12f --- /dev/null +++ b/internal/operator-controller/rukpak/render/certproviders/certmanager_test.go @@ -0,0 +1,158 @@ +package certproviders_test + +import ( + "testing" + + certmanagerv1 "github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1" + certmanagermetav1 "github.com/cert-manager/cert-manager/pkg/apis/meta/v1" + "github.com/stretchr/testify/require" + admissionregistrationv1 "k8s.io/api/admissionregistration/v1" + corev1 "k8s.io/api/core/v1" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/render" + "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/render/certproviders" + "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/util" +) + +func Test_CertManagerProvider_InjectCABundle(t *testing.T) { + for _, tc := range []struct { + name string + obj client.Object + cfg render.CertificateProvisionerConfig + expectedObj client.Object + }{ + { + name: "injects certificate annotation in validating webhook configuration", + obj: &admissionregistrationv1.ValidatingWebhookConfiguration{}, + cfg: render.CertificateProvisionerConfig{ + WebhookServiceName: "webhook-service", + Namespace: "namespace", + CertName: "cert-name", + }, + expectedObj: &admissionregistrationv1.ValidatingWebhookConfiguration{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + "cert-manager.io/inject-ca-from": "namespace/cert-name", + }, + }, + }, + }, + { + name: "injects certificate annotation in mutating webhook configuration", + obj: &admissionregistrationv1.MutatingWebhookConfiguration{}, + cfg: render.CertificateProvisionerConfig{ + WebhookServiceName: "webhook-service", + Namespace: "namespace", + CertName: "cert-name", + }, + expectedObj: &admissionregistrationv1.MutatingWebhookConfiguration{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + "cert-manager.io/inject-ca-from": "namespace/cert-name", + }, + }, + }, + }, + { + name: "injects certificate annotation in custom resource definition", + obj: &apiextensionsv1.CustomResourceDefinition{}, + cfg: render.CertificateProvisionerConfig{ + WebhookServiceName: "webhook-service", + Namespace: "namespace", + CertName: "cert-name", + }, + expectedObj: &apiextensionsv1.CustomResourceDefinition{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + "cert-manager.io/inject-ca-from": "namespace/cert-name", + }, + }, + }, + }, + { + name: "ignores other objects", + obj: &corev1.Service{}, + cfg: render.CertificateProvisionerConfig{ + WebhookServiceName: "webhook-service", + Namespace: "namespace", + CertName: "cert-name", + }, + expectedObj: &corev1.Service{}, + }, + } { + t.Run(tc.name, func(t *testing.T) { + certProvier := certproviders.CertManagerCertificateProvider{} + require.NoError(t, certProvier.InjectCABundle(tc.obj, tc.cfg)) + require.Equal(t, tc.expectedObj, tc.obj) + }) + } +} + +func Test_CertManagerProvider_AdditionalObjects(t *testing.T) { + certProvier := certproviders.CertManagerCertificateProvider{} + objs, err := certProvier.AdditionalObjects(render.CertificateProvisionerConfig{ + WebhookServiceName: "webhook-service", + Namespace: "namespace", + CertName: "cert-name", + }) + require.NoError(t, err) + require.Equal(t, []unstructured.Unstructured{ + toUnstructured(t, &certmanagerv1.Issuer{ + TypeMeta: metav1.TypeMeta{ + APIVersion: certmanagerv1.SchemeGroupVersion.String(), + Kind: "Issuer", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "cert-name-selfsigned-issuer", + Namespace: "namespace", + }, + Spec: certmanagerv1.IssuerSpec{ + IssuerConfig: certmanagerv1.IssuerConfig{ + SelfSigned: &certmanagerv1.SelfSignedIssuer{}, + }, + }, + }), + toUnstructured(t, &certmanagerv1.Certificate{ + TypeMeta: metav1.TypeMeta{ + APIVersion: certmanagerv1.SchemeGroupVersion.String(), + Kind: "Certificate", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "cert-name", + Namespace: "namespace", + }, + Spec: certmanagerv1.CertificateSpec{ + SecretName: "cert-name", + Usages: []certmanagerv1.KeyUsage{certmanagerv1.UsageServerAuth}, + DNSNames: []string{"webhook-service.namespace.svc"}, + IssuerRef: certmanagermetav1.ObjectReference{ + Name: "cert-name-selfsigned-issuer", + }, + }, + }), + }, objs) +} + +func Test_CertManagerProvider_GetCertSecretInfo(t *testing.T) { + certProvier := certproviders.CertManagerCertificateProvider{} + certInfo := certProvier.GetCertSecretInfo(render.CertificateProvisionerConfig{ + WebhookServiceName: "webhook-service", + Namespace: "namespace", + CertName: "cert-name", + }) + require.Equal(t, render.CertSecretInfo{ + SecretName: "cert-name", + PrivateKeyKey: "tls.key", + CertificateKey: "tls.crt", + }, certInfo) +} + +func toUnstructured(t *testing.T, obj client.Object) unstructured.Unstructured { + u, err := util.ToUnstructured(obj) + require.NoError(t, err) + return *u +} diff --git a/internal/operator-controller/rukpak/render/generators/generators.go b/internal/operator-controller/rukpak/render/generators/generators.go index dfc73a2ab..5e702c492 100644 --- a/internal/operator-controller/rukpak/render/generators/generators.go +++ b/internal/operator-controller/rukpak/render/generators/generators.go @@ -3,20 +3,39 @@ package generators import ( "cmp" "fmt" + "maps" + "slices" + "strconv" "strings" + admissionregistrationv1 "k8s.io/api/admissionregistration/v1" + appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" rbacv1 "k8s.io/api/rbac/v1" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + "k8s.io/apimachinery/pkg/util/intstr" "k8s.io/apimachinery/pkg/util/sets" "k8s.io/utils/ptr" "sigs.k8s.io/controller-runtime/pkg/client" + "github.com/operator-framework/api/pkg/operators/v1alpha1" registrybundle "github.com/operator-framework/operator-registry/pkg/lib/bundle" "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/render" "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/util" ) +var certVolumeMounts = map[string]corev1.VolumeMount{ + "apiservice-cert": { + Name: "apiservice-cert", + MountPath: "/apiserver.local.config/certificates", + }, + "webhook-cert": { + Name: "webhook-cert", + MountPath: "/tmp/k8s-webhook-server/serving-certs", + }, +} + // BundleCSVRBACResourceGenerator generates all ServiceAccounts, ClusterRoles, ClusterRoleBindings, Roles, RoleBindings // defined in the RegistryV1 bundle's cluster service version (CSV) var BundleCSVRBACResourceGenerator = render.ResourceGenerators{ @@ -34,6 +53,13 @@ func BundleCSVDeploymentGenerator(rv1 *render.RegistryV1, opts render.Options) ( if rv1 == nil { return nil, fmt.Errorf("bundle cannot be nil") } + + // collect deployments that service webhooks + webhookDeployments := sets.Set[string]{} + for _, wh := range rv1.CSV.Spec.WebhookDefinitions { + webhookDeployments.Insert(wh.DeploymentName) + } + objs := make([]client.Object, 0, len(rv1.CSV.Spec.InstallStrategy.StrategySpec.DeploymentSpecs)) for _, depSpec := range rv1.CSV.Spec.InstallStrategy.StrategySpec.DeploymentSpecs { // Add CSV annotations to template annotations @@ -50,14 +76,19 @@ func BundleCSVDeploymentGenerator(rv1 *render.RegistryV1, opts render.Options) ( // See https://github.com/operator-framework/operator-lifecycle-manager/blob/dfd0b2bea85038d3c0d65348bc812d297f16b8d2/pkg/controller/install/deployment.go#L177-L180 depSpec.Spec.RevisionHistoryLimit = ptr.To(int32(1)) - objs = append(objs, - CreateDeploymentResource( - depSpec.Name, - opts.InstallNamespace, - WithDeploymentSpec(depSpec.Spec), - WithLabels(depSpec.Label), - ), + deploymentResource := CreateDeploymentResource( + depSpec.Name, + opts.InstallNamespace, + WithDeploymentSpec(depSpec.Spec), + WithLabels(depSpec.Label), ) + + secretInfo := render.CertProvisionerFor(depSpec.Name, opts).GetCertSecretInfo() + if webhookDeployments.Has(depSpec.Name) && secretInfo != nil { + addCertVolumesToDeployment(deploymentResource, *secretInfo) + } + + objs = append(objs, deploymentResource) } return objs, nil } @@ -171,14 +202,66 @@ func BundleCSVServiceAccountGenerator(rv1 *render.RegistryV1, opts render.Option return objs, nil } -// BundleCRDGenerator generates CustomResourceDefinition resources from the registry+v1 bundle -func BundleCRDGenerator(rv1 *render.RegistryV1, _ render.Options) ([]client.Object, error) { +// BundleCRDGenerator generates CustomResourceDefinition resources from the registry+v1 bundle. If the CRD is referenced +// by any conversion webhook defined in the bundle's cluster service version spec, the CRD is modified +// by the CertificateProvider in opts to add any annotations or modifications necessary for certificate injection. +func BundleCRDGenerator(rv1 *render.RegistryV1, opts render.Options) ([]client.Object, error) { if rv1 == nil { return nil, fmt.Errorf("bundle cannot be nil") } + + // collect deployments to crds with conversion webhooks + crdToDeploymentMap := map[string]v1alpha1.WebhookDescription{} + for _, wh := range rv1.CSV.Spec.WebhookDefinitions { + if wh.Type != v1alpha1.ConversionWebhook { + continue + } + for _, crdName := range wh.ConversionCRDs { + if _, ok := crdToDeploymentMap[crdName]; ok { + return nil, fmt.Errorf("custom resource definition '%s' is referenced by multiple conversion webhook definitions", crdName) + } + crdToDeploymentMap[crdName] = wh + } + } + objs := make([]client.Object, 0, len(rv1.CRDs)) for _, crd := range rv1.CRDs { - objs = append(objs, crd.DeepCopy()) + cp := crd.DeepCopy() + if cw, ok := crdToDeploymentMap[crd.Name]; ok { + // OLMv0 behaviour parity + // See https://github.com/operator-framework/operator-lifecycle-manager/blob/dfd0b2bea85038d3c0d65348bc812d297f16b8d2/pkg/controller/install/webhook.go#L232 + if crd.Spec.PreserveUnknownFields { + return nil, fmt.Errorf("custom resource definition '%s' must have .spec.preserveUnknownFields set to false to let API Server call webhook to do the conversion", crd.Name) + } + + // OLMv0 behaviour parity + // https://github.com/operator-framework/operator-lifecycle-manager/blob/dfd0b2bea85038d3c0d65348bc812d297f16b8d2/pkg/controller/install/webhook.go#L242 + conversionWebhookPath := "/" + if cw.WebhookPath != nil { + conversionWebhookPath = *cw.WebhookPath + } + + certProvisioner := render.CertProvisionerFor(cw.DeploymentName, opts) + cp.Spec.Conversion = &apiextensionsv1.CustomResourceConversion{ + Strategy: apiextensionsv1.WebhookConverter, + Webhook: &apiextensionsv1.WebhookConversion{ + ClientConfig: &apiextensionsv1.WebhookClientConfig{ + Service: &apiextensionsv1.ServiceReference{ + Namespace: opts.InstallNamespace, + Name: certProvisioner.WebhookServiceName, + Path: &conversionWebhookPath, + Port: &cw.ContainerPort, + }, + }, + ConversionReviewVersions: cw.AdmissionReviewVersions, + }, + } + + if err := certProvisioner.InjectCABundle(cp); err != nil { + return nil, err + } + } + objs = append(objs, cp) } return objs, nil } @@ -206,6 +289,262 @@ func BundleAdditionalResourcesGenerator(rv1 *render.RegistryV1, opts render.Opti return objs, nil } +// BundleValidatingWebhookResourceGenerator generates ValidatingAdmissionWebhookConfiguration resources based on +// the bundle's cluster service version spec. The resource is modified by the CertificateProvider in opts +// to add any annotations or modifications necessary for certificate injection. +func BundleValidatingWebhookResourceGenerator(rv1 *render.RegistryV1, opts render.Options) ([]client.Object, error) { + if rv1 == nil { + return nil, fmt.Errorf("bundle cannot be nil") + } + + //nolint:prealloc + var objs []client.Object + for _, wh := range rv1.CSV.Spec.WebhookDefinitions { + if wh.Type != v1alpha1.ValidatingAdmissionWebhook { + continue + } + certProvisioner := render.CertProvisionerFor(wh.DeploymentName, opts) + webhookName := strings.TrimSuffix(wh.GenerateName, "-") + webhookResource := CreateValidatingWebhookConfigurationResource( + webhookName, + opts.InstallNamespace, + WithValidatingWebhooks( + admissionregistrationv1.ValidatingWebhook{ + Name: webhookName, + Rules: wh.Rules, + FailurePolicy: wh.FailurePolicy, + MatchPolicy: wh.MatchPolicy, + ObjectSelector: wh.ObjectSelector, + SideEffects: wh.SideEffects, + TimeoutSeconds: wh.TimeoutSeconds, + AdmissionReviewVersions: wh.AdmissionReviewVersions, + ClientConfig: admissionregistrationv1.WebhookClientConfig{ + Service: &admissionregistrationv1.ServiceReference{ + Namespace: opts.InstallNamespace, + Name: certProvisioner.WebhookServiceName, + Path: wh.WebhookPath, + Port: &wh.ContainerPort, + }, + }, + }, + ), + ) + if err := certProvisioner.InjectCABundle(webhookResource); err != nil { + return nil, err + } + objs = append(objs, webhookResource) + } + return objs, nil +} + +// BundleMutatingWebhookResourceGenerator generates MutatingAdmissionWebhookConfiguration resources based on +// the bundle's cluster service version spec. The resource is modified by the CertificateProvider in opts +// to add any annotations or modifications necessary for certificate injection. +func BundleMutatingWebhookResourceGenerator(rv1 *render.RegistryV1, opts render.Options) ([]client.Object, error) { + if rv1 == nil { + return nil, fmt.Errorf("bundle cannot be nil") + } + + //nolint:prealloc + var objs []client.Object + for _, wh := range rv1.CSV.Spec.WebhookDefinitions { + if wh.Type != v1alpha1.MutatingAdmissionWebhook { + continue + } + certProvisioner := render.CertProvisionerFor(wh.DeploymentName, opts) + webhookName := strings.TrimSuffix(wh.GenerateName, "-") + webhookResource := CreateMutatingWebhookConfigurationResource( + webhookName, + opts.InstallNamespace, + WithMutatingWebhooks( + admissionregistrationv1.MutatingWebhook{ + Name: webhookName, + Rules: wh.Rules, + FailurePolicy: wh.FailurePolicy, + MatchPolicy: wh.MatchPolicy, + ObjectSelector: wh.ObjectSelector, + SideEffects: wh.SideEffects, + TimeoutSeconds: wh.TimeoutSeconds, + AdmissionReviewVersions: wh.AdmissionReviewVersions, + ClientConfig: admissionregistrationv1.WebhookClientConfig{ + Service: &admissionregistrationv1.ServiceReference{ + Namespace: opts.InstallNamespace, + Name: certProvisioner.WebhookServiceName, + Path: wh.WebhookPath, + Port: &wh.ContainerPort, + }, + }, + ReinvocationPolicy: wh.ReinvocationPolicy, + }, + ), + ) + if err := certProvisioner.InjectCABundle(webhookResource); err != nil { + return nil, err + } + objs = append(objs, webhookResource) + } + return objs, nil +} + +// BundleWebhookServiceResourceGenerator generates Service resources based that support the webhooks defined in +// the bundle's cluster service version spec. The resource is modified by the CertificateProvider in opts +// to add any annotations or modifications necessary for certificate injection. +func BundleWebhookServiceResourceGenerator(rv1 *render.RegistryV1, opts render.Options) ([]client.Object, error) { + if rv1 == nil { + return nil, fmt.Errorf("bundle cannot be nil") + } + + // collect webhook service ports + webhookServicePortsByDeployment := map[string]sets.Set[corev1.ServicePort]{} + for _, wh := range rv1.CSV.Spec.WebhookDefinitions { + if _, ok := webhookServicePortsByDeployment[wh.DeploymentName]; !ok { + webhookServicePortsByDeployment[wh.DeploymentName] = sets.Set[corev1.ServicePort]{} + } + webhookServicePortsByDeployment[wh.DeploymentName].Insert(getWebhookServicePort(wh)) + } + + objs := make([]client.Object, 0, len(webhookServicePortsByDeployment)) + for _, deploymentSpec := range rv1.CSV.Spec.InstallStrategy.StrategySpec.DeploymentSpecs { + if _, ok := webhookServicePortsByDeployment[deploymentSpec.Name]; !ok { + continue + } + + servicePorts := webhookServicePortsByDeployment[deploymentSpec.Name] + ports := servicePorts.UnsortedList() + slices.SortStableFunc(ports, func(a, b corev1.ServicePort) int { + return cmp.Or(cmp.Compare(a.Port, b.Port), cmp.Compare(a.TargetPort.IntValue(), b.TargetPort.IntValue())) + }) + + var labelSelector map[string]string + if deploymentSpec.Spec.Selector != nil { + labelSelector = deploymentSpec.Spec.Selector.MatchLabels + } + + certProvisioner := render.CertProvisionerFor(deploymentSpec.Name, opts) + serviceResource := CreateServiceResource( + certProvisioner.WebhookServiceName, + opts.InstallNamespace, + WithServiceSpec( + corev1.ServiceSpec{ + Ports: ports, + Selector: labelSelector, + }, + ), + ) + + if err := certProvisioner.InjectCABundle(serviceResource); err != nil { + return nil, err + } + objs = append(objs, serviceResource) + } + + return objs, nil +} + +// CertProviderResourceGenerator generates any resources necessary for the CertificateProvider +// in opts to function correctly, e.g. Issuer or Certificate resources. +func CertProviderResourceGenerator(rv1 *render.RegistryV1, opts render.Options) ([]client.Object, error) { + deploymentsWithWebhooks := sets.Set[string]{} + + for _, wh := range rv1.CSV.Spec.WebhookDefinitions { + deploymentsWithWebhooks.Insert(wh.DeploymentName) + } + + var objs []client.Object + for _, depName := range deploymentsWithWebhooks.UnsortedList() { + certCfg := render.CertProvisionerFor(depName, opts) + certObjs, err := certCfg.AdditionalObjects() + if err != nil { + return nil, err + } + for _, certObj := range certObjs { + objs = append(objs, &certObj) + } + } + return objs, nil +} + func saNameOrDefault(saName string) string { return cmp.Or(saName, "default") } + +func getWebhookServicePort(wh v1alpha1.WebhookDescription) corev1.ServicePort { + containerPort := int32(443) + if wh.ContainerPort > 0 { + containerPort = wh.ContainerPort + } + + targetPort := intstr.FromInt32(containerPort) + if wh.TargetPort != nil { + targetPort = *wh.TargetPort + } + + return corev1.ServicePort{ + Name: strconv.Itoa(int(containerPort)), + Port: containerPort, + TargetPort: targetPort, + } +} + +func addCertVolumesToDeployment(dep *appsv1.Deployment, certSecretInfo render.CertSecretInfo) { + // update pod volumes + dep.Spec.Template.Spec.Volumes = slices.Concat( + slices.DeleteFunc(dep.Spec.Template.Spec.Volumes, func(v corev1.Volume) bool { + _, ok := certVolumeMounts[v.Name] + return ok + }), + []corev1.Volume{ + { + Name: "apiservice-cert", + VolumeSource: corev1.VolumeSource{ + Secret: &corev1.SecretVolumeSource{ + SecretName: certSecretInfo.SecretName, + Items: []corev1.KeyToPath{ + { + Key: certSecretInfo.CertificateKey, + Path: "apiserver.crt", + }, + { + Key: certSecretInfo.PrivateKeyKey, + Path: "apiserver.key", + }, + }, + }, + }, + }, { + Name: "webhook-cert", + VolumeSource: corev1.VolumeSource{ + Secret: &corev1.SecretVolumeSource{ + SecretName: certSecretInfo.SecretName, + Items: []corev1.KeyToPath{ + { + Key: certSecretInfo.CertificateKey, + Path: "tls.crt", + }, + { + Key: certSecretInfo.PrivateKeyKey, + Path: "tls.key", + }, + }, + }, + }, + }, + }, + ) + + // update container volume mounts + for i := range dep.Spec.Template.Spec.Containers { + dep.Spec.Template.Spec.Containers[i].VolumeMounts = slices.Concat( + slices.DeleteFunc(dep.Spec.Template.Spec.Containers[i].VolumeMounts, func(v corev1.VolumeMount) bool { + _, ok := certVolumeMounts[v.Name] + return ok + }), + slices.SortedFunc( + maps.Values(certVolumeMounts), + func(a corev1.VolumeMount, b corev1.VolumeMount) int { + return cmp.Compare(a.Name, b.Name) + }, + ), + ) + } +} diff --git a/internal/operator-controller/rukpak/render/generators/generators_test.go b/internal/operator-controller/rukpak/render/generators/generators_test.go index d3151f829..0dcb9b11e 100644 --- a/internal/operator-controller/rukpak/render/generators/generators_test.go +++ b/internal/operator-controller/rukpak/render/generators/generators_test.go @@ -8,13 +8,14 @@ import ( "testing" "github.com/stretchr/testify/require" + admissionregistrationv1 "k8s.io/api/admissionregistration/v1" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" rbacv1 "k8s.io/api/rbac/v1" apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/util/intstr" "k8s.io/utils/ptr" "sigs.k8s.io/controller-runtime/pkg/client" @@ -22,7 +23,7 @@ import ( "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/render" "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/render/generators" - . "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/util" + . "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/util/testing" ) func Test_BundleCSVRBACResourceGenerator_HasCorrectGenerators(t *testing.T) { @@ -175,6 +176,160 @@ func Test_BundleCSVDeploymentGenerator_Succeeds(t *testing.T) { } } +func Test_BundleCSVDeploymentGenerator_WithCertWithCertProvider_Succeeds(t *testing.T) { + fakeProvider := FakeCertProvider{ + GetCertSecretInfoFn: func(cfg render.CertificateProvisionerConfig) render.CertSecretInfo { + return render.CertSecretInfo{ + SecretName: "some-secret", + CertificateKey: "some-cert-key", + PrivateKeyKey: "some-private-key-key", + } + }, + } + + bundle := &render.RegistryV1{ + CSV: MakeCSV( + WithWebhookDefinitions( + v1alpha1.WebhookDescription{ + Type: v1alpha1.ValidatingAdmissionWebhook, + DeploymentName: "deployment-one", + }), + // deployment must have a referencing webhook (or owned apiservice) definition to trigger cert secret + WithStrategyDeploymentSpecs( + v1alpha1.StrategyDeploymentSpec{ + Name: "deployment-one", + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Volumes: []corev1.Volume{ + { + Name: "apiservice-cert", + VolumeSource: corev1.VolumeSource{ + EmptyDir: &corev1.EmptyDirVolumeSource{}, + }, + }, + { + Name: "some-other-mount", + VolumeSource: corev1.VolumeSource{ + EmptyDir: &corev1.EmptyDirVolumeSource{}, + }, + }, + // expect webhook-cert volume to be injected + }, + Containers: []corev1.Container{ + { + Name: "container-1", + VolumeMounts: []corev1.VolumeMount{ + // expect apiservice-cert volume to be injected + { + Name: "webhook-cert", + MountPath: "/webhook-cert-path", + }, { + Name: "some-other-mount", + MountPath: "/some/other/mount/path", + }, + }, + }, + { + Name: "container-2", + // expect cert volumes to be injected + }, + }, + }, + }, + }, + }, + ), + ), + } + + objs, err := generators.BundleCSVDeploymentGenerator(bundle, render.Options{ + InstallNamespace: "install-namespace", + CertificateProvider: fakeProvider, + }) + require.NoError(t, err) + require.Len(t, objs, 1) + + deployment := objs[0].(*appsv1.Deployment) + require.NotNil(t, deployment) + + require.Equal(t, []corev1.Volume{ + { + Name: "some-other-mount", + VolumeSource: corev1.VolumeSource{ + EmptyDir: &corev1.EmptyDirVolumeSource{}, + }, + }, + { + Name: "apiservice-cert", + VolumeSource: corev1.VolumeSource{ + Secret: &corev1.SecretVolumeSource{ + SecretName: "some-secret", + Items: []corev1.KeyToPath{ + { + Key: "some-cert-key", + Path: "apiserver.crt", + }, + { + Key: "some-private-key-key", + Path: "apiserver.key", + }, + }, + }, + }, + }, { + Name: "webhook-cert", + VolumeSource: corev1.VolumeSource{ + Secret: &corev1.SecretVolumeSource{ + SecretName: "some-secret", + Items: []corev1.KeyToPath{ + { + Key: "some-cert-key", + Path: "tls.crt", + }, + { + Key: "some-private-key-key", + Path: "tls.key", + }, + }, + }, + }, + }, + }, deployment.Spec.Template.Spec.Volumes) + require.Equal(t, []corev1.Container{ + { + Name: "container-1", + VolumeMounts: []corev1.VolumeMount{ + { + Name: "some-other-mount", + MountPath: "/some/other/mount/path", + }, + { + Name: "apiservice-cert", + MountPath: "/apiserver.local.config/certificates", + }, + { + Name: "webhook-cert", + MountPath: "/tmp/k8s-webhook-server/serving-certs", + }, + }, + }, + { + Name: "container-2", + VolumeMounts: []corev1.VolumeMount{ + { + Name: "apiservice-cert", + MountPath: "/apiserver.local.config/certificates", + }, + { + Name: "webhook-cert", + MountPath: "/tmp/k8s-webhook-server/serving-certs", + }, + }, + }, + }, deployment.Spec.Template.Spec.Containers) +} + func Test_BundleCSVDeploymentGenerator_FailsOnNil(t *testing.T) { objs, err := generators.BundleCSVDeploymentGenerator(nil, render.Options{}) require.Nil(t, objs) @@ -1118,6 +1273,164 @@ func Test_BundleCRDGenerator_Succeeds(t *testing.T) { }, objs) } +func Test_BundleCRDGenerator_WithConversionWebhook_Succeeds(t *testing.T) { + opts := render.Options{ + InstallNamespace: "install-namespace", + TargetNamespaces: []string{""}, + } + + bundle := &render.RegistryV1{ + CRDs: []apiextensionsv1.CustomResourceDefinition{ + {ObjectMeta: metav1.ObjectMeta{Name: "crd-one"}}, + {ObjectMeta: metav1.ObjectMeta{Name: "crd-two"}}, + }, + CSV: MakeCSV( + WithWebhookDefinitions( + v1alpha1.WebhookDescription{ + Type: v1alpha1.ConversionWebhook, + WebhookPath: ptr.To("/some/path"), + ContainerPort: 8443, + AdmissionReviewVersions: []string{"v1", "v1beta1"}, + ConversionCRDs: []string{"crd-one"}, + DeploymentName: "some-deployment", + }, + v1alpha1.WebhookDescription{ + // should use / as WebhookPath by default + Type: v1alpha1.ConversionWebhook, + ContainerPort: 8443, + AdmissionReviewVersions: []string{"v1", "v1beta1"}, + ConversionCRDs: []string{"crd-two"}, + DeploymentName: "some-deployment", + }, + ), + ), + } + + objs, err := generators.BundleCRDGenerator(bundle, opts) + require.NoError(t, err) + require.Equal(t, []client.Object{ + &apiextensionsv1.CustomResourceDefinition{ + ObjectMeta: metav1.ObjectMeta{ + Name: "crd-one", + }, + Spec: apiextensionsv1.CustomResourceDefinitionSpec{ + Conversion: &apiextensionsv1.CustomResourceConversion{ + Strategy: apiextensionsv1.WebhookConverter, + Webhook: &apiextensionsv1.WebhookConversion{ + ClientConfig: &apiextensionsv1.WebhookClientConfig{ + Service: &apiextensionsv1.ServiceReference{ + Namespace: "install-namespace", + Name: "some-deployment-service", + Path: ptr.To("/some/path"), + Port: ptr.To(int32(8443)), + }, + }, + ConversionReviewVersions: []string{"v1", "v1beta1"}, + }, + }, + }, + }, + &apiextensionsv1.CustomResourceDefinition{ + ObjectMeta: metav1.ObjectMeta{ + Name: "crd-two", + }, + Spec: apiextensionsv1.CustomResourceDefinitionSpec{ + Conversion: &apiextensionsv1.CustomResourceConversion{ + Strategy: apiextensionsv1.WebhookConverter, + Webhook: &apiextensionsv1.WebhookConversion{ + ClientConfig: &apiextensionsv1.WebhookClientConfig{ + Service: &apiextensionsv1.ServiceReference{ + Namespace: "install-namespace", + Name: "some-deployment-service", + Path: ptr.To("/"), + Port: ptr.To(int32(8443)), + }, + }, + ConversionReviewVersions: []string{"v1", "v1beta1"}, + }, + }, + }, + }, + }, objs) +} + +func Test_BundleCRDGenerator_WithConversionWebhook_Fails(t *testing.T) { + opts := render.Options{ + InstallNamespace: "install-namespace", + TargetNamespaces: []string{""}, + } + + bundle := &render.RegistryV1{ + CRDs: []apiextensionsv1.CustomResourceDefinition{ + { + ObjectMeta: metav1.ObjectMeta{Name: "crd-one"}, + Spec: apiextensionsv1.CustomResourceDefinitionSpec{ + PreserveUnknownFields: true, + }, + }, + }, + CSV: MakeCSV( + WithWebhookDefinitions( + v1alpha1.WebhookDescription{ + Type: v1alpha1.ConversionWebhook, + WebhookPath: ptr.To("/some/path"), + ContainerPort: 8443, + AdmissionReviewVersions: []string{"v1", "v1beta1"}, + ConversionCRDs: []string{"crd-one"}, + DeploymentName: "some-deployment", + }, + ), + ), + } + + objs, err := generators.BundleCRDGenerator(bundle, opts) + require.Nil(t, objs) + require.Error(t, err) + require.Contains(t, err.Error(), "must have .spec.preserveUnknownFields set to false to let API Server call webhook to do the conversion") +} + +func Test_BundleCRDGenerator_WithCertProvider_Succeeds(t *testing.T) { + fakeProvider := FakeCertProvider{ + InjectCABundleFn: func(obj client.Object, cfg render.CertificateProvisionerConfig) error { + obj.SetAnnotations(map[string]string{ + "cert-provider": "annotation", + }) + return nil + }, + } + + opts := render.Options{ + InstallNamespace: "install-namespace", + TargetNamespaces: []string{""}, + CertificateProvider: fakeProvider, + } + + bundle := &render.RegistryV1{ + CRDs: []apiextensionsv1.CustomResourceDefinition{ + {ObjectMeta: metav1.ObjectMeta{Name: "crd-one"}}, + {ObjectMeta: metav1.ObjectMeta{Name: "crd-two"}}, + }, + CSV: MakeCSV( + WithWebhookDefinitions( + v1alpha1.WebhookDescription{ + Type: v1alpha1.ConversionWebhook, + DeploymentName: "my-deployment", + ConversionCRDs: []string{ + "crd-one", + }, + }, + ), + ), + } + + objs, err := generators.BundleCRDGenerator(bundle, opts) + require.NoError(t, err) + require.Len(t, objs, 2) + require.Equal(t, map[string]string{ + "cert-provider": "annotation", + }, objs[0].GetAnnotations()) +} + func Test_BundleCRDGenerator_FailsOnNil(t *testing.T) { objs, err := generators.BundleCRDGenerator(nil, render.Options{}) require.Nil(t, objs) @@ -1132,7 +1445,7 @@ func Test_BundleAdditionalResourcesGenerator_Succeeds(t *testing.T) { bundle := &render.RegistryV1{ Others: []unstructured.Unstructured{ - toUnstructured(t, + *ToUnstructuredT(t, &corev1.Service{ TypeMeta: metav1.TypeMeta{ Kind: "Service", @@ -1143,7 +1456,7 @@ func Test_BundleAdditionalResourcesGenerator_Succeeds(t *testing.T) { }, }, ), - toUnstructured(t, + *ToUnstructuredT(t, &rbacv1.ClusterRole{ TypeMeta: metav1.TypeMeta{ Kind: "ClusterRole", @@ -1169,15 +1482,1005 @@ func Test_BundleAdditionalResourcesGenerator_FailsOnNil(t *testing.T) { require.Contains(t, err.Error(), "bundle cannot be nil") } -func toUnstructured(t *testing.T, obj client.Object) unstructured.Unstructured { - gvk := obj.GetObjectKind().GroupVersionKind() - - var u unstructured.Unstructured - uObj, err := runtime.DefaultUnstructuredConverter.ToUnstructured(obj) - require.NoError(t, err) - unstructured.RemoveNestedField(uObj, "metadata", "creationTimestamp") - unstructured.RemoveNestedField(uObj, "status") - u.Object = uObj - u.SetGroupVersionKind(gvk) - return u +func Test_BundleValidatingWebhookResourceGenerator_Succeeds(t *testing.T) { + fakeProvider := FakeCertProvider{ + InjectCABundleFn: func(obj client.Object, cfg render.CertificateProvisionerConfig) error { + obj.SetAnnotations(map[string]string{ + "cert-provider": "annotation", + }) + return nil + }, + } + for _, tc := range []struct { + name string + bundle *render.RegistryV1 + opts render.Options + expectedResources []client.Object + }{ + { + name: "generates validating webhook configuration resources described in the bundle's cluster service version", + bundle: &render.RegistryV1{ + CSV: MakeCSV( + WithWebhookDefinitions( + v1alpha1.WebhookDescription{ + Type: v1alpha1.ValidatingAdmissionWebhook, + GenerateName: "my-webhook", + DeploymentName: "my-deployment", + Rules: []admissionregistrationv1.RuleWithOperations{ + { + Operations: []admissionregistrationv1.OperationType{ + admissionregistrationv1.OperationAll, + }, + Rule: admissionregistrationv1.Rule{ + APIGroups: []string{""}, + APIVersions: []string{""}, + Resources: []string{"namespaces"}, + }, + }, + }, + FailurePolicy: ptr.To(admissionregistrationv1.Fail), + ObjectSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "foo": "bar", + }, + }, + SideEffects: ptr.To(admissionregistrationv1.SideEffectClassNone), + TimeoutSeconds: ptr.To(int32(1)), + AdmissionReviewVersions: []string{ + "v1beta1", + "v1beta2", + }, + WebhookPath: ptr.To("/webhook-path"), + ContainerPort: 443, + }, + ), + ), + }, + opts: render.Options{ + InstallNamespace: "install-namespace", + TargetNamespaces: []string{"watch-namespace-one", "watch-namespace-two"}, + }, + expectedResources: []client.Object{ + &admissionregistrationv1.ValidatingWebhookConfiguration{ + TypeMeta: metav1.TypeMeta{ + Kind: "ValidatingWebhookConfiguration", + APIVersion: admissionregistrationv1.SchemeGroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "my-webhook", + Namespace: "install-namespace", + }, + Webhooks: []admissionregistrationv1.ValidatingWebhook{ + { + Name: "my-webhook", + Rules: []admissionregistrationv1.RuleWithOperations{ + { + Operations: []admissionregistrationv1.OperationType{ + admissionregistrationv1.OperationAll, + }, + Rule: admissionregistrationv1.Rule{ + APIGroups: []string{""}, + APIVersions: []string{""}, + Resources: []string{"namespaces"}, + }, + }, + }, + FailurePolicy: ptr.To(admissionregistrationv1.Fail), + ObjectSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "foo": "bar", + }, + }, + SideEffects: ptr.To(admissionregistrationv1.SideEffectClassNone), + TimeoutSeconds: ptr.To(int32(1)), + AdmissionReviewVersions: []string{ + "v1beta1", + "v1beta2", + }, + ClientConfig: admissionregistrationv1.WebhookClientConfig{ + Service: &admissionregistrationv1.ServiceReference{ + Namespace: "install-namespace", + Name: "my-deployment-service", + Path: ptr.To("/webhook-path"), + Port: ptr.To(int32(443)), + }, + }, + }, + }, + }, + }, + }, + { + name: "removes any - suffixes from the webhook name (v0 used GenerateName to allow multiple operator installations - we don't want that in v1)", + bundle: &render.RegistryV1{ + CSV: MakeCSV( + WithWebhookDefinitions( + v1alpha1.WebhookDescription{ + Type: v1alpha1.ValidatingAdmissionWebhook, + GenerateName: "my-webhook-", + DeploymentName: "my-deployment", + Rules: []admissionregistrationv1.RuleWithOperations{ + { + Operations: []admissionregistrationv1.OperationType{ + admissionregistrationv1.OperationAll, + }, + Rule: admissionregistrationv1.Rule{ + APIGroups: []string{""}, + APIVersions: []string{""}, + Resources: []string{"namespaces"}, + }, + }, + }, + FailurePolicy: ptr.To(admissionregistrationv1.Fail), + ObjectSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "foo": "bar", + }, + }, + SideEffects: ptr.To(admissionregistrationv1.SideEffectClassNone), + TimeoutSeconds: ptr.To(int32(1)), + AdmissionReviewVersions: []string{ + "v1beta1", + "v1beta2", + }, + WebhookPath: ptr.To("/webhook-path"), + ContainerPort: 443, + }, + ), + ), + }, + opts: render.Options{ + InstallNamespace: "install-namespace", + TargetNamespaces: []string{"watch-namespace-one", "watch-namespace-two"}, + }, + expectedResources: []client.Object{ + &admissionregistrationv1.ValidatingWebhookConfiguration{ + TypeMeta: metav1.TypeMeta{ + Kind: "ValidatingWebhookConfiguration", + APIVersion: admissionregistrationv1.SchemeGroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "my-webhook", + Namespace: "install-namespace", + }, + Webhooks: []admissionregistrationv1.ValidatingWebhook{ + { + Name: "my-webhook", + Rules: []admissionregistrationv1.RuleWithOperations{ + { + Operations: []admissionregistrationv1.OperationType{ + admissionregistrationv1.OperationAll, + }, + Rule: admissionregistrationv1.Rule{ + APIGroups: []string{""}, + APIVersions: []string{""}, + Resources: []string{"namespaces"}, + }, + }, + }, + FailurePolicy: ptr.To(admissionregistrationv1.Fail), + ObjectSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "foo": "bar", + }, + }, + SideEffects: ptr.To(admissionregistrationv1.SideEffectClassNone), + TimeoutSeconds: ptr.To(int32(1)), + AdmissionReviewVersions: []string{ + "v1beta1", + "v1beta2", + }, + ClientConfig: admissionregistrationv1.WebhookClientConfig{ + Service: &admissionregistrationv1.ServiceReference{ + Namespace: "install-namespace", + Name: "my-deployment-service", + Path: ptr.To("/webhook-path"), + Port: ptr.To(int32(443)), + }, + }, + }, + }, + }, + }, + }, + { + name: "generates validating webhook configuration resources with certificate provider modifications", + bundle: &render.RegistryV1{ + CSV: MakeCSV( + WithWebhookDefinitions( + v1alpha1.WebhookDescription{ + Type: v1alpha1.ValidatingAdmissionWebhook, + GenerateName: "my-webhook", + DeploymentName: "my-deployment", + ContainerPort: 443, + }, + ), + ), + }, + opts: render.Options{ + InstallNamespace: "install-namespace", + TargetNamespaces: []string{"watch-namespace-one", "watch-namespace-two"}, + CertificateProvider: fakeProvider, + }, + expectedResources: []client.Object{ + &admissionregistrationv1.ValidatingWebhookConfiguration{ + TypeMeta: metav1.TypeMeta{ + Kind: "ValidatingWebhookConfiguration", + APIVersion: admissionregistrationv1.SchemeGroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "my-webhook", + Namespace: "install-namespace", + Annotations: map[string]string{ + "cert-provider": "annotation", + }, + }, + Webhooks: []admissionregistrationv1.ValidatingWebhook{ + { + Name: "my-webhook", + ClientConfig: admissionregistrationv1.WebhookClientConfig{ + Service: &admissionregistrationv1.ServiceReference{ + Namespace: "install-namespace", + Name: "my-deployment-service", + Port: ptr.To(int32(443)), + }, + }, + }, + }, + }, + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + objs, err := generators.BundleValidatingWebhookResourceGenerator(tc.bundle, tc.opts) + require.NoError(t, err) + require.Equal(t, tc.expectedResources, objs) + }) + } +} + +func Test_BundleValidatingWebhookResourceGenerator_FailsOnNil(t *testing.T) { + objs, err := generators.BundleValidatingWebhookResourceGenerator(nil, render.Options{}) + require.Nil(t, objs) + require.Error(t, err) + require.Contains(t, err.Error(), "bundle cannot be nil") +} + +func Test_BundleMutatingWebhookResourceGenerator_Succeeds(t *testing.T) { + fakeProvider := FakeCertProvider{ + InjectCABundleFn: func(obj client.Object, cfg render.CertificateProvisionerConfig) error { + obj.SetAnnotations(map[string]string{ + "cert-provider": "annotation", + }) + return nil + }, + } + for _, tc := range []struct { + name string + bundle *render.RegistryV1 + opts render.Options + expectedResources []client.Object + }{ + { + name: "generates validating webhook configuration resources described in the bundle's cluster service version", + bundle: &render.RegistryV1{ + CSV: MakeCSV( + WithWebhookDefinitions( + v1alpha1.WebhookDescription{ + Type: v1alpha1.MutatingAdmissionWebhook, + GenerateName: "my-webhook", + DeploymentName: "my-deployment", + Rules: []admissionregistrationv1.RuleWithOperations{ + { + Operations: []admissionregistrationv1.OperationType{ + admissionregistrationv1.OperationAll, + }, + Rule: admissionregistrationv1.Rule{ + APIGroups: []string{""}, + APIVersions: []string{""}, + Resources: []string{"namespaces"}, + }, + }, + }, + FailurePolicy: ptr.To(admissionregistrationv1.Fail), + ObjectSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "foo": "bar", + }, + }, + SideEffects: ptr.To(admissionregistrationv1.SideEffectClassNone), + TimeoutSeconds: ptr.To(int32(1)), + AdmissionReviewVersions: []string{ + "v1beta1", + "v1beta2", + }, + WebhookPath: ptr.To("/webhook-path"), + ContainerPort: 443, + ReinvocationPolicy: ptr.To(admissionregistrationv1.IfNeededReinvocationPolicy), + }, + ), + ), + }, + opts: render.Options{ + InstallNamespace: "install-namespace", + TargetNamespaces: []string{"watch-namespace-one", "watch-namespace-two"}, + }, + expectedResources: []client.Object{ + &admissionregistrationv1.MutatingWebhookConfiguration{ + TypeMeta: metav1.TypeMeta{ + Kind: "MutatingWebhookConfiguration", + APIVersion: admissionregistrationv1.SchemeGroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "my-webhook", + Namespace: "install-namespace", + }, + Webhooks: []admissionregistrationv1.MutatingWebhook{ + { + Name: "my-webhook", + Rules: []admissionregistrationv1.RuleWithOperations{ + { + Operations: []admissionregistrationv1.OperationType{ + admissionregistrationv1.OperationAll, + }, + Rule: admissionregistrationv1.Rule{ + APIGroups: []string{""}, + APIVersions: []string{""}, + Resources: []string{"namespaces"}, + }, + }, + }, + FailurePolicy: ptr.To(admissionregistrationv1.Fail), + ObjectSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "foo": "bar", + }, + }, + SideEffects: ptr.To(admissionregistrationv1.SideEffectClassNone), + TimeoutSeconds: ptr.To(int32(1)), + AdmissionReviewVersions: []string{ + "v1beta1", + "v1beta2", + }, + ReinvocationPolicy: ptr.To(admissionregistrationv1.IfNeededReinvocationPolicy), + ClientConfig: admissionregistrationv1.WebhookClientConfig{ + Service: &admissionregistrationv1.ServiceReference{ + Namespace: "install-namespace", + Name: "my-deployment-service", + Path: ptr.To("/webhook-path"), + Port: ptr.To(int32(443)), + }, + }, + }, + }, + }, + }, + }, + { + name: "removes any - suffixes from the webhook name (v0 used GenerateName to allow multiple operator installations - we don't want that in v1)", + bundle: &render.RegistryV1{ + CSV: MakeCSV( + WithWebhookDefinitions( + v1alpha1.WebhookDescription{ + Type: v1alpha1.MutatingAdmissionWebhook, + GenerateName: "my-webhook-", + DeploymentName: "my-deployment", + Rules: []admissionregistrationv1.RuleWithOperations{ + { + Operations: []admissionregistrationv1.OperationType{ + admissionregistrationv1.OperationAll, + }, + Rule: admissionregistrationv1.Rule{ + APIGroups: []string{""}, + APIVersions: []string{""}, + Resources: []string{"namespaces"}, + }, + }, + }, + FailurePolicy: ptr.To(admissionregistrationv1.Fail), + ObjectSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "foo": "bar", + }, + }, + SideEffects: ptr.To(admissionregistrationv1.SideEffectClassNone), + TimeoutSeconds: ptr.To(int32(1)), + AdmissionReviewVersions: []string{ + "v1beta1", + "v1beta2", + }, + WebhookPath: ptr.To("/webhook-path"), + ContainerPort: 443, + ReinvocationPolicy: ptr.To(admissionregistrationv1.IfNeededReinvocationPolicy), + }, + ), + ), + }, + opts: render.Options{ + InstallNamespace: "install-namespace", + TargetNamespaces: []string{"watch-namespace-one", "watch-namespace-two"}, + }, + expectedResources: []client.Object{ + &admissionregistrationv1.MutatingWebhookConfiguration{ + TypeMeta: metav1.TypeMeta{ + Kind: "MutatingWebhookConfiguration", + APIVersion: admissionregistrationv1.SchemeGroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "my-webhook", + Namespace: "install-namespace", + }, + Webhooks: []admissionregistrationv1.MutatingWebhook{ + { + Name: "my-webhook", + Rules: []admissionregistrationv1.RuleWithOperations{ + { + Operations: []admissionregistrationv1.OperationType{ + admissionregistrationv1.OperationAll, + }, + Rule: admissionregistrationv1.Rule{ + APIGroups: []string{""}, + APIVersions: []string{""}, + Resources: []string{"namespaces"}, + }, + }, + }, + FailurePolicy: ptr.To(admissionregistrationv1.Fail), + ObjectSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "foo": "bar", + }, + }, + SideEffects: ptr.To(admissionregistrationv1.SideEffectClassNone), + TimeoutSeconds: ptr.To(int32(1)), + AdmissionReviewVersions: []string{ + "v1beta1", + "v1beta2", + }, + ReinvocationPolicy: ptr.To(admissionregistrationv1.IfNeededReinvocationPolicy), + ClientConfig: admissionregistrationv1.WebhookClientConfig{ + Service: &admissionregistrationv1.ServiceReference{ + Namespace: "install-namespace", + Name: "my-deployment-service", + Path: ptr.To("/webhook-path"), + Port: ptr.To(int32(443)), + }, + }, + }, + }, + }, + }, + }, + { + name: "generates validating webhook configuration resources with certificate provider modifications", + bundle: &render.RegistryV1{ + CSV: MakeCSV( + WithWebhookDefinitions( + v1alpha1.WebhookDescription{ + Type: v1alpha1.MutatingAdmissionWebhook, + GenerateName: "my-webhook", + DeploymentName: "my-deployment", + ContainerPort: 443, + }, + ), + ), + }, + opts: render.Options{ + InstallNamespace: "install-namespace", + TargetNamespaces: []string{"watch-namespace-one", "watch-namespace-two"}, + CertificateProvider: fakeProvider, + }, + expectedResources: []client.Object{ + &admissionregistrationv1.MutatingWebhookConfiguration{ + TypeMeta: metav1.TypeMeta{ + Kind: "MutatingWebhookConfiguration", + APIVersion: admissionregistrationv1.SchemeGroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "my-webhook", + Namespace: "install-namespace", + Annotations: map[string]string{ + "cert-provider": "annotation", + }, + }, + Webhooks: []admissionregistrationv1.MutatingWebhook{ + { + Name: "my-webhook", + ClientConfig: admissionregistrationv1.WebhookClientConfig{ + Service: &admissionregistrationv1.ServiceReference{ + Namespace: "install-namespace", + Name: "my-deployment-service", + Port: ptr.To(int32(443)), + }, + }, + }, + }, + }, + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + objs, err := generators.BundleMutatingWebhookResourceGenerator(tc.bundle, tc.opts) + require.NoError(t, err) + require.Equal(t, tc.expectedResources, objs) + }) + } +} + +func Test_BundleMutatingWebhookResourceGenerator_FailsOnNil(t *testing.T) { + objs, err := generators.BundleMutatingWebhookResourceGenerator(nil, render.Options{}) + require.Nil(t, objs) + require.Error(t, err) + require.Contains(t, err.Error(), "bundle cannot be nil") +} + +func Test_BundleWebhookServiceResourceGenerator_Succeeds(t *testing.T) { + fakeProvider := FakeCertProvider{ + InjectCABundleFn: func(obj client.Object, cfg render.CertificateProvisionerConfig) error { + obj.SetAnnotations(map[string]string{ + "cert-provider": "annotation", + }) + return nil + }, + } + for _, tc := range []struct { + name string + bundle *render.RegistryV1 + opts render.Options + expectedResources []client.Object + }{ + { + name: "generates webhook services using container port 443 and target port 443 by default", + bundle: &render.RegistryV1{ + CSV: MakeCSV( + WithStrategyDeploymentSpecs( + v1alpha1.StrategyDeploymentSpec{ + Name: "my-deployment", + }), + WithWebhookDefinitions( + v1alpha1.WebhookDescription{ + Type: v1alpha1.MutatingAdmissionWebhook, + DeploymentName: "my-deployment", + }, + ), + ), + }, + opts: render.Options{ + InstallNamespace: "install-namespace", + TargetNamespaces: []string{"watch-namespace-one", "watch-namespace-two"}, + }, + expectedResources: []client.Object{ + &corev1.Service{ + TypeMeta: metav1.TypeMeta{ + Kind: "Service", + APIVersion: corev1.SchemeGroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "my-deployment-service", + Namespace: "install-namespace", + }, + Spec: corev1.ServiceSpec{ + Ports: []corev1.ServicePort{ + { + Name: "443", + Port: int32(443), + TargetPort: intstr.IntOrString{ + Type: intstr.Int, + IntVal: 443, + }, + }, + }, + }, + }, + }, + }, + { + name: "generates webhook services using the given container port and setting target port the same as the container port if not given", + bundle: &render.RegistryV1{ + CSV: MakeCSV( + WithStrategyDeploymentSpecs( + v1alpha1.StrategyDeploymentSpec{ + Name: "my-deployment", + }), + WithWebhookDefinitions( + v1alpha1.WebhookDescription{ + Type: v1alpha1.ValidatingAdmissionWebhook, + DeploymentName: "my-deployment", + ContainerPort: int32(8443), + }, + ), + ), + }, + opts: render.Options{ + InstallNamespace: "install-namespace", + TargetNamespaces: []string{"watch-namespace-one", "watch-namespace-two"}, + }, + expectedResources: []client.Object{ + &corev1.Service{ + TypeMeta: metav1.TypeMeta{ + Kind: "Service", + APIVersion: corev1.SchemeGroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "my-deployment-service", + Namespace: "install-namespace", + }, + Spec: corev1.ServiceSpec{ + Ports: []corev1.ServicePort{ + { + Name: "8443", + Port: int32(8443), + TargetPort: intstr.IntOrString{ + Type: intstr.Int, + IntVal: 8443, + }, + }, + }, + }, + }, + }, + }, + { + name: "generates webhook services using given container port of 443 and given target port", + bundle: &render.RegistryV1{ + CSV: MakeCSV( + WithStrategyDeploymentSpecs( + v1alpha1.StrategyDeploymentSpec{ + Name: "my-deployment", + }), + WithWebhookDefinitions( + v1alpha1.WebhookDescription{ + Type: v1alpha1.ConversionWebhook, + DeploymentName: "my-deployment", + TargetPort: &intstr.IntOrString{ + Type: intstr.Int, + IntVal: 8080, + }, + }, + ), + ), + }, + opts: render.Options{ + InstallNamespace: "install-namespace", + TargetNamespaces: []string{"watch-namespace-one", "watch-namespace-two"}, + }, + expectedResources: []client.Object{ + &corev1.Service{ + TypeMeta: metav1.TypeMeta{ + Kind: "Service", + APIVersion: corev1.SchemeGroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "my-deployment-service", + Namespace: "install-namespace", + }, + Spec: corev1.ServiceSpec{ + Ports: []corev1.ServicePort{ + { + Name: "443", + Port: int32(443), + TargetPort: intstr.IntOrString{ + Type: intstr.Int, + IntVal: 8080, + }, + }, + }, + }, + }, + }, + }, + { + name: "generates webhook services using given container port and target port", + bundle: &render.RegistryV1{ + CSV: MakeCSV( + WithStrategyDeploymentSpecs( + v1alpha1.StrategyDeploymentSpec{ + Name: "my-deployment", + }), + WithWebhookDefinitions( + v1alpha1.WebhookDescription{ + Type: v1alpha1.ConversionWebhook, + DeploymentName: "my-deployment", + ContainerPort: int32(9090), + TargetPort: &intstr.IntOrString{ + Type: intstr.Int, + IntVal: 9099, + }, + }, + ), + ), + }, + opts: render.Options{ + InstallNamespace: "install-namespace", + TargetNamespaces: []string{"watch-namespace-one", "watch-namespace-two"}, + }, + expectedResources: []client.Object{ + &corev1.Service{ + TypeMeta: metav1.TypeMeta{ + Kind: "Service", + APIVersion: corev1.SchemeGroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "my-deployment-service", + Namespace: "install-namespace", + }, + Spec: corev1.ServiceSpec{ + Ports: []corev1.ServicePort{ + { + Name: "9090", + Port: int32(9090), + TargetPort: intstr.IntOrString{ + Type: intstr.Int, + IntVal: 9099, + }, + }, + }, + }, + }, + }, + }, + { + name: "generates webhook services using referenced deployment defined label selector", + bundle: &render.RegistryV1{ + CSV: MakeCSV( + WithStrategyDeploymentSpecs( + v1alpha1.StrategyDeploymentSpec{ + Name: "my-deployment", + Spec: appsv1.DeploymentSpec{ + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "foo": "bar", + }, + }, + }, + }), + WithWebhookDefinitions( + v1alpha1.WebhookDescription{ + Type: v1alpha1.ConversionWebhook, + DeploymentName: "my-deployment", + ContainerPort: int32(9090), + TargetPort: &intstr.IntOrString{ + Type: intstr.Int, + IntVal: 9099, + }, + }, + ), + ), + }, + opts: render.Options{ + InstallNamespace: "install-namespace", + TargetNamespaces: []string{"watch-namespace-one", "watch-namespace-two"}, + }, + expectedResources: []client.Object{ + &corev1.Service{ + TypeMeta: metav1.TypeMeta{ + Kind: "Service", + APIVersion: corev1.SchemeGroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "my-deployment-service", + Namespace: "install-namespace", + }, + Spec: corev1.ServiceSpec{ + Ports: []corev1.ServicePort{ + { + Name: "9090", + Port: int32(9090), + TargetPort: intstr.IntOrString{ + Type: intstr.Int, + IntVal: 9099, + }, + }, + }, + Selector: map[string]string{ + "foo": "bar", + }, + }, + }, + }, + }, + { + name: "aggregates all webhook definitions referencing the same deployment into a single service", + bundle: &render.RegistryV1{ + CSV: MakeCSV( + WithStrategyDeploymentSpecs( + v1alpha1.StrategyDeploymentSpec{ + Name: "my-deployment", + Spec: appsv1.DeploymentSpec{ + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "foo": "bar", + }, + }, + }, + }), + WithWebhookDefinitions( + v1alpha1.WebhookDescription{ + Type: v1alpha1.MutatingAdmissionWebhook, + DeploymentName: "my-deployment", + }, + v1alpha1.WebhookDescription{ + Type: v1alpha1.ValidatingAdmissionWebhook, + DeploymentName: "my-deployment", + ContainerPort: int32(8443), + }, + v1alpha1.WebhookDescription{ + Type: v1alpha1.ConversionWebhook, + DeploymentName: "my-deployment", + TargetPort: &intstr.IntOrString{ + Type: intstr.Int, + IntVal: 8080, + }, + }, + v1alpha1.WebhookDescription{ + Type: v1alpha1.ConversionWebhook, + DeploymentName: "my-deployment", + ContainerPort: int32(9090), + TargetPort: &intstr.IntOrString{ + Type: intstr.Int, + IntVal: 9099, + }, + }, + ), + ), + }, + opts: render.Options{ + InstallNamespace: "install-namespace", + TargetNamespaces: []string{"watch-namespace-one", "watch-namespace-two"}, + }, + expectedResources: []client.Object{ + &corev1.Service{ + TypeMeta: metav1.TypeMeta{ + Kind: "Service", + APIVersion: corev1.SchemeGroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "my-deployment-service", + Namespace: "install-namespace", + }, + Spec: corev1.ServiceSpec{ + Ports: []corev1.ServicePort{ + { + Name: "443", + Port: int32(443), + TargetPort: intstr.IntOrString{ + Type: intstr.Int, + IntVal: 443, + }, + }, { + Name: "443", + Port: int32(443), + TargetPort: intstr.IntOrString{ + Type: intstr.Int, + IntVal: 8080, + }, + }, { + Name: "8443", + Port: int32(8443), + TargetPort: intstr.IntOrString{ + Type: intstr.Int, + IntVal: 8443, + }, + }, { + Name: "9090", + Port: int32(9090), + TargetPort: intstr.IntOrString{ + Type: intstr.Int, + IntVal: 9099, + }, + }, + }, + Selector: map[string]string{ + "foo": "bar", + }, + }, + }, + }, + }, + { + name: "applies cert provider modifiers to webhook service", + bundle: &render.RegistryV1{ + CSV: MakeCSV( + WithStrategyDeploymentSpecs( + v1alpha1.StrategyDeploymentSpec{ + Name: "my-deployment", + }), + WithWebhookDefinitions( + v1alpha1.WebhookDescription{ + Type: v1alpha1.MutatingAdmissionWebhook, + DeploymentName: "my-deployment", + }, + ), + ), + }, + opts: render.Options{ + InstallNamespace: "install-namespace", + TargetNamespaces: []string{"watch-namespace-one", "watch-namespace-two"}, + CertificateProvider: fakeProvider, + }, + expectedResources: []client.Object{ + &corev1.Service{ + TypeMeta: metav1.TypeMeta{ + Kind: "Service", + APIVersion: corev1.SchemeGroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "my-deployment-service", + Namespace: "install-namespace", + Annotations: map[string]string{ + "cert-provider": "annotation", + }, + }, + Spec: corev1.ServiceSpec{ + Ports: []corev1.ServicePort{ + { + Name: "443", + Port: int32(443), + TargetPort: intstr.IntOrString{ + Type: intstr.Int, + IntVal: 443, + }, + }, + }, + }, + }, + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + objs, err := generators.BundleWebhookServiceResourceGenerator(tc.bundle, tc.opts) + require.NoError(t, err) + require.Equal(t, tc.expectedResources, objs) + }) + } +} + +func Test_BundleWebhookServiceResourceGenerator_FailsOnNil(t *testing.T) { + objs, err := generators.BundleMutatingWebhookResourceGenerator(nil, render.Options{}) + require.Nil(t, objs) + require.Error(t, err) + require.Contains(t, err.Error(), "bundle cannot be nil") +} + +func Test_CertProviderResourceGenerator_Succeeds(t *testing.T) { + fakeProvider := FakeCertProvider{ + AdditionalObjectsFn: func(cfg render.CertificateProvisionerConfig) ([]unstructured.Unstructured, error) { + return []unstructured.Unstructured{*ToUnstructuredT(t, &corev1.Secret{ + TypeMeta: metav1.TypeMeta{Kind: "Secret", APIVersion: corev1.SchemeGroupVersion.String()}, + ObjectMeta: metav1.ObjectMeta{ + Name: cfg.CertName, + }, + })}, nil + }, + } + + objs, err := generators.CertProviderResourceGenerator(&render.RegistryV1{ + CSV: MakeCSV( + WithWebhookDefinitions( + // only generate resources for deployments referenced by webhook definitions + v1alpha1.WebhookDescription{ + Type: v1alpha1.MutatingAdmissionWebhook, + DeploymentName: "my-deployment", + }, + ), + WithStrategyDeploymentSpecs( + v1alpha1.StrategyDeploymentSpec{ + Name: "my-deployment", + }, + v1alpha1.StrategyDeploymentSpec{ + Name: "my-other-deployment", + }, + ), + ), + }, render.Options{ + InstallNamespace: "install-namespace", + CertificateProvider: fakeProvider, + }) + require.NoError(t, err) + require.Equal(t, []client.Object{ + ToUnstructuredT(t, &corev1.Secret{ + TypeMeta: metav1.TypeMeta{Kind: "Secret", APIVersion: corev1.SchemeGroupVersion.String()}, + ObjectMeta: metav1.ObjectMeta{Name: "my-deployment-service-cert"}, + }), + }, objs) } diff --git a/internal/operator-controller/rukpak/render/generators/resources.go b/internal/operator-controller/rukpak/render/generators/resources.go index fa925f2f3..ed1cf6552 100644 --- a/internal/operator-controller/rukpak/render/generators/resources.go +++ b/internal/operator-controller/rukpak/render/generators/resources.go @@ -1,6 +1,7 @@ package generators import ( + admissionregistrationv1 "k8s.io/api/admissionregistration/v1" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" rbacv1 "k8s.io/api/rbac/v1" @@ -84,6 +85,36 @@ func WithLabels(labels map[string]string) func(client.Object) { } } +// WithServiceSpec applies a service spec to a Service resource +func WithServiceSpec(serviceSpec corev1.ServiceSpec) func(client.Object) { + return func(obj client.Object) { + switch o := obj.(type) { + case *corev1.Service: + o.Spec = serviceSpec + } + } +} + +// WithValidatingWebhooks applies validating webhooks to a ValidatingWebhookConfiguration resource +func WithValidatingWebhooks(webhooks ...admissionregistrationv1.ValidatingWebhook) func(client.Object) { + return func(obj client.Object) { + switch o := obj.(type) { + case *admissionregistrationv1.ValidatingWebhookConfiguration: + o.Webhooks = webhooks + } + } +} + +// WithMutatingWebhooks applies mutating webhooks to a MutatingWebhookConfiguration resource +func WithMutatingWebhooks(webhooks ...admissionregistrationv1.MutatingWebhook) func(client.Object) { + return func(obj client.Object) { + switch o := obj.(type) { + case *admissionregistrationv1.MutatingWebhookConfiguration: + o.Webhooks = webhooks + } + } +} + // CreateServiceAccountResource creates a ServiceAccount resource with name 'name', namespace 'namespace', and applying // any ServiceAccount related options in opts func CreateServiceAccountResource(name string, namespace string, opts ...ResourceCreatorOption) *corev1.ServiceAccount { @@ -183,3 +214,51 @@ func CreateDeploymentResource(name string, namespace string, opts ...ResourceCre }, ).(*appsv1.Deployment) } + +// CreateValidatingWebhookConfigurationResource creates a ValidatingWebhookConfiguration resource with name 'name', +// namespace 'namespace', and applying any ValidatingWebhookConfiguration related options in opts +func CreateValidatingWebhookConfigurationResource(name string, namespace string, opts ...ResourceCreatorOption) *admissionregistrationv1.ValidatingWebhookConfiguration { + return ResourceCreatorOptions(opts).ApplyTo( + &admissionregistrationv1.ValidatingWebhookConfiguration{ + TypeMeta: metav1.TypeMeta{ + Kind: "ValidatingWebhookConfiguration", + APIVersion: admissionregistrationv1.SchemeGroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + }, + ).(*admissionregistrationv1.ValidatingWebhookConfiguration) +} + +// CreateMutatingWebhookConfigurationResource creates a MutatingWebhookConfiguration resource with name 'name', +// namespace 'namespace', and applying any MutatingWebhookConfiguration related options in opts +func CreateMutatingWebhookConfigurationResource(name string, namespace string, opts ...ResourceCreatorOption) *admissionregistrationv1.MutatingWebhookConfiguration { + return ResourceCreatorOptions(opts).ApplyTo( + &admissionregistrationv1.MutatingWebhookConfiguration{ + TypeMeta: metav1.TypeMeta{ + Kind: "MutatingWebhookConfiguration", + APIVersion: admissionregistrationv1.SchemeGroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + }, + ).(*admissionregistrationv1.MutatingWebhookConfiguration) +} + +// CreateServiceResource creates a Service resource with name 'name', namespace 'namespace', and applying any Service related options in opts +func CreateServiceResource(name string, namespace string, opts ...ResourceCreatorOption) *corev1.Service { + return ResourceCreatorOptions(opts).ApplyTo(&corev1.Service{ + TypeMeta: metav1.TypeMeta{ + Kind: "Service", + APIVersion: corev1.SchemeGroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Namespace: namespace, + Name: name, + }, + }).(*corev1.Service) +} diff --git a/internal/operator-controller/rukpak/render/generators/resources_test.go b/internal/operator-controller/rukpak/render/generators/resources_test.go index 6aeed1c8f..7d6a95e33 100644 --- a/internal/operator-controller/rukpak/render/generators/resources_test.go +++ b/internal/operator-controller/rukpak/render/generators/resources_test.go @@ -7,6 +7,7 @@ import ( "testing" "github.com/stretchr/testify/require" + admissionregistrationv1 "k8s.io/api/admissionregistration/v1" corev1 "k8s.io/api/core/v1" rbacv1 "k8s.io/api/rbac/v1" "sigs.k8s.io/controller-runtime/pkg/client" @@ -74,6 +75,27 @@ func Test_CreateDeployment(t *testing.T) { require.Equal(t, "my-namespace", deployment.Namespace) } +func Test_CreateService(t *testing.T) { + svc := generators.CreateServiceResource("my-service", "my-namespace") + require.NotNil(t, svc) + require.Equal(t, "my-service", svc.Name) + require.Equal(t, "my-namespace", svc.Namespace) +} + +func Test_CreateValidatingWebhookConfiguration(t *testing.T) { + wh := generators.CreateValidatingWebhookConfigurationResource("my-validating-webhook-configuration", "my-namespace") + require.NotNil(t, wh) + require.Equal(t, "my-validating-webhook-configuration", wh.Name) + require.Equal(t, "my-namespace", wh.Namespace) +} + +func Test_CreateMutatingWebhookConfiguration(t *testing.T) { + wh := generators.CreateMutatingWebhookConfigurationResource("my-mutating-webhook-configuration", "my-namespace") + require.NotNil(t, wh) + require.Equal(t, "my-mutating-webhook-configuration", wh.Name) + require.Equal(t, "my-namespace", wh.Namespace) +} + func Test_WithSubjects(t *testing.T) { for _, tc := range []struct { name string @@ -208,3 +230,49 @@ func Test_WithLabels(t *testing.T) { }) } } + +func Test_WithServiceSpec(t *testing.T) { + svc := generators.CreateServiceResource("mysvc", "myns", generators.WithServiceSpec(corev1.ServiceSpec{ + ClusterIP: "1.2.3.4", + })) + require.NotNil(t, svc) + require.Equal(t, corev1.ServiceSpec{ + ClusterIP: "1.2.3.4", + }, svc.Spec) +} + +func Test_WithValidatingWebhook(t *testing.T) { + wh := generators.CreateValidatingWebhookConfigurationResource("mywh", "myns", + generators.WithValidatingWebhooks( + admissionregistrationv1.ValidatingWebhook{ + Name: "wh-one", + }, + admissionregistrationv1.ValidatingWebhook{ + Name: "wh-two", + }, + ), + ) + require.NotNil(t, wh) + require.Equal(t, []admissionregistrationv1.ValidatingWebhook{ + {Name: "wh-one"}, + {Name: "wh-two"}, + }, wh.Webhooks) +} + +func Test_WithMutatingWebhook(t *testing.T) { + wh := generators.CreateMutatingWebhookConfigurationResource("mywh", "myns", + generators.WithMutatingWebhooks( + admissionregistrationv1.MutatingWebhook{ + Name: "wh-one", + }, + admissionregistrationv1.MutatingWebhook{ + Name: "wh-two", + }, + ), + ) + require.NotNil(t, wh) + require.Equal(t, []admissionregistrationv1.MutatingWebhook{ + {Name: "wh-one"}, + {Name: "wh-two"}, + }, wh.Webhooks) +} diff --git a/internal/operator-controller/rukpak/render/render.go b/internal/operator-controller/rukpak/render/render.go index 279320afc..7e7a599bf 100644 --- a/internal/operator-controller/rukpak/render/render.go +++ b/internal/operator-controller/rukpak/render/render.go @@ -66,6 +66,7 @@ type Options struct { InstallNamespace string TargetNamespaces []string UniqueNameGenerator UniqueNameGenerator + CertificateProvider CertificateProvider } func (o *Options) apply(opts ...Option) *Options { @@ -91,6 +92,12 @@ func WithUniqueNameGenerator(generator UniqueNameGenerator) Option { } } +func WithCertificateProvider(provider CertificateProvider) Option { + return func(o *Options) { + o.CertificateProvider = provider + } +} + type BundleRenderer struct { BundleValidator BundleValidator ResourceGenerators []ResourceGenerator diff --git a/internal/operator-controller/rukpak/render/render_test.go b/internal/operator-controller/rukpak/render/render_test.go index 510a62987..07409ed5b 100644 --- a/internal/operator-controller/rukpak/render/render_test.go +++ b/internal/operator-controller/rukpak/render/render_test.go @@ -12,6 +12,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/render" + . "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/util/testing" ) func Test_BundleRenderer_NoConfig(t *testing.T) { @@ -81,6 +82,13 @@ func Test_WithUniqueNameGenerator(t *testing.T) { require.Equal(t, "a man needs a name", generatedName) } +func Test_WithCertificateProvide(t *testing.T) { + opts := &render.Options{} + expectedCertProvider := FakeCertProvider{} + render.WithCertificateProvider(expectedCertProvider)(opts) + require.Equal(t, expectedCertProvider, opts.CertificateProvider) +} + func Test_BundleRenderer_CallsResourceGenerators(t *testing.T) { renderer := render.BundleRenderer{ ResourceGenerators: []render.ResourceGenerator{ diff --git a/internal/operator-controller/rukpak/render/validators/validator.go b/internal/operator-controller/rukpak/render/validators/validator.go index d2ed950e5..ee77292a3 100644 --- a/internal/operator-controller/rukpak/render/validators/validator.go +++ b/internal/operator-controller/rukpak/render/validators/validator.go @@ -1,12 +1,17 @@ package validators import ( + "cmp" "errors" "fmt" + "maps" "slices" + "strings" "k8s.io/apimachinery/pkg/util/sets" + "github.com/operator-framework/api/pkg/operators/v1alpha1" + "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/render" ) @@ -19,6 +24,10 @@ var RegistryV1BundleValidator = render.BundleValidator{ CheckCRDResourceUniqueness, CheckOwnedCRDExistence, CheckPackageNameNotEmpty, + CheckWebhookDeploymentReferentialIntegrity, + CheckWebhookNameUniqueness, + CheckConversionWebhookCRDReferenceUniqueness, + CheckConversionWebhooksReferenceOwnedCRDs, } // CheckDeploymentSpecUniqueness checks that each strategy deployment spec in the csv has a unique name. @@ -86,3 +95,144 @@ func CheckPackageNameNotEmpty(rv1 *render.RegistryV1) []error { } return nil } + +// CheckWebhookSupport checks that if the bundle cluster service version declares webhook definitions +// that it is a singleton operator, i.e. that it only supports AllNamespaces mode. This keeps parity +// with OLMv0 behavior for conversion webhooks, +// https://github.com/operator-framework/operator-lifecycle-manager/blob/dfd0b2bea85038d3c0d65348bc812d297f16b8d2/pkg/controller/install/webhook.go#L193 +// Since OLMv1 considers APIs to be cluster-scoped, we initially extend this constraint to validating and mutating webhooks. +// While this might restrict the number of supported bundles, we can tackle the issue of relaxing this constraint in turn +// after getting the webhook support working. +func CheckWebhookSupport(rv1 *render.RegistryV1) []error { + if len(rv1.CSV.Spec.WebhookDefinitions) > 0 { + supportedInstallModes := sets.Set[v1alpha1.InstallModeType]{} + for _, mode := range rv1.CSV.Spec.InstallModes { + supportedInstallModes.Insert(mode.Type) + } + if len(supportedInstallModes) != 1 || !supportedInstallModes.Has(v1alpha1.InstallModeTypeAllNamespaces) { + return []error{errors.New("bundle contains webhook definitions but supported install modes beyond AllNamespaces")} + } + } + + return nil +} + +// CheckWebhookDeploymentReferentialIntegrity checks that each webhook definition in the csv +// references an existing strategy deployment spec. Errors are sorted by strategy deployment spec name, +// webhook type, and webhook name. +func CheckWebhookDeploymentReferentialIntegrity(rv1 *render.RegistryV1) []error { + webhooksByDeployment := map[string][]v1alpha1.WebhookDescription{} + for _, wh := range rv1.CSV.Spec.WebhookDefinitions { + webhooksByDeployment[wh.DeploymentName] = append(webhooksByDeployment[wh.DeploymentName], wh) + } + + for _, depl := range rv1.CSV.Spec.InstallStrategy.StrategySpec.DeploymentSpecs { + delete(webhooksByDeployment, depl.Name) + } + + var errs []error + // Loop through sorted keys to keep error messages ordered by deployment name + for _, deploymentName := range slices.Sorted(maps.Keys(webhooksByDeployment)) { + webhookDefns := webhooksByDeployment[deploymentName] + slices.SortFunc(webhookDefns, func(a, b v1alpha1.WebhookDescription) int { + return cmp.Or(cmp.Compare(a.Type, b.Type), cmp.Compare(a.GenerateName, b.GenerateName)) + }) + for _, webhookDef := range webhookDefns { + errs = append(errs, fmt.Errorf("webhook '%s' of type '%s' references non-existent deployment '%s'", webhookDef.GenerateName, webhookDef.Type, webhookDef.DeploymentName)) + } + } + return errs +} + +// CheckWebhookNameUniqueness checks that each webhook definition of each type (validating, mutating, or conversion) +// has a unique name. Webhooks of different types can have the same name. Errors are sorted by webhook type +// and name. +func CheckWebhookNameUniqueness(rv1 *render.RegistryV1) []error { + webhookNameSetByType := map[v1alpha1.WebhookAdmissionType]sets.Set[string]{} + duplicateWebhooksByType := map[v1alpha1.WebhookAdmissionType]sets.Set[string]{} + for _, wh := range rv1.CSV.Spec.WebhookDefinitions { + if _, ok := webhookNameSetByType[wh.Type]; !ok { + webhookNameSetByType[wh.Type] = sets.Set[string]{} + } + if webhookNameSetByType[wh.Type].Has(wh.GenerateName) { + if _, ok := duplicateWebhooksByType[wh.Type]; !ok { + duplicateWebhooksByType[wh.Type] = sets.Set[string]{} + } + duplicateWebhooksByType[wh.Type].Insert(wh.GenerateName) + } + webhookNameSetByType[wh.Type].Insert(wh.GenerateName) + } + + var errs []error + for _, whType := range slices.Sorted(maps.Keys(duplicateWebhooksByType)) { + for _, webhookName := range slices.Sorted(slices.Values(duplicateWebhooksByType[whType].UnsortedList())) { + errs = append(errs, fmt.Errorf("duplicate webhook '%s' of type '%s'", webhookName, whType)) + } + } + return errs +} + +// CheckConversionWebhooksReferenceOwnedCRDs checks defined conversion webhooks reference bundle owned CRDs. +// Errors are sorted by webhook name and CRD name. +func CheckConversionWebhooksReferenceOwnedCRDs(rv1 *render.RegistryV1) []error { + //nolint:prealloc + var conversionWebhooks []v1alpha1.WebhookDescription + for _, wh := range rv1.CSV.Spec.WebhookDefinitions { + if wh.Type != v1alpha1.ConversionWebhook { + continue + } + conversionWebhooks = append(conversionWebhooks, wh) + } + + if len(conversionWebhooks) == 0 { + return nil + } + + ownedCRDNames := sets.Set[string]{} + for _, crd := range rv1.CSV.Spec.CustomResourceDefinitions.Owned { + ownedCRDNames.Insert(crd.Name) + } + + slices.SortFunc(conversionWebhooks, func(a, b v1alpha1.WebhookDescription) int { + return cmp.Compare(a.GenerateName, b.GenerateName) + }) + + var errs []error + for _, webhook := range conversionWebhooks { + webhookCRDs := webhook.ConversionCRDs + slices.Sort(webhookCRDs) + for _, crd := range webhookCRDs { + if !ownedCRDNames.Has(crd) { + errs = append(errs, fmt.Errorf("conversion webhook '%s' references custom resource definition '%s' not owned bundle", webhook.GenerateName, crd)) + } + } + } + return errs +} + +// CheckConversionWebhookCRDReferenceUniqueness checks no two (or more) conversion webhooks reference the same CRD. +func CheckConversionWebhookCRDReferenceUniqueness(rv1 *render.RegistryV1) []error { + // collect webhooks by crd + crdToWh := map[string][]string{} + for _, wh := range rv1.CSV.Spec.WebhookDefinitions { + if wh.Type != v1alpha1.ConversionWebhook { + continue + } + for _, crd := range wh.ConversionCRDs { + crdToWh[crd] = append(crdToWh[crd], wh.GenerateName) + } + } + + // remove crds with single webhook + maps.DeleteFunc(crdToWh, func(crd string, whs []string) bool { + return len(whs) == 1 + }) + + errs := make([]error, 0, len(crdToWh)) + orderedCRDs := slices.Sorted(maps.Keys(crdToWh)) + for _, crd := range orderedCRDs { + orderedWhs := strings.Join(slices.Sorted(slices.Values(crdToWh[crd])), ",") + errs = append(errs, fmt.Errorf("conversion webhooks [%s] reference same custom resource definition '%s'", orderedWhs, crd)) + } + return errs +} diff --git a/internal/operator-controller/rukpak/render/validators/validator_test.go b/internal/operator-controller/rukpak/render/validators/validator_test.go index 17da3e640..d8a480bf6 100644 --- a/internal/operator-controller/rukpak/render/validators/validator_test.go +++ b/internal/operator-controller/rukpak/render/validators/validator_test.go @@ -13,7 +13,7 @@ import ( "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/render" "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/render/validators" - . "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/util" + . "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/util/testing" ) func Test_BundleValidatorHasAllValidationFns(t *testing.T) { @@ -22,6 +22,10 @@ func Test_BundleValidatorHasAllValidationFns(t *testing.T) { validators.CheckCRDResourceUniqueness, validators.CheckOwnedCRDExistence, validators.CheckPackageNameNotEmpty, + validators.CheckWebhookDeploymentReferentialIntegrity, + validators.CheckWebhookNameUniqueness, + validators.CheckConversionWebhookCRDReferenceUniqueness, + validators.CheckConversionWebhooksReferenceOwnedCRDs, } actualValidationFns := validators.RegistryV1BundleValidator @@ -216,3 +220,620 @@ func Test_CheckPackageNameNotEmpty(t *testing.T) { }) } } + +func Test_CheckWebhookSupport(t *testing.T) { + for _, tc := range []struct { + name string + bundle *render.RegistryV1 + expectedErrs []error + }{ + { + name: "accepts bundles with validating webhook definitions when they only support AllNamespaces install mode", + bundle: &render.RegistryV1{ + CSV: MakeCSV( + WithInstallModeSupportFor(v1alpha1.InstallModeTypeAllNamespaces), + WithWebhookDefinitions( + v1alpha1.WebhookDescription{ + Type: v1alpha1.ValidatingAdmissionWebhook, + }, + ), + ), + }, + }, + { + name: "accepts bundles with mutating webhook definitions when they only support AllNamespaces install mode", + bundle: &render.RegistryV1{ + CSV: MakeCSV( + WithInstallModeSupportFor(v1alpha1.InstallModeTypeAllNamespaces), + WithWebhookDefinitions( + v1alpha1.WebhookDescription{ + Type: v1alpha1.MutatingAdmissionWebhook, + }, + ), + ), + }, + }, + { + name: "accepts bundles with conversion webhook definitions when they only support AllNamespaces install mode", + bundle: &render.RegistryV1{ + CSV: MakeCSV( + WithInstallModeSupportFor(v1alpha1.InstallModeTypeAllNamespaces), + WithWebhookDefinitions( + v1alpha1.WebhookDescription{ + Type: v1alpha1.ConversionWebhook, + }, + ), + ), + }, + }, + { + name: "rejects bundles with validating webhook definitions when they support more modes than AllNamespaces install mode", + bundle: &render.RegistryV1{ + CSV: MakeCSV( + WithInstallModeSupportFor(v1alpha1.InstallModeTypeAllNamespaces, v1alpha1.InstallModeTypeSingleNamespace), + WithWebhookDefinitions( + v1alpha1.WebhookDescription{ + Type: v1alpha1.ValidatingAdmissionWebhook, + }, + ), + ), + }, + expectedErrs: []error{errors.New("bundle contains webhook definitions but supported install modes beyond AllNamespaces")}, + }, + { + name: "accepts bundles with mutating webhook definitions when they support more modes than AllNamespaces install mode", + bundle: &render.RegistryV1{ + CSV: MakeCSV( + WithInstallModeSupportFor(v1alpha1.InstallModeTypeAllNamespaces, v1alpha1.InstallModeTypeSingleNamespace), + WithWebhookDefinitions( + v1alpha1.WebhookDescription{ + Type: v1alpha1.MutatingAdmissionWebhook, + }, + ), + ), + }, + expectedErrs: []error{errors.New("bundle contains webhook definitions but supported install modes beyond AllNamespaces")}, + }, + { + name: "accepts bundles with conversion webhook definitions when they support more modes than AllNamespaces install mode", + bundle: &render.RegistryV1{ + CSV: MakeCSV( + WithInstallModeSupportFor(v1alpha1.InstallModeTypeAllNamespaces, v1alpha1.InstallModeTypeSingleNamespace), + WithWebhookDefinitions( + v1alpha1.WebhookDescription{ + Type: v1alpha1.ConversionWebhook, + }, + ), + ), + }, + expectedErrs: []error{errors.New("bundle contains webhook definitions but supported install modes beyond AllNamespaces")}, + }, + } { + t.Run(tc.name, func(t *testing.T) { + errs := validators.CheckWebhookSupport(tc.bundle) + require.Equal(t, tc.expectedErrs, errs) + }) + } +} + +func Test_CheckWebhookDeploymentReferentialIntegrity(t *testing.T) { + for _, tc := range []struct { + name string + bundle *render.RegistryV1 + expectedErrs []error + }{ + { + name: "accepts bundles where webhook definitions reference existing strategy deployment specs", + bundle: &render.RegistryV1{ + CSV: MakeCSV( + WithStrategyDeploymentSpecs( + v1alpha1.StrategyDeploymentSpec{Name: "test-deployment-one"}, + v1alpha1.StrategyDeploymentSpec{Name: "test-deployment-two"}, + ), + WithWebhookDefinitions( + v1alpha1.WebhookDescription{ + Type: v1alpha1.MutatingAdmissionWebhook, + GenerateName: "test-webhook", + DeploymentName: "test-deployment-one", + }, + ), + ), + }, + }, { + name: "rejects bundles with webhook definitions that reference non-existing strategy deployment specs", + bundle: &render.RegistryV1{ + CSV: MakeCSV( + WithStrategyDeploymentSpecs( + v1alpha1.StrategyDeploymentSpec{Name: "test-deployment-one"}, + ), + WithWebhookDefinitions( + v1alpha1.WebhookDescription{ + Type: v1alpha1.ValidatingAdmissionWebhook, + GenerateName: "test-webhook", + DeploymentName: "test-deployment-two", + }, + ), + ), + }, + expectedErrs: []error{ + errors.New("webhook 'test-webhook' of type 'ValidatingAdmissionWebhook' references non-existent deployment 'test-deployment-two'"), + }, + }, { + name: "errors are ordered by deployment strategy spec name, webhook type, and webhook name", + bundle: &render.RegistryV1{ + CSV: MakeCSV( + WithStrategyDeploymentSpecs( + v1alpha1.StrategyDeploymentSpec{Name: "test-deployment-one"}, + ), + WithWebhookDefinitions( + v1alpha1.WebhookDescription{ + Type: v1alpha1.ValidatingAdmissionWebhook, + GenerateName: "test-val-webhook-c", + DeploymentName: "test-deployment-c", + }, + v1alpha1.WebhookDescription{ + Type: v1alpha1.MutatingAdmissionWebhook, + GenerateName: "test-mute-webhook-a", + DeploymentName: "test-deployment-a", + }, + v1alpha1.WebhookDescription{ + Type: v1alpha1.ConversionWebhook, + GenerateName: "test-conv-webhook-b", + DeploymentName: "test-deployment-b", + }, v1alpha1.WebhookDescription{ + Type: v1alpha1.MutatingAdmissionWebhook, + GenerateName: "test-mute-webhook-c", + DeploymentName: "test-deployment-c", + }, + v1alpha1.WebhookDescription{ + Type: v1alpha1.ConversionWebhook, + GenerateName: "test-conv-webhook-c-b", + DeploymentName: "test-deployment-c", + }, v1alpha1.WebhookDescription{ + Type: v1alpha1.ConversionWebhook, + GenerateName: "test-conv-webhook-c-a", + DeploymentName: "test-deployment-c", + }, + ), + ), + }, + expectedErrs: []error{ + errors.New("webhook 'test-mute-webhook-a' of type 'MutatingAdmissionWebhook' references non-existent deployment 'test-deployment-a'"), + errors.New("webhook 'test-conv-webhook-b' of type 'ConversionWebhook' references non-existent deployment 'test-deployment-b'"), + errors.New("webhook 'test-conv-webhook-c-a' of type 'ConversionWebhook' references non-existent deployment 'test-deployment-c'"), + errors.New("webhook 'test-conv-webhook-c-b' of type 'ConversionWebhook' references non-existent deployment 'test-deployment-c'"), + errors.New("webhook 'test-mute-webhook-c' of type 'MutatingAdmissionWebhook' references non-existent deployment 'test-deployment-c'"), + errors.New("webhook 'test-val-webhook-c' of type 'ValidatingAdmissionWebhook' references non-existent deployment 'test-deployment-c'"), + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + errs := validators.CheckWebhookDeploymentReferentialIntegrity(tc.bundle) + require.Equal(t, tc.expectedErrs, errs) + }) + } +} + +func Test_CheckWebhookNameUniqueness(t *testing.T) { + for _, tc := range []struct { + name string + bundle *render.RegistryV1 + expectedErrs []error + }{ + { + name: "accepts bundles without webhook definitions", + bundle: &render.RegistryV1{ + CSV: MakeCSV(), + }, + }, { + name: "accepts bundles with unique webhook names", + bundle: &render.RegistryV1{ + CSV: MakeCSV( + WithWebhookDefinitions( + v1alpha1.WebhookDescription{ + Type: v1alpha1.MutatingAdmissionWebhook, + GenerateName: "test-webhook-one", + }, v1alpha1.WebhookDescription{ + Type: v1alpha1.ValidatingAdmissionWebhook, + GenerateName: "test-webhook-two", + }, v1alpha1.WebhookDescription{ + Type: v1alpha1.ConversionWebhook, + GenerateName: "test-webhook-three", + }, v1alpha1.WebhookDescription{ + Type: v1alpha1.MutatingAdmissionWebhook, + GenerateName: "test-webhook-four", + }, v1alpha1.WebhookDescription{ + Type: v1alpha1.ValidatingAdmissionWebhook, + GenerateName: "test-webhook-five", + }, v1alpha1.WebhookDescription{ + Type: v1alpha1.ConversionWebhook, + GenerateName: "test-webhook-six", + }, + ), + ), + }, + }, { + name: "accepts bundles with webhooks with the same name but different types", + bundle: &render.RegistryV1{ + CSV: MakeCSV( + WithWebhookDefinitions( + v1alpha1.WebhookDescription{ + Type: v1alpha1.MutatingAdmissionWebhook, + GenerateName: "test-webhook", + }, v1alpha1.WebhookDescription{ + Type: v1alpha1.ValidatingAdmissionWebhook, + GenerateName: "test-webhook", + }, v1alpha1.WebhookDescription{ + Type: v1alpha1.ConversionWebhook, + GenerateName: "test-webhook", + }, + ), + ), + }, + }, { + name: "rejects bundles with duplicate validating webhook definitions", + bundle: &render.RegistryV1{ + CSV: MakeCSV( + WithWebhookDefinitions( + v1alpha1.WebhookDescription{ + Type: v1alpha1.ValidatingAdmissionWebhook, + GenerateName: "test-webhook", + }, v1alpha1.WebhookDescription{ + Type: v1alpha1.ValidatingAdmissionWebhook, + GenerateName: "test-webhook", + }, + ), + ), + }, + expectedErrs: []error{ + errors.New("duplicate webhook 'test-webhook' of type 'ValidatingAdmissionWebhook'"), + }, + }, { + name: "rejects bundles with duplicate mutating webhook definitions", + bundle: &render.RegistryV1{ + CSV: MakeCSV( + WithWebhookDefinitions( + v1alpha1.WebhookDescription{ + Type: v1alpha1.MutatingAdmissionWebhook, + GenerateName: "test-webhook", + }, v1alpha1.WebhookDescription{ + Type: v1alpha1.MutatingAdmissionWebhook, + GenerateName: "test-webhook", + }, + ), + ), + }, + expectedErrs: []error{ + errors.New("duplicate webhook 'test-webhook' of type 'MutatingAdmissionWebhook'"), + }, + }, { + name: "rejects bundles with duplicate conversion webhook definitions", + bundle: &render.RegistryV1{ + CSV: MakeCSV( + WithWebhookDefinitions( + v1alpha1.WebhookDescription{ + Type: v1alpha1.ConversionWebhook, + GenerateName: "test-webhook", + }, v1alpha1.WebhookDescription{ + Type: v1alpha1.ConversionWebhook, + GenerateName: "test-webhook", + }, + ), + ), + }, + expectedErrs: []error{ + errors.New("duplicate webhook 'test-webhook' of type 'ConversionWebhook'"), + }, + }, { + name: "orders errors by webhook type and name", + bundle: &render.RegistryV1{ + CSV: MakeCSV( + WithWebhookDefinitions( + v1alpha1.WebhookDescription{ + Type: v1alpha1.ValidatingAdmissionWebhook, + GenerateName: "test-val-webhook-b", + }, v1alpha1.WebhookDescription{ + Type: v1alpha1.ValidatingAdmissionWebhook, + GenerateName: "test-val-webhook-a", + }, + v1alpha1.WebhookDescription{ + Type: v1alpha1.ValidatingAdmissionWebhook, + GenerateName: "test-val-webhook-a", + }, v1alpha1.WebhookDescription{ + Type: v1alpha1.ValidatingAdmissionWebhook, + GenerateName: "test-val-webhook-b", + }, + v1alpha1.WebhookDescription{ + Type: v1alpha1.ConversionWebhook, + GenerateName: "test-conv-webhook-b", + }, v1alpha1.WebhookDescription{ + Type: v1alpha1.ConversionWebhook, + GenerateName: "test-conv-webhook-a", + }, + v1alpha1.WebhookDescription{ + Type: v1alpha1.ConversionWebhook, + GenerateName: "test-conv-webhook-a", + }, v1alpha1.WebhookDescription{ + Type: v1alpha1.ConversionWebhook, + GenerateName: "test-conv-webhook-b", + }, v1alpha1.WebhookDescription{ + Type: v1alpha1.MutatingAdmissionWebhook, + GenerateName: "test-mute-webhook-b", + }, v1alpha1.WebhookDescription{ + Type: v1alpha1.MutatingAdmissionWebhook, + GenerateName: "test-mute-webhook-a", + }, + v1alpha1.WebhookDescription{ + Type: v1alpha1.MutatingAdmissionWebhook, + GenerateName: "test-mute-webhook-a", + }, v1alpha1.WebhookDescription{ + Type: v1alpha1.MutatingAdmissionWebhook, + GenerateName: "test-mute-webhook-b", + }, + ), + ), + }, + expectedErrs: []error{ + errors.New("duplicate webhook 'test-conv-webhook-a' of type 'ConversionWebhook'"), + errors.New("duplicate webhook 'test-conv-webhook-b' of type 'ConversionWebhook'"), + errors.New("duplicate webhook 'test-mute-webhook-a' of type 'MutatingAdmissionWebhook'"), + errors.New("duplicate webhook 'test-mute-webhook-b' of type 'MutatingAdmissionWebhook'"), + errors.New("duplicate webhook 'test-val-webhook-a' of type 'ValidatingAdmissionWebhook'"), + errors.New("duplicate webhook 'test-val-webhook-b' of type 'ValidatingAdmissionWebhook'"), + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + errs := validators.CheckWebhookNameUniqueness(tc.bundle) + require.Equal(t, tc.expectedErrs, errs) + }) + } +} + +func Test_CheckConversionWebhooksReferenceOwnedCRDs(t *testing.T) { + for _, tc := range []struct { + name string + bundle *render.RegistryV1 + expectedErrs []error + }{ + { + name: "accepts bundles without webhook definitions", + bundle: &render.RegistryV1{}, + }, { + name: "accepts bundles without conversion webhook definitions", + bundle: &render.RegistryV1{ + CSV: MakeCSV( + WithWebhookDefinitions( + v1alpha1.WebhookDescription{ + Type: v1alpha1.ValidatingAdmissionWebhook, + GenerateName: "test-val-webhook", + }, + v1alpha1.WebhookDescription{ + Type: v1alpha1.MutatingAdmissionWebhook, + GenerateName: "test-mute-webhook", + }, + ), + ), + }, + }, { + name: "accepts bundles with conversion webhooks that reference owned CRDs", + bundle: &render.RegistryV1{ + CSV: MakeCSV( + WithOwnedCRDs( + v1alpha1.CRDDescription{Name: "some.crd.something"}, + v1alpha1.CRDDescription{Name: "another.crd.something"}, + ), + WithWebhookDefinitions( + v1alpha1.WebhookDescription{ + Type: v1alpha1.ConversionWebhook, + GenerateName: "test-webhook", + ConversionCRDs: []string{ + "some.crd.something", + "another.crd.something", + }, + }, + ), + ), + }, + }, { + name: "rejects bundles with conversion webhooks that reference existing CRDs that are not owned", + bundle: &render.RegistryV1{ + CSV: MakeCSV( + WithOwnedCRDs( + v1alpha1.CRDDescription{Name: "some.crd.something"}, + ), + WithWebhookDefinitions( + v1alpha1.WebhookDescription{ + Type: v1alpha1.ConversionWebhook, + GenerateName: "test-webhook", + ConversionCRDs: []string{ + "some.crd.something", + "another.crd.something", + }, + }, + ), + ), + }, + expectedErrs: []error{ + errors.New("conversion webhook 'test-webhook' references custom resource definition 'another.crd.something' not owned bundle"), + }, + }, { + name: "errors are ordered by webhook name and CRD name", + bundle: &render.RegistryV1{ + CSV: MakeCSV( + WithOwnedCRDs( + v1alpha1.CRDDescription{Name: "b.crd.something"}, + ), + WithWebhookDefinitions( + v1alpha1.WebhookDescription{ + Type: v1alpha1.ConversionWebhook, + GenerateName: "test-webhook-b", + ConversionCRDs: []string{ + "b.crd.something", + }, + }, v1alpha1.WebhookDescription{ + Type: v1alpha1.ConversionWebhook, + GenerateName: "test-webhook-a", + ConversionCRDs: []string{ + "c.crd.something", + "a.crd.something", + }, + }, v1alpha1.WebhookDescription{ + Type: v1alpha1.ConversionWebhook, + GenerateName: "test-webhook-c", + ConversionCRDs: []string{ + "a.crd.something", + "d.crd.something", + }, + }, + ), + ), + }, + expectedErrs: []error{ + errors.New("conversion webhook 'test-webhook-a' references custom resource definition 'a.crd.something' not owned bundle"), + errors.New("conversion webhook 'test-webhook-a' references custom resource definition 'c.crd.something' not owned bundle"), + errors.New("conversion webhook 'test-webhook-c' references custom resource definition 'a.crd.something' not owned bundle"), + errors.New("conversion webhook 'test-webhook-c' references custom resource definition 'd.crd.something' not owned bundle"), + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + errs := validators.CheckConversionWebhooksReferenceOwnedCRDs(tc.bundle) + require.Equal(t, tc.expectedErrs, errs) + }) + } +} + +func Test_CheckConversionWebhookCRDReferenceUniqueness(t *testing.T) { + for _, tc := range []struct { + name string + bundle *render.RegistryV1 + expectedErrs []error + }{ + { + name: "accepts bundles without webhook definitions", + bundle: &render.RegistryV1{}, + expectedErrs: []error{}, + }, + { + name: "accepts bundles without conversion webhook definitions", + bundle: &render.RegistryV1{ + CSV: MakeCSV( + WithWebhookDefinitions( + v1alpha1.WebhookDescription{ + Type: v1alpha1.ValidatingAdmissionWebhook, + GenerateName: "test-val-webhook", + }, + v1alpha1.WebhookDescription{ + Type: v1alpha1.MutatingAdmissionWebhook, + GenerateName: "test-mute-webhook", + }, + ), + ), + }, + expectedErrs: []error{}, + }, + { + name: "accepts bundles with conversion webhooks that reference different CRDs", + bundle: &render.RegistryV1{ + CSV: MakeCSV( + WithOwnedCRDs( + v1alpha1.CRDDescription{Name: "some.crd.something"}, + v1alpha1.CRDDescription{Name: "another.crd.something"}, + ), + WithWebhookDefinitions( + v1alpha1.WebhookDescription{ + Type: v1alpha1.ConversionWebhook, + GenerateName: "test-webhook", + ConversionCRDs: []string{ + "some.crd.something", + }, + }, + v1alpha1.WebhookDescription{ + Type: v1alpha1.ConversionWebhook, + GenerateName: "test-webhook-2", + ConversionCRDs: []string{ + "another.crd.something", + }, + }, + ), + ), + }, + expectedErrs: []error{}, + }, + { + name: "rejects bundles with conversion webhooks that reference the same CRD", + bundle: &render.RegistryV1{ + CSV: MakeCSV( + WithOwnedCRDs( + v1alpha1.CRDDescription{Name: "some.crd.something"}, + ), + WithWebhookDefinitions( + v1alpha1.WebhookDescription{ + Type: v1alpha1.ConversionWebhook, + GenerateName: "test-webhook", + ConversionCRDs: []string{ + "some.crd.something", + }, + }, + v1alpha1.WebhookDescription{ + Type: v1alpha1.ConversionWebhook, + GenerateName: "test-webhook-two", + ConversionCRDs: []string{ + "some.crd.something", + }, + }, + ), + ), + }, + expectedErrs: []error{ + errors.New("conversion webhooks [test-webhook,test-webhook-two] reference same custom resource definition 'some.crd.something'"), + }, + }, + { + name: "errors are ordered by CRD name and webhook names", + bundle: &render.RegistryV1{ + CSV: MakeCSV( + WithOwnedCRDs( + v1alpha1.CRDDescription{Name: "b.crd.something"}, + ), + WithWebhookDefinitions( + v1alpha1.WebhookDescription{ + Type: v1alpha1.ConversionWebhook, + GenerateName: "test-webhook-b", + ConversionCRDs: []string{ + "b.crd.something", + "a.crd.something", + }, + }, v1alpha1.WebhookDescription{ + Type: v1alpha1.ConversionWebhook, + GenerateName: "test-webhook-a", + ConversionCRDs: []string{ + "d.crd.something", + "a.crd.something", + "b.crd.something", + }, + }, v1alpha1.WebhookDescription{ + Type: v1alpha1.ConversionWebhook, + GenerateName: "test-webhook-c", + ConversionCRDs: []string{ + "b.crd.something", + "d.crd.something", + }, + }, + ), + ), + }, + expectedErrs: []error{ + errors.New("conversion webhooks [test-webhook-a,test-webhook-b] reference same custom resource definition 'a.crd.something'"), + errors.New("conversion webhooks [test-webhook-a,test-webhook-b,test-webhook-c] reference same custom resource definition 'b.crd.something'"), + errors.New("conversion webhooks [test-webhook-a,test-webhook-c] reference same custom resource definition 'd.crd.something'"), + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + errs := validators.CheckConversionWebhookCRDReferenceUniqueness(tc.bundle) + require.Equal(t, tc.expectedErrs, errs) + }) + } +} diff --git a/internal/operator-controller/rukpak/util/testing.go b/internal/operator-controller/rukpak/util/testing.go deleted file mode 100644 index 4dfc12976..000000000 --- a/internal/operator-controller/rukpak/util/testing.go +++ /dev/null @@ -1,59 +0,0 @@ -package util - -import ( - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - - "github.com/operator-framework/api/pkg/operators/v1alpha1" -) - -type CSVOption func(version *v1alpha1.ClusterServiceVersion) - -//nolint:unparam -func WithName(name string) CSVOption { - return func(csv *v1alpha1.ClusterServiceVersion) { - csv.Name = name - } -} - -func WithStrategyDeploymentSpecs(strategyDeploymentSpecs ...v1alpha1.StrategyDeploymentSpec) CSVOption { - return func(csv *v1alpha1.ClusterServiceVersion) { - csv.Spec.InstallStrategy.StrategySpec.DeploymentSpecs = strategyDeploymentSpecs - } -} - -func WithAnnotations(annotations map[string]string) CSVOption { - return func(csv *v1alpha1.ClusterServiceVersion) { - csv.Annotations = annotations - } -} - -func WithPermissions(permissions ...v1alpha1.StrategyDeploymentPermissions) CSVOption { - return func(csv *v1alpha1.ClusterServiceVersion) { - csv.Spec.InstallStrategy.StrategySpec.Permissions = permissions - } -} - -func WithClusterPermissions(permissions ...v1alpha1.StrategyDeploymentPermissions) CSVOption { - return func(csv *v1alpha1.ClusterServiceVersion) { - csv.Spec.InstallStrategy.StrategySpec.ClusterPermissions = permissions - } -} - -func WithOwnedCRDs(crdDesc ...v1alpha1.CRDDescription) CSVOption { - return func(csv *v1alpha1.ClusterServiceVersion) { - csv.Spec.CustomResourceDefinitions.Owned = crdDesc - } -} - -func MakeCSV(opts ...CSVOption) v1alpha1.ClusterServiceVersion { - csv := v1alpha1.ClusterServiceVersion{ - TypeMeta: metav1.TypeMeta{ - APIVersion: v1alpha1.SchemeGroupVersion.String(), - Kind: "ClusterServiceVersion", - }, - } - for _, opt := range opts { - opt(&csv) - } - return csv -} diff --git a/internal/operator-controller/rukpak/util/testing/testing.go b/internal/operator-controller/rukpak/util/testing/testing.go index 4a247dc07..0a4ec84fe 100644 --- a/internal/operator-controller/rukpak/util/testing/testing.go +++ b/internal/operator-controller/rukpak/util/testing/testing.go @@ -1,9 +1,17 @@ package testing import ( + "testing" + + "github.com/stretchr/testify/require" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "sigs.k8s.io/controller-runtime/pkg/client" "github.com/operator-framework/api/pkg/operators/v1alpha1" + + "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/render" + "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/util" ) type CSVOption func(version *v1alpha1.ClusterServiceVersion) @@ -58,6 +66,12 @@ func WithInstallModeSupportFor(installModeType ...v1alpha1.InstallModeType) CSVO } } +func WithWebhookDefinitions(webhookDefinitions ...v1alpha1.WebhookDescription) CSVOption { + return func(csv *v1alpha1.ClusterServiceVersion) { + csv.Spec.WebhookDefinitions = webhookDefinitions + } +} + func MakeCSV(opts ...CSVOption) v1alpha1.ClusterServiceVersion { csv := v1alpha1.ClusterServiceVersion{ TypeMeta: metav1.TypeMeta{ @@ -70,3 +84,27 @@ func MakeCSV(opts ...CSVOption) v1alpha1.ClusterServiceVersion { } return csv } + +type FakeCertProvider struct { + InjectCABundleFn func(obj client.Object, cfg render.CertificateProvisionerConfig) error + AdditionalObjectsFn func(cfg render.CertificateProvisionerConfig) ([]unstructured.Unstructured, error) + GetCertSecretInfoFn func(cfg render.CertificateProvisionerConfig) render.CertSecretInfo +} + +func (f FakeCertProvider) InjectCABundle(obj client.Object, cfg render.CertificateProvisionerConfig) error { + return f.InjectCABundleFn(obj, cfg) +} + +func (f FakeCertProvider) AdditionalObjects(cfg render.CertificateProvisionerConfig) ([]unstructured.Unstructured, error) { + return f.AdditionalObjectsFn(cfg) +} + +func (f FakeCertProvider) GetCertSecretInfo(cfg render.CertificateProvisionerConfig) render.CertSecretInfo { + return f.GetCertSecretInfoFn(cfg) +} + +func ToUnstructuredT(t *testing.T, obj client.Object) *unstructured.Unstructured { + u, err := util.ToUnstructured(obj) + require.NoError(t, err) + return u +} diff --git a/internal/operator-controller/rukpak/util/testing_test.go b/internal/operator-controller/rukpak/util/testing_test.go deleted file mode 100644 index 17ca328f8..000000000 --- a/internal/operator-controller/rukpak/util/testing_test.go +++ /dev/null @@ -1,188 +0,0 @@ -package util - -import ( - "testing" - - "github.com/stretchr/testify/require" - rbacv1 "k8s.io/api/rbac/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - - "github.com/operator-framework/api/pkg/operators/v1alpha1" -) - -func Test_MakeCSV(t *testing.T) { - csv := MakeCSV() - require.Equal(t, v1alpha1.ClusterServiceVersion{ - TypeMeta: metav1.TypeMeta{ - Kind: "ClusterServiceVersion", - APIVersion: v1alpha1.SchemeGroupVersion.String(), - }, - }, csv) -} - -func Test_MakeCSV_WithName(t *testing.T) { - csv := MakeCSV(WithName("some-name")) - require.Equal(t, v1alpha1.ClusterServiceVersion{ - TypeMeta: metav1.TypeMeta{ - Kind: "ClusterServiceVersion", - APIVersion: v1alpha1.SchemeGroupVersion.String(), - }, - ObjectMeta: metav1.ObjectMeta{ - Name: "some-name", - }, - }, csv) -} - -func Test_MakeCSV_WithStrategyDeploymentSpecs(t *testing.T) { - csv := MakeCSV( - WithStrategyDeploymentSpecs( - v1alpha1.StrategyDeploymentSpec{ - Name: "spec-one", - }, - v1alpha1.StrategyDeploymentSpec{ - Name: "spec-two", - }, - ), - ) - - require.Equal(t, v1alpha1.ClusterServiceVersion{ - TypeMeta: metav1.TypeMeta{ - Kind: "ClusterServiceVersion", - APIVersion: v1alpha1.SchemeGroupVersion.String(), - }, - Spec: v1alpha1.ClusterServiceVersionSpec{ - InstallStrategy: v1alpha1.NamedInstallStrategy{ - StrategySpec: v1alpha1.StrategyDetailsDeployment{ - DeploymentSpecs: []v1alpha1.StrategyDeploymentSpec{ - { - Name: "spec-one", - }, - { - Name: "spec-two", - }, - }, - }, - }, - }, - }, csv) -} - -func Test_MakeCSV_WithPermissions(t *testing.T) { - csv := MakeCSV( - WithPermissions( - v1alpha1.StrategyDeploymentPermissions{ - ServiceAccountName: "service-account", - Rules: []rbacv1.PolicyRule{ - { - APIGroups: []string{""}, - Resources: []string{"secrets"}, - Verbs: []string{"list", "watch"}, - }, - }, - }, - v1alpha1.StrategyDeploymentPermissions{ - ServiceAccountName: "", - }, - ), - ) - - require.Equal(t, v1alpha1.ClusterServiceVersion{ - TypeMeta: metav1.TypeMeta{ - Kind: "ClusterServiceVersion", - APIVersion: v1alpha1.SchemeGroupVersion.String(), - }, - Spec: v1alpha1.ClusterServiceVersionSpec{ - InstallStrategy: v1alpha1.NamedInstallStrategy{ - StrategySpec: v1alpha1.StrategyDetailsDeployment{ - Permissions: []v1alpha1.StrategyDeploymentPermissions{ - { - ServiceAccountName: "service-account", - Rules: []rbacv1.PolicyRule{ - { - APIGroups: []string{""}, - Resources: []string{"secrets"}, - Verbs: []string{"list", "watch"}, - }, - }, - }, - { - ServiceAccountName: "", - }, - }, - }, - }, - }, - }, csv) -} - -func Test_MakeCSV_WithClusterPermissions(t *testing.T) { - csv := MakeCSV( - WithClusterPermissions( - v1alpha1.StrategyDeploymentPermissions{ - ServiceAccountName: "service-account", - Rules: []rbacv1.PolicyRule{ - { - APIGroups: []string{""}, - Resources: []string{"secrets"}, - Verbs: []string{"list", "watch"}, - }, - }, - }, - v1alpha1.StrategyDeploymentPermissions{ - ServiceAccountName: "", - }, - ), - ) - - require.Equal(t, v1alpha1.ClusterServiceVersion{ - TypeMeta: metav1.TypeMeta{ - Kind: "ClusterServiceVersion", - APIVersion: v1alpha1.SchemeGroupVersion.String(), - }, - Spec: v1alpha1.ClusterServiceVersionSpec{ - InstallStrategy: v1alpha1.NamedInstallStrategy{ - StrategySpec: v1alpha1.StrategyDetailsDeployment{ - ClusterPermissions: []v1alpha1.StrategyDeploymentPermissions{ - { - ServiceAccountName: "service-account", - Rules: []rbacv1.PolicyRule{ - { - APIGroups: []string{""}, - Resources: []string{"secrets"}, - Verbs: []string{"list", "watch"}, - }, - }, - }, - { - ServiceAccountName: "", - }, - }, - }, - }, - }, - }, csv) -} - -func Test_MakeCSV_WithOwnedCRDs(t *testing.T) { - csv := MakeCSV( - WithOwnedCRDs( - v1alpha1.CRDDescription{Name: "a.crd.something"}, - v1alpha1.CRDDescription{Name: "b.crd.something"}, - ), - ) - - require.Equal(t, v1alpha1.ClusterServiceVersion{ - TypeMeta: metav1.TypeMeta{ - Kind: "ClusterServiceVersion", - APIVersion: v1alpha1.SchemeGroupVersion.String(), - }, - Spec: v1alpha1.ClusterServiceVersionSpec{ - CustomResourceDefinitions: v1alpha1.CustomResourceDefinitions{ - Owned: []v1alpha1.CRDDescription{ - {Name: "a.crd.something"}, - {Name: "b.crd.something"}, - }, - }, - }, - }, csv) -} diff --git a/internal/operator-controller/rukpak/util/util.go b/internal/operator-controller/rukpak/util/util.go index b6f64d20b..503d7afa7 100644 --- a/internal/operator-controller/rukpak/util/util.go +++ b/internal/operator-controller/rukpak/util/util.go @@ -1,9 +1,12 @@ package util import ( + "errors" "fmt" "io" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" "k8s.io/cli-runtime/pkg/resource" "sigs.k8s.io/controller-runtime/pkg/client" ) @@ -17,6 +20,33 @@ func ObjectNameForBaseAndSuffix(base string, suffix string) string { return fmt.Sprintf("%s-%s", base, suffix) } +// ToUnstructured converts obj into an Unstructured. It expects the obj's gvk to be defined. If it is not, +// an error will be returned. +func ToUnstructured(obj client.Object) (*unstructured.Unstructured, error) { + if obj == nil { + return nil, errors.New("object is nil") + } + + gvk := obj.GetObjectKind().GroupVersionKind() + if len(gvk.Kind) == 0 { + return nil, errors.New("object has no kind") + } + if len(gvk.Version) == 0 { + return nil, errors.New("object has no version") + } + + var u unstructured.Unstructured + uObj, err := runtime.DefaultUnstructuredConverter.ToUnstructured(obj) + if err != nil { + return nil, fmt.Errorf("convert %s %q to unstructured: %w", gvk.Kind, obj.GetName(), err) + } + unstructured.RemoveNestedField(uObj, "metadata", "creationTimestamp") + unstructured.RemoveNestedField(uObj, "status") + u.Object = uObj + u.SetGroupVersionKind(gvk) + return &u, nil +} + func MergeMaps(maps ...map[string]string) map[string]string { out := map[string]string{} for _, m := range maps { diff --git a/internal/operator-controller/rukpak/util/util_test.go b/internal/operator-controller/rukpak/util/util_test.go index f5048abf1..60c1cd646 100644 --- a/internal/operator-controller/rukpak/util/util_test.go +++ b/internal/operator-controller/rukpak/util/util_test.go @@ -56,15 +56,6 @@ func TestMergeMaps(t *testing.T) { } } -// Mock reader for testing that always returns an error when Read is called -type errorReader struct { - io.Reader -} - -func (m errorReader) Read(p []byte) (int, error) { - return 0, errors.New("Oh no!") -} - func TestManifestObjects(t *testing.T) { tests := []struct { name string @@ -152,3 +143,54 @@ spec: }) } } + +func Test_ToUnstructured(t *testing.T) { + for _, tc := range []struct { + name string + obj client.Object + err error + }{ + { + name: "converts object to unstructured", + obj: &corev1.Service{ + TypeMeta: metav1.TypeMeta{Kind: "Service", APIVersion: "v1"}, + ObjectMeta: metav1.ObjectMeta{Name: "my-service", Namespace: "my-namespace"}, + }, + }, { + name: "fails if object doesn't define kind", + obj: &corev1.Service{ + TypeMeta: metav1.TypeMeta{Kind: "", APIVersion: "v1"}, + ObjectMeta: metav1.ObjectMeta{Name: "my-service", Namespace: "my-namespace"}, + }, + err: errors.New("object has no kind"), + }, { + name: "fails if object doesn't define version", + obj: &corev1.Service{ + TypeMeta: metav1.TypeMeta{Kind: "Service", APIVersion: ""}, + ObjectMeta: metav1.ObjectMeta{Name: "my-service", Namespace: "my-namespace"}, + }, + err: errors.New("object has no version"), + }, { + name: "fails if object is nil", + err: errors.New("object is nil"), + }, + } { + t.Run(tc.name, func(t *testing.T) { + out, err := util.ToUnstructured(tc.obj) + if tc.err != nil { + require.Error(t, err) + } else { + assert.Equal(t, tc.obj.GetObjectKind().GroupVersionKind(), out.GroupVersionKind()) + } + }) + } +} + +// Mock reader for testing that always returns an error when Read is called +type errorReader struct { + io.Reader +} + +func (m errorReader) Read(p []byte) (int, error) { + return 0, errors.New("Oh no!") +}