diff --git a/go.mod b/go.mod index 246e5a447..7db3f3c37 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.34.3 github.com/fsnotify/fsnotify v1.9.0 @@ -43,7 +44,7 @@ require ( ) require ( - cel.dev/expr v0.19.0 // indirect + cel.dev/expr v0.19.1 // indirect dario.cat/mergo v1.0.1 // indirect github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 // indirect github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect @@ -55,7 +56,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 @@ -90,7 +91,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 @@ -121,10 +122,10 @@ 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.23.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1 // indirect github.com/h2non/filetype v1.1.3 // indirect github.com/h2non/go-is-svg v0.0.0-20160927212452-35e8c4b0612c // indirect github.com/hashicorp/errwrap v1.1.0 // indirect @@ -208,12 +209,12 @@ require ( go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 // indirect go.opentelemetry.io/otel v1.33.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.32.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.32.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0 // indirect go.opentelemetry.io/otel/metric v1.33.0 // indirect go.opentelemetry.io/otel/sdk v1.33.0 // indirect go.opentelemetry.io/otel/trace v1.33.0 // indirect - go.opentelemetry.io/proto/otlp v1.3.1 // indirect + go.opentelemetry.io/proto/otlp v1.4.0 // indirect golang.org/x/crypto v0.37.0 // indirect golang.org/x/mod v0.24.0 // indirect golang.org/x/net v0.39.0 // indirect @@ -235,7 +236,8 @@ require ( k8s.io/kube-openapi v0.0.0-20241212222426-2c72e554b1e7 // indirect k8s.io/kubectl v0.32.2 // 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 41c39ba01..ad8db7b5f 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -cel.dev/expr v0.19.0 h1:lXuo+nDhpyJSpWxpPVi5cPUwzKb+dsdOiw6IreM5yt0= -cel.dev/expr v0.19.0/go.mod h1:MrpN08Q+lEBs+bGYdLxxHkZoUSsCp0nSKTs0nTymJgw= +cel.dev/expr v0.19.1 h1:NciYrtDRIR0lNCnH1LFJegdjspNx9fI59O7TWcua/W4= +cel.dev/expr v0.19.1/go.mod h1:MrpN08Q+lEBs+bGYdLxxHkZoUSsCp0nSKTs0nTymJgw= cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= @@ -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= @@ -259,16 +261,16 @@ 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= github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= github.com/grpc-ecosystem/go-grpc-prometheus v1.2.1-0.20210315223345-82c243799c99 h1:JYghRBlGCZyCF2wNUJ8W0cwaQdtpcssJ4CgC406g+WU= github.com/grpc-ecosystem/go-grpc-prometheus v1.2.1-0.20210315223345-82c243799c99/go.mod h1:3bDW6wMZJB7tiONtC/1Xpicra6Wp5GgbTbQWCbI5fkc= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.23.0 h1:ad0vkEBuk23VJzZR9nkLVG0YAoN9coASF1GusYX6AlU= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.23.0/go.mod h1:igFoXX2ELCW06bol23DWPB5BEWfZISOzSP5K2sbLea0= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1 h1:VNqngBF40hVlDloBruUehVYC3ArSgIyScOAyMRqBxRg= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1/go.mod h1:RBRO7fro65R6tjKzYgLAFo0t1QEXY1Dp+i/bvpRiqiQ= github.com/h2non/filetype v1.1.3 h1:FKkx9QbD7HR/zjK1Ia5XiBsq9zdLi5Kf3zGyFTAFkGg= github.com/h2non/filetype v1.1.3/go.mod h1:319b3zT68BvV+WRj7cwy856M2ehB3HqNOt6sy1HndBY= github.com/h2non/go-is-svg v0.0.0-20160927212452-35e8c4b0612c h1:fEE5/5VNnYUoBOj2I9TP8Jc+a7lge3QWn9DKE7NCwfc= @@ -339,8 +341,8 @@ github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxU github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= github.com/mattn/go-sqlite3 v1.14.24/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= @@ -523,12 +525,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= @@ -539,8 +541,8 @@ go.opentelemetry.io/contrib/bridges/prometheus v0.57.0 h1:UW0+QyeyBVhn+COBec3nGh go.opentelemetry.io/contrib/bridges/prometheus v0.57.0/go.mod h1:ppciCHRLsyCio54qbzQv0E4Jyth/fLWDTJYfvWpcSVk= go.opentelemetry.io/contrib/exporters/autoexport v0.57.0 h1:jmTVJ86dP60C01K3slFQa2NQ/Aoi7zA+wy7vMOKD9H4= go.opentelemetry.io/contrib/exporters/autoexport v0.57.0/go.mod h1:EJBheUMttD/lABFyLXhce47Wr6DPWYReCzaZiXadH7g= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0 h1:r6I7RJCN86bpD/FQwedZ0vSixDpwuWREjW9oRMsmqDc= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0/go.mod h1:B9yO6b04uB80CzjedvewuqDhxJxi11s7/GtiGa8bAjI= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.58.0 h1:PS8wXpbyaDJQ2VDHHncMe9Vct0Zn1fEjpsjrLxGJoSc= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.58.0/go.mod h1:HDBUsEjOuRC0EzKZ1bSaRGZWUBAzo+MhAcUUORSr4D0= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 h1:yd02MEjBdJkG3uabWP9apV+OuWRIXGDuJEUJbOHmCFU= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0/go.mod h1:umTcuxiv1n/s/S6/c2AT/g2CQ7u5C59sHDNmfSwgz7Q= go.opentelemetry.io/otel v1.33.0 h1:/FerN9bax5LoK51X/sI0SVYrjSE0/yUL7DpxW4K3FWw= @@ -553,10 +555,10 @@ go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.32.0 h1:j7Z go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.32.0/go.mod h1:WXbYJTUaZXAbYd8lbgGuvih0yuCfOFC5RJoYnoLcGz8= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.32.0 h1:t/Qur3vKSkUCcDVaSumWF2PKHt85pc7fRvFuoVT8qFU= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.32.0/go.mod h1:Rl61tySSdcOJWoEgYZVtmnKdA0GeKrSqkHC1t+91CH8= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.32.0 h1:IJFEoHiytixx8cMiVAO+GmHR6Frwu+u5Ur8njpFO6Ac= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.32.0/go.mod h1:3rHrKNtLIoS0oZwkY2vxi+oJcwFRWdtUyRII+so45p8= -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 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.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= @@ -579,8 +581,8 @@ go.opentelemetry.io/otel/sdk/metric v1.32.0 h1:rZvFnvmvawYb0alrYkjraqJq0Z4ZUJAiy go.opentelemetry.io/otel/sdk/metric v1.32.0/go.mod h1:PWeZlq0zt9YkYAp3gjKZ0eicRYvOh1Gd+X99x6GHpCQ= go.opentelemetry.io/otel/trace v1.33.0 h1:cCJuF7LRjUFso9LPnEAHJDB2pqzp+hbO8eu1qqW2d/s= go.opentelemetry.io/otel/trace v1.33.0/go.mod h1:uIcdVUZMpTAmz0tI1z04GoVSezK37CbGV4fr1f2nBck= -go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0= -go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8= +go.opentelemetry.io/proto/otlp v1.4.0 h1:TA9WRvW6zMwP+Ssb6fLoUIuirti1gGbP28GcKG1jgeg= +go.opentelemetry.io/proto/otlp v1.4.0/go.mod h1:PPBWZIP98o2ElSqI35IHfu7hIhSwvc5N38Jw8pXuGFY= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= @@ -793,10 +795,12 @@ k8s.io/utils v0.0.0-20241210054802-24370beab758 h1:sdbE21q2nlQtFh65saZY+rRM6x6aJ k8s.io/utils v0.0.0-20241210054802-24370beab758/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= oras.land/oras-go v1.2.5 h1:XpYuAwAb0DfQsunIyMfeET92emK8km3W4yEzZvUbsTo= oras.land/oras-go v1.2.5/go.mod h1:PuAwRShRZCsZb7g8Ar3jKKQR/2A/qN+pkYxIOd/FAoo= -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.2 h1:/439OZVxoEc02psi1h4QO3bHzTgu49bb347Xp4gW1pc= sigs.k8s.io/controller-runtime v0.20.2/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 885f3b4db..523962386 100644 --- a/internal/operator-controller/features/features.go +++ b/internal/operator-controller/features/features.go @@ -10,6 +10,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{ @@ -29,6 +30,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 cf12ebe42..1975f3a11 100644 --- a/internal/operator-controller/rukpak/convert/registryv1.go +++ b/internal/operator-controller/rukpak/convert/registryv1.go @@ -7,37 +7,27 @@ import ( "fmt" "io/fs" "path/filepath" - "strings" "helm.sh/helm/v3/pkg/chart" - 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/sets" "k8s.io/cli-runtime/pkg/resource" - "k8s.io/utils/ptr" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/yaml" "github.com/operator-framework/api/pkg/operators/v1alpha1" "github.com/operator-framework/operator-registry/alpha/property" - registrybundle "github.com/operator-framework/operator-registry/pkg/lib/bundle" + "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/util" + "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" ) -type RegistryV1 struct { - PackageName string - CSV v1alpha1.ClusterServiceVersion - CRDs []apiextensionsv1.CustomResourceDefinition - Others []unstructured.Unstructured -} - type Plain struct { Objects []client.Object } @@ -70,7 +60,7 @@ func RegistryV1ToHelmChart(rv1 fs.FS, installNamespace string, watchNamespace st return chrt, nil } -// ParseFS converts the rv1 filesystem into a RegistryV1. +// ParseFS converts the rv1 filesystem into a render.RegistryV1. // ParseFS expects the filesystem to conform to the registry+v1 format: // metadata/annotations.yaml // manifests/ @@ -78,8 +68,8 @@ func RegistryV1ToHelmChart(rv1 fs.FS, installNamespace string, watchNamespace st // - ... // // manifests directory does not contain subdirectories -func ParseFS(rv1 fs.FS) (RegistryV1, error) { - reg := RegistryV1{} +func ParseFS(rv1 fs.FS) (render.RegistryV1, error) { + reg := render.RegistryV1{} annotationsFileData, err := fs.ReadFile(rv1, filepath.Join("metadata", "annotations.yaml")) if err != nil { return reg, err @@ -224,22 +214,27 @@ func validateTargetNamespaces(supportedInstallModes sets.Set[string], installNam return fmt.Errorf("supported install modes %v do not support target namespaces %v", sets.List[string](supportedInstallModes), targetNamespaces) } -func saNameOrDefault(saName string) string { - if saName == "" { - return "default" - } - return saName +var PlainConverter = Converter{ + BundleRenderer: render.BundleRenderer{ + BundleValidator: validators.RegistryV1BundleValidator, + ResourceGenerators: []render.ResourceGenerator{ + generators.BundleCSVRBACResourceGenerator.ResourceGenerator(), + generators.BundleCRDGenerator, + generators.BundleAdditionalResourcesGenerator, + generators.BundleCSVDeploymentGenerator, + generators.BundleValidatingWebhookResourceGenerator, + generators.BundleMutatingWebhookResourceGenerator, + generators.BundleWebhookServiceResourceGenerator, + generators.CertProviderResourceGenerator, + }, + }, } type Converter struct { - BundleValidator BundleValidator + render.BundleRenderer } -func (c Converter) Convert(rv1 RegistryV1, installNamespace string, targetNamespaces []string) (*Plain, error) { - if err := c.BundleValidator.Validate(&rv1); err != nil { - return nil, err - } - +func (c Converter) Convert(rv1 render.RegistryV1, installNamespace string, targetNamespaces []string) (*Plain, error) { if installNamespace == "" { installNamespace = rv1.CSV.Annotations["operatorframework.io/suggested-namespace"] } @@ -268,250 +263,13 @@ func (c Converter) Convert(rv1 RegistryV1, installNamespace string, targetNamesp 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") } - deployments := []appsv1.Deployment{} - serviceAccounts := map[string]corev1.ServiceAccount{} - for _, depSpec := range rv1.CSV.Spec.InstallStrategy.StrategySpec.DeploymentSpecs { - annotations := util.MergeMaps(rv1.CSV.Annotations, depSpec.Spec.Template.Annotations) - annotations["olm.targetNamespaces"] = strings.Join(targetNamespaces, ",") - depSpec.Spec.Template.Annotations = annotations - - // Hardcode the deployment with RevisionHistoryLimit=1 to replicate OLMv0 behavior - // https://github.com/operator-framework/operator-lifecycle-manager/blob/dfd0b2bea85038d3c0d65348bc812d297f16b8d2/pkg/controller/install/deployment.go#L181 - depSpec.Spec.RevisionHistoryLimit = ptr.To(int32(1)) - - deployments = append(deployments, appsv1.Deployment{ - TypeMeta: metav1.TypeMeta{ - Kind: "Deployment", - APIVersion: appsv1.SchemeGroupVersion.String(), - }, - - ObjectMeta: metav1.ObjectMeta{ - Namespace: installNamespace, - Name: depSpec.Name, - Labels: depSpec.Label, - }, - Spec: depSpec.Spec, - }) - saName := saNameOrDefault(depSpec.Spec.Template.Spec.ServiceAccountName) - serviceAccounts[saName] = newServiceAccount(installNamespace, saName) - } - - // NOTES: - // 1. There's an extra Role for OperatorConditions: get/update/patch; resourceName=csv.name - // - This is managed by the OperatorConditions controller here: https://github.com/operator-framework/operator-lifecycle-manager/blob/9ced412f3e263b8827680dc0ad3477327cd9a508/pkg/controller/operators/operatorcondition_controller.go#L106-L109 - // 2. There's an extra RoleBinding for the above mentioned role. - // - Every SA mentioned in the OperatorCondition.spec.serviceAccounts is a subject for this role binding: https://github.com/operator-framework/operator-lifecycle-manager/blob/9ced412f3e263b8827680dc0ad3477327cd9a508/pkg/controller/operators/operatorcondition_controller.go#L171-L177 - // 3. strategySpec.permissions are _also_ given a clusterrole/clusterrole binding. - // - (for AllNamespaces mode only?) - // - (where does the extra namespaces get/list/watch rule come from?) - - roles := []rbacv1.Role{} - roleBindings := []rbacv1.RoleBinding{} - clusterRoles := []rbacv1.ClusterRole{} - clusterRoleBindings := []rbacv1.ClusterRoleBinding{} - - permissions := rv1.CSV.Spec.InstallStrategy.StrategySpec.Permissions - clusterPermissions := rv1.CSV.Spec.InstallStrategy.StrategySpec.ClusterPermissions - allPermissions := append(permissions, clusterPermissions...) - - // Create all the service accounts - for _, permission := range allPermissions { - saName := saNameOrDefault(permission.ServiceAccountName) - if _, ok := serviceAccounts[saName]; !ok { - serviceAccounts[saName] = newServiceAccount(installNamespace, saName) - } - } - - // If we're in AllNamespaces mode, promote the permissions to clusterPermissions - if len(targetNamespaces) == 1 && targetNamespaces[0] == "" { - for _, p := range permissions { - p.Rules = append(p.Rules, rbacv1.PolicyRule{ - Verbs: []string{"get", "list", "watch"}, - APIGroups: []string{corev1.GroupName}, - Resources: []string{"namespaces"}, - }) - clusterPermissions = append(clusterPermissions, p) - } - permissions = nil - } - - for _, ns := range targetNamespaces { - for _, permission := range permissions { - saName := saNameOrDefault(permission.ServiceAccountName) - name, err := generateName(fmt.Sprintf("%s-%s", rv1.CSV.Name, saName), permission) - if err != nil { - return nil, err - } - roles = append(roles, newRole(ns, name, permission.Rules)) - roleBindings = append(roleBindings, newRoleBinding(ns, name, name, installNamespace, saName)) - } - } - - for _, permission := range clusterPermissions { - saName := saNameOrDefault(permission.ServiceAccountName) - name, err := generateName(fmt.Sprintf("%s-%s", rv1.CSV.Name, saName), permission) - if err != nil { - return nil, err - } - clusterRoles = append(clusterRoles, newClusterRole(name, permission.Rules)) - clusterRoleBindings = append(clusterRoleBindings, newClusterRoleBinding(name, name, installNamespace, saName)) - } - - objs := []client.Object{} - for _, obj := range serviceAccounts { - obj := obj - if obj.GetName() != "default" { - objs = append(objs, &obj) - } - } - for _, obj := range roles { - obj := obj - objs = append(objs, &obj) - } - for _, obj := range roleBindings { - obj := obj - objs = append(objs, &obj) - } - for _, obj := range clusterRoles { - obj := obj - objs = append(objs, &obj) - } - for _, obj := range clusterRoleBindings { - obj := obj - objs = append(objs, &obj) - } - for _, obj := range rv1.CRDs { - objs = append(objs, &obj) - } - for _, obj := range rv1.Others { - obj := obj - supported, namespaced := registrybundle.IsSupported(obj.GetKind()) - if !supported { - return nil, fmt.Errorf("bundle contains unsupported resource: Name: %v, Kind: %v", obj.GetName(), obj.GetKind()) - } - if namespaced { - obj.SetNamespace(installNamespace) - } - objs = append(objs, &obj) - } - for _, obj := range deployments { - obj := obj - objs = append(objs, &obj) - } - return &Plain{Objects: objs}, nil -} - -var PlainConverter = Converter{ - BundleValidator: RegistryV1BundleValidator, -} - -const maxNameLength = 63 - -func generateName(base string, o interface{}) (string, error) { - hashStr, err := util.DeepHashObject(o) + objs, err := c.BundleRenderer.Render(rv1, installNamespace, targetNamespaces, certproviders.WithCertManagerCertificateProvider()) if err != nil { - return "", err - } - if len(base)+len(hashStr) > maxNameLength { - base = base[:maxNameLength-len(hashStr)-1] - } - - return fmt.Sprintf("%s-%s", base, hashStr), nil -} - -func newServiceAccount(namespace, name string) corev1.ServiceAccount { - return corev1.ServiceAccount{ - TypeMeta: metav1.TypeMeta{ - Kind: "ServiceAccount", - APIVersion: corev1.SchemeGroupVersion.String(), - }, - ObjectMeta: metav1.ObjectMeta{ - Namespace: namespace, - Name: name, - }, - } -} - -func newRole(namespace, name string, rules []rbacv1.PolicyRule) rbacv1.Role { - return rbacv1.Role{ - TypeMeta: metav1.TypeMeta{ - Kind: "Role", - APIVersion: rbacv1.SchemeGroupVersion.String(), - }, - ObjectMeta: metav1.ObjectMeta{ - Namespace: namespace, - Name: name, - }, - Rules: rules, - } -} - -func newClusterRole(name string, rules []rbacv1.PolicyRule) rbacv1.ClusterRole { - return rbacv1.ClusterRole{ - TypeMeta: metav1.TypeMeta{ - Kind: "ClusterRole", - APIVersion: rbacv1.SchemeGroupVersion.String(), - }, - ObjectMeta: metav1.ObjectMeta{ - Name: name, - }, - Rules: rules, - } -} - -func newRoleBinding(namespace, name, roleName, saNamespace string, saNames ...string) rbacv1.RoleBinding { - subjects := make([]rbacv1.Subject, 0, len(saNames)) - for _, saName := range saNames { - subjects = append(subjects, rbacv1.Subject{ - Kind: "ServiceAccount", - Namespace: saNamespace, - Name: saName, - }) - } - return rbacv1.RoleBinding{ - TypeMeta: metav1.TypeMeta{ - Kind: "RoleBinding", - APIVersion: rbacv1.SchemeGroupVersion.String(), - }, - ObjectMeta: metav1.ObjectMeta{ - Namespace: namespace, - Name: name, - }, - Subjects: subjects, - RoleRef: rbacv1.RoleRef{ - APIGroup: rbacv1.GroupName, - Kind: "Role", - Name: roleName, - }, - } -} - -func newClusterRoleBinding(name, roleName, saNamespace string, saNames ...string) rbacv1.ClusterRoleBinding { - subjects := make([]rbacv1.Subject, 0, len(saNames)) - for _, saName := range saNames { - subjects = append(subjects, rbacv1.Subject{ - Kind: "ServiceAccount", - Namespace: saNamespace, - Name: saName, - }) - } - return rbacv1.ClusterRoleBinding{ - TypeMeta: metav1.TypeMeta{ - Kind: "ClusterRoleBinding", - APIVersion: rbacv1.SchemeGroupVersion.String(), - }, - ObjectMeta: metav1.ObjectMeta{ - Name: name, - }, - Subjects: subjects, - RoleRef: rbacv1.RoleRef{ - APIGroup: rbacv1.GroupName, - Kind: "ClusterRole", - Name: roleName, - }, + return nil, err } + return &Plain{Objects: objs}, nil } diff --git a/internal/operator-controller/rukpak/convert/registryv1_test.go b/internal/operator-controller/rukpak/convert/registryv1_test.go index 8d6b90a29..49296ae56 100644 --- a/internal/operator-controller/rukpak/convert/registryv1_test.go +++ b/internal/operator-controller/rukpak/convert/registryv1_test.go @@ -1,7 +1,6 @@ package convert_test import ( - "errors" "fmt" "io/fs" "os" @@ -19,13 +18,17 @@ 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" . "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/util/testing" filterutil "github.com/operator-framework/operator-controller/internal/shared/util/filter" ) @@ -50,22 +53,8 @@ func getCsvAndService() (v1alpha1.ClusterServiceVersion, corev1.Service) { return csv, svc } -func TestConverterValidatesBundle(t *testing.T) { - converter := convert.Converter{ - BundleValidator: []func(rv1 *convert.RegistryV1) []error{ - func(rv1 *convert.RegistryV1) []error { - return []error{errors.New("test error")} - }, - }, - } - - _, err := converter.Convert(convert.RegistryV1{}, "installNamespace", []string{"watchNamespace"}) - require.Error(t, err) - require.Contains(t, err.Error(), "test error") -} - func TestPlainConverterUsedRegV1Validator(t *testing.T) { - require.Equal(t, convert.RegistryV1BundleValidator, convert.PlainConverter.BundleValidator) + require.Equal(t, validators.RegistryV1BundleValidator, convert.PlainConverter.BundleValidator) } func TestRegistryV1SuiteNamespaceNotAvailable(t *testing.T) { @@ -79,7 +68,7 @@ func TestRegistryV1SuiteNamespaceNotAvailable(t *testing.T) { csv, svc := getCsvAndService() unstructuredSvc := convertToUnstructured(t, svc) - registryv1Bundle := convert.RegistryV1{ + registryv1Bundle := render.RegistryV1{ PackageName: "testPkg", CSV: csv, Others: []unstructured.Unstructured{unstructuredSvc}, @@ -113,7 +102,7 @@ func TestRegistryV1SuiteNamespaceAvailable(t *testing.T) { unstructuredSvc := convertToUnstructured(t, svc) unstructuredSvc.SetGroupVersionKind(schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Service"}) - registryv1Bundle := convert.RegistryV1{ + registryv1Bundle := render.RegistryV1{ PackageName: "testPkg", CSV: csv, Others: []unstructured.Unstructured{unstructuredSvc}, @@ -154,7 +143,7 @@ func TestRegistryV1SuiteNamespaceUnsupportedKind(t *testing.T) { unstructuredEvt := convertToUnstructured(t, event) unstructuredEvt.SetGroupVersionKind(schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Event"}) - registryv1Bundle := convert.RegistryV1{ + registryv1Bundle := render.RegistryV1{ PackageName: "testPkg", CSV: csv, Others: []unstructured.Unstructured{unstructuredEvt}, @@ -188,7 +177,7 @@ func TestRegistryV1SuiteNamespaceClusterScoped(t *testing.T) { unstructuredpriorityclass := convertToUnstructured(t, pc) unstructuredpriorityclass.SetGroupVersionKind(schema.GroupVersionKind{Group: "", Version: "v1", Kind: "PriorityClass"}) - registryv1Bundle := convert.RegistryV1{ + registryv1Bundle := render.RegistryV1{ PackageName: "testPkg", CSV: csv, Others: []unstructured.Unstructured{unstructuredpriorityclass}, @@ -267,7 +256,7 @@ func TestRegistryV1SuiteGenerateAllNamespace(t *testing.T) { t.Log("By creating a registry v1 bundle") watchNamespaces := []string{""} unstructuredSvc := convertToUnstructured(t, svc) - registryv1Bundle := convert.RegistryV1{ + registryv1Bundle := render.RegistryV1{ PackageName: "testPkg", CSV: *csv, Others: []unstructured.Unstructured{unstructuredSvc}, @@ -300,7 +289,7 @@ func TestRegistryV1SuiteGenerateMultiNamespace(t *testing.T) { t.Log("By creating a registry v1 bundle") watchNamespaces := []string{"testWatchNs1", "testWatchNs2"} unstructuredSvc := convertToUnstructured(t, svc) - registryv1Bundle := convert.RegistryV1{ + registryv1Bundle := render.RegistryV1{ PackageName: "testPkg", CSV: *csv, Others: []unstructured.Unstructured{unstructuredSvc}, @@ -333,7 +322,7 @@ func TestRegistryV1SuiteGenerateSingleNamespace(t *testing.T) { t.Log("By creating a registry v1 bundle") watchNamespaces := []string{"testWatchNs1"} unstructuredSvc := convertToUnstructured(t, svc) - registryv1Bundle := convert.RegistryV1{ + registryv1Bundle := render.RegistryV1{ PackageName: "testPkg", CSV: *csv, Others: []unstructured.Unstructured{unstructuredSvc}, @@ -366,7 +355,7 @@ func TestRegistryV1SuiteGenerateOwnNamespace(t *testing.T) { t.Log("By creating a registry v1 bundle") watchNamespaces := []string{installNamespace} unstructuredSvc := convertToUnstructured(t, svc) - registryv1Bundle := convert.RegistryV1{ + registryv1Bundle := render.RegistryV1{ PackageName: "testPkg", CSV: *csv, Others: []unstructured.Unstructured{unstructuredSvc}, @@ -471,7 +460,7 @@ func TestConvertInstallModeValidation(t *testing.T) { t.Log("By creating a registry v1 bundle") unstructuredSvc := convertToUnstructured(t, svc) - registryv1Bundle := convert.RegistryV1{ + registryv1Bundle := render.RegistryV1{ PackageName: "testPkg", CSV: *csv, Others: []unstructured.Unstructured{unstructuredSvc}, @@ -561,7 +550,7 @@ func TestRegistryV1SuiteGenerateNoWebhooks(t *testing.T) { }, } watchNamespaces := []string{metav1.NamespaceAll} - registryv1Bundle := convert.RegistryV1{ + registryv1Bundle := render.RegistryV1{ PackageName: "testPkg", CSV: csv, } @@ -573,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") @@ -592,7 +627,7 @@ func TestRegistryV1SuiteGenerateNoAPISerciceDefinitions(t *testing.T) { }, } watchNamespaces := []string{metav1.NamespaceAll} - registryv1Bundle := convert.RegistryV1{ + registryv1Bundle := render.RegistryV1{ PackageName: "testPkg", CSV: csv, } @@ -607,7 +642,7 @@ func TestRegistryV1SuiteGenerateNoAPISerciceDefinitions(t *testing.T) { func Test_Convert_DeploymentResourceGeneration(t *testing.T) { for _, tc := range []struct { name string - bundle convert.RegistryV1 + bundle render.RegistryV1 installNamespace string targetNamespaces []string expectedResources []client.Object @@ -616,7 +651,7 @@ func Test_Convert_DeploymentResourceGeneration(t *testing.T) { name: "generates deployment resources", installNamespace: "install-namespace", targetNamespaces: []string{""}, - bundle: convert.RegistryV1{ + bundle: render.RegistryV1{ CSV: MakeCSV( WithInstallModeSupportFor(v1alpha1.InstallModeTypeAllNamespaces), WithAnnotations(map[string]string{ @@ -702,7 +737,10 @@ func Test_Convert_DeploymentResourceGeneration(t *testing.T) { }, } { t.Run(tc.name, func(t *testing.T) { - conv := convert.Converter{} + // ignore bundle validation for these unit tests as we only want to test + // the specific resource generation logic + conv := convert.PlainConverter + conv.BundleValidator = nil plain, err := conv.Convert(tc.bundle, tc.installNamespace, tc.targetNamespaces) require.NoError(t, err) for _, expectedObj := range tc.expectedResources { @@ -720,14 +758,14 @@ func Test_Convert_RoleRoleBindingResourceGeneration(t *testing.T) { name string installNamespace string targetNamespaces []string - bundle convert.RegistryV1 + bundle render.RegistryV1 expectedResources []client.Object }{ { name: "does not generate any resources when in AllNamespaces mode (target namespace is [''])", installNamespace: "install-namespace", targetNamespaces: []string{metav1.NamespaceAll}, - bundle: convert.RegistryV1{ + bundle: render.RegistryV1{ CSV: MakeCSV( WithInstallModeSupportFor(v1alpha1.InstallModeTypeAllNamespaces), WithName("csv"), @@ -751,7 +789,7 @@ func Test_Convert_RoleRoleBindingResourceGeneration(t *testing.T) { name: "generates role and rolebinding for permission service-account when in Single/OwnNamespace mode (target namespace contains a single namespace)", installNamespace: "install-namespace", targetNamespaces: []string{"watch-namespace"}, - bundle: convert.RegistryV1{ + bundle: render.RegistryV1{ CSV: MakeCSV( WithName("csv"), WithInstallModeSupportFor(v1alpha1.InstallModeTypeAllNamespaces, v1alpha1.InstallModeTypeSingleNamespace), @@ -824,7 +862,7 @@ func Test_Convert_RoleRoleBindingResourceGeneration(t *testing.T) { name: "generates role and rolebinding for permission service-account for each target namespace when in MultiNamespace install mode (target namespace contains multiple namespaces)", installNamespace: "install-namespace", targetNamespaces: []string{"watch-namespace", "watch-namespace-two"}, - bundle: convert.RegistryV1{ + bundle: render.RegistryV1{ CSV: MakeCSV( WithInstallModeSupportFor(v1alpha1.InstallModeTypeAllNamespaces, v1alpha1.InstallModeTypeMultiNamespace), WithName("csv"), @@ -941,7 +979,7 @@ func Test_Convert_RoleRoleBindingResourceGeneration(t *testing.T) { name: "generates role and rolebinding for each permission service-account", installNamespace: "install-namespace", targetNamespaces: []string{"watch-namespace"}, - bundle: convert.RegistryV1{ + bundle: render.RegistryV1{ CSV: MakeCSV( WithInstallModeSupportFor(v1alpha1.InstallModeTypeSingleNamespace, v1alpha1.InstallModeTypeAllNamespaces), WithName("csv"), @@ -1056,7 +1094,7 @@ func Test_Convert_RoleRoleBindingResourceGeneration(t *testing.T) { name: "treats empty service account as 'default' service account", installNamespace: "install-namespace", targetNamespaces: []string{"watch-namespace"}, - bundle: convert.RegistryV1{ + bundle: render.RegistryV1{ CSV: MakeCSV( WithInstallModeSupportFor(v1alpha1.InstallModeTypeSingleNamespace, v1alpha1.InstallModeTypeAllNamespaces), WithName("csv"), @@ -1119,7 +1157,10 @@ func Test_Convert_RoleRoleBindingResourceGeneration(t *testing.T) { }, } { t.Run(tc.name, func(t *testing.T) { - conv := convert.Converter{} + // ignore bundle validation for these unit tests as we only want to test + // the specific resource generation logic + conv := convert.PlainConverter + conv.BundleValidator = nil plain, err := conv.Convert(tc.bundle, tc.installNamespace, tc.targetNamespaces) require.NoError(t, err) for _, expectedObj := range tc.expectedResources { @@ -1137,14 +1178,14 @@ func Test_Convert_ClusterRoleClusterRoleBindingResourceGeneration(t *testing.T) name string installNamespace string targetNamespaces []string - bundle convert.RegistryV1 + bundle render.RegistryV1 expectedResources []client.Object }{ { name: "promotes permissions to clusters permissions and adds namespace policy rule when in AllNamespaces mode (target namespace is [''])", installNamespace: "install-namespace", targetNamespaces: []string{""}, - bundle: convert.RegistryV1{ + bundle: render.RegistryV1{ CSV: MakeCSV( WithInstallModeSupportFor(v1alpha1.InstallModeTypeAllNamespaces), WithName("csv"), @@ -1263,7 +1304,7 @@ func Test_Convert_ClusterRoleClusterRoleBindingResourceGeneration(t *testing.T) name: "generates clusterroles and clusterrolebindings for clusterpermissions", installNamespace: "install-namespace", targetNamespaces: []string{"watch-namespace"}, - bundle: convert.RegistryV1{ + bundle: render.RegistryV1{ CSV: MakeCSV( WithInstallModeSupportFor(v1alpha1.InstallModeTypeAllNamespaces, v1alpha1.InstallModeTypeSingleNamespace), WithName("csv"), @@ -1374,7 +1415,7 @@ func Test_Convert_ClusterRoleClusterRoleBindingResourceGeneration(t *testing.T) name: "treats empty service accounts as 'default' service account", installNamespace: "install-namespace", targetNamespaces: []string{"watch-namespace"}, - bundle: convert.RegistryV1{ + bundle: render.RegistryV1{ CSV: MakeCSV( WithInstallModeSupportFor(v1alpha1.InstallModeTypeAllNamespaces, v1alpha1.InstallModeTypeSingleNamespace), WithName("csv"), @@ -1435,7 +1476,11 @@ func Test_Convert_ClusterRoleClusterRoleBindingResourceGeneration(t *testing.T) }, } { t.Run(tc.name, func(t *testing.T) { - conv := convert.Converter{} + // ignore bundle validation for these unit tests as we only want to test + // the specific resource generation logic + conv := convert.PlainConverter + conv.BundleValidator = nil + plain, err := conv.Convert(tc.bundle, tc.installNamespace, tc.targetNamespaces) require.NoError(t, err) for _, expectedObj := range tc.expectedResources { @@ -1453,13 +1498,13 @@ func Test_Convert_ServiceAccountResourceGeneration(t *testing.T) { name string installNamespace string targetNamespaces []string - bundle convert.RegistryV1 + bundle render.RegistryV1 expectedResources []client.Object }{ { name: "generates unique set of clusterpermissions and permissions service accounts in the install namespace", installNamespace: "install-namespace", - bundle: convert.RegistryV1{ + bundle: render.RegistryV1{ CSV: MakeCSV( WithInstallModeSupportFor(v1alpha1.InstallModeTypeAllNamespaces), WithName("csv"), @@ -1545,7 +1590,7 @@ func Test_Convert_ServiceAccountResourceGeneration(t *testing.T) { { name: "treats empty service accounts as default and doesn't generate them", installNamespace: "install-namespace", - bundle: convert.RegistryV1{ + bundle: render.RegistryV1{ CSV: MakeCSV( WithInstallModeSupportFor(v1alpha1.InstallModeTypeAllNamespaces), WithName("csv"), @@ -1579,7 +1624,10 @@ func Test_Convert_ServiceAccountResourceGeneration(t *testing.T) { }, } { t.Run(tc.name, func(t *testing.T) { - conv := convert.Converter{} + // ignore bundle validation for these unit tests as we only want to test + // the specific resource generation logic + conv := convert.PlainConverter + conv.BundleValidator = nil plain, err := conv.Convert(tc.bundle, tc.installNamespace, tc.targetNamespaces) require.NoError(t, err) for _, expectedObj := range tc.expectedResources { @@ -1593,7 +1641,7 @@ func Test_Convert_ServiceAccountResourceGeneration(t *testing.T) { } func Test_Convert_BundleCRDGeneration(t *testing.T) { - bundle := convert.RegistryV1{ + bundle := render.RegistryV1{ CRDs: []apiextensionsv1.CustomResourceDefinition{ {ObjectMeta: metav1.ObjectMeta{Name: "crd-one"}}, {ObjectMeta: metav1.ObjectMeta{Name: "crd-two"}}, @@ -1601,7 +1649,10 @@ func Test_Convert_BundleCRDGeneration(t *testing.T) { CSV: MakeCSV(WithInstallModeSupportFor(v1alpha1.InstallModeTypeAllNamespaces)), } - conv := convert.Converter{} + // ignore bundle validation for these unit tests as we only want to test + // the specific resource generation logic + conv := convert.PlainConverter + conv.BundleValidator = nil plain, err := conv.Convert(bundle, "install-namespace", []string{""}) require.NoError(t, err) expectedResources := []client.Object{ @@ -1618,7 +1669,7 @@ func Test_Convert_BundleCRDGeneration(t *testing.T) { } func Test_Convert_AdditionalResourcesGeneration(t *testing.T) { - bundle := convert.RegistryV1{ + bundle := render.RegistryV1{ CSV: MakeCSV(WithInstallModeSupportFor(v1alpha1.InstallModeTypeAllNamespaces)), Others: []unstructured.Unstructured{ convertToUnstructured(t, @@ -1646,7 +1697,10 @@ func Test_Convert_AdditionalResourcesGeneration(t *testing.T) { }, } - conv := convert.Converter{} + // ignore bundle validation for these unit tests as we only want to test + // the specific resource generation logic + conv := convert.PlainConverter + conv.BundleValidator = nil plain, err := conv.Convert(bundle, "install-namespace", []string{""}) require.NoError(t, err) expectedResources := []unstructured.Unstructured{ diff --git a/internal/operator-controller/rukpak/convert/validator.go b/internal/operator-controller/rukpak/convert/validator.go deleted file mode 100644 index e0e8135eb..000000000 --- a/internal/operator-controller/rukpak/convert/validator.go +++ /dev/null @@ -1,95 +0,0 @@ -package convert - -import ( - "errors" - "fmt" - "slices" - - "k8s.io/apimachinery/pkg/util/sets" -) - -type BundleValidator []func(v1 *RegistryV1) []error - -func (v BundleValidator) Validate(rv1 *RegistryV1) error { - var errs []error - for _, validator := range v { - errs = append(errs, validator(rv1)...) - } - return errors.Join(errs...) -} - -var RegistryV1BundleValidator = BundleValidator{ - // NOTE: if you update this list, Test_BundleValidatorHasAllValidationFns will fail until - // you bring the same changes over to that test. This helps ensure all validation rules are executed - // while giving us the flexibility to test each validation function individually - CheckDeploymentSpecUniqueness, - CheckCRDResourceUniqueness, - CheckOwnedCRDExistence, - CheckPackageNameNotEmpty, -} - -// CheckDeploymentSpecUniqueness checks that each strategy deployment spec in the csv has a unique name. -// Errors are sorted by deployment name. -func CheckDeploymentSpecUniqueness(rv1 *RegistryV1) []error { - deploymentNameSet := sets.Set[string]{} - duplicateDeploymentNames := sets.Set[string]{} - for _, dep := range rv1.CSV.Spec.InstallStrategy.StrategySpec.DeploymentSpecs { - if deploymentNameSet.Has(dep.Name) { - duplicateDeploymentNames.Insert(dep.Name) - } - deploymentNameSet.Insert(dep.Name) - } - - errs := make([]error, 0, len(duplicateDeploymentNames)) - for _, d := range slices.Sorted(slices.Values(duplicateDeploymentNames.UnsortedList())) { - errs = append(errs, fmt.Errorf("cluster service version contains duplicate strategy deployment spec '%s'", d)) - } - return errs -} - -// CheckOwnedCRDExistence checks bundle owned custom resource definitions declared in the csv exist in the bundle -func CheckOwnedCRDExistence(rv1 *RegistryV1) []error { - crdsNames := sets.Set[string]{} - for _, crd := range rv1.CRDs { - crdsNames.Insert(crd.Name) - } - - missingCRDNames := sets.Set[string]{} - for _, crd := range rv1.CSV.Spec.CustomResourceDefinitions.Owned { - if !crdsNames.Has(crd.Name) { - missingCRDNames.Insert(crd.Name) - } - } - - errs := make([]error, 0, len(missingCRDNames)) - for _, crdName := range slices.Sorted(slices.Values(missingCRDNames.UnsortedList())) { - errs = append(errs, fmt.Errorf("cluster service definition references owned custom resource definition '%s' not found in bundle", crdName)) - } - return errs -} - -// CheckCRDResourceUniqueness checks that the bundle CRD names are unique -func CheckCRDResourceUniqueness(rv1 *RegistryV1) []error { - crdsNames := sets.Set[string]{} - duplicateCRDNames := sets.Set[string]{} - for _, crd := range rv1.CRDs { - if crdsNames.Has(crd.Name) { - duplicateCRDNames.Insert(crd.Name) - } - crdsNames.Insert(crd.Name) - } - - errs := make([]error, 0, len(duplicateCRDNames)) - for _, crdName := range slices.Sorted(slices.Values(duplicateCRDNames.UnsortedList())) { - errs = append(errs, fmt.Errorf("bundle contains duplicate custom resource definition '%s'", crdName)) - } - return errs -} - -// CheckPackageNameNotEmpty checks that PackageName is not empty -func CheckPackageNameNotEmpty(rv1 *RegistryV1) []error { - if rv1.PackageName == "" { - return []error{errors.New("package name is empty")} - } - return nil -} diff --git a/internal/operator-controller/rukpak/convert/validator_test.go b/internal/operator-controller/rukpak/convert/validator_test.go deleted file mode 100644 index 99c78f40f..000000000 --- a/internal/operator-controller/rukpak/convert/validator_test.go +++ /dev/null @@ -1,254 +0,0 @@ -package convert_test - -import ( - "errors" - "reflect" - "testing" - - "github.com/stretchr/testify/require" - apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - - "github.com/operator-framework/api/pkg/operators/v1alpha1" - - "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/convert" -) - -func Test_BundleValidatorHasAllValidationFns(t *testing.T) { - expectedValidationFns := []func(v1 *convert.RegistryV1) []error{ - convert.CheckDeploymentSpecUniqueness, - convert.CheckCRDResourceUniqueness, - convert.CheckOwnedCRDExistence, - convert.CheckPackageNameNotEmpty, - } - actualValidationFns := convert.RegistryV1BundleValidator - - require.Equal(t, len(expectedValidationFns), len(actualValidationFns)) - for i := range expectedValidationFns { - require.Equal(t, reflect.ValueOf(expectedValidationFns[i]).Pointer(), reflect.ValueOf(actualValidationFns[i]).Pointer(), "bundle validator has unexpected validation function") - } -} - -func Test_BundleValidatorCallsAllValidationFnsInOrder(t *testing.T) { - actual := "" - validator := convert.BundleValidator{ - func(v1 *convert.RegistryV1) []error { - actual += "h" - return nil - }, - func(v1 *convert.RegistryV1) []error { - actual += "i" - return nil - }, - } - require.NoError(t, validator.Validate(nil)) - require.Equal(t, "hi", actual) -} - -func Test_CheckDeploymentSpecUniqueness(t *testing.T) { - for _, tc := range []struct { - name string - bundle *convert.RegistryV1 - expectedErrs []error - }{ - { - name: "accepts bundles with unique deployment strategy spec names", - bundle: &convert.RegistryV1{ - CSV: makeCSV( - withStrategyDeploymentSpecs( - v1alpha1.StrategyDeploymentSpec{Name: "test-deployment-one"}, - v1alpha1.StrategyDeploymentSpec{Name: "test-deployment-two"}, - ), - ), - }, - expectedErrs: []error{}, - }, { - name: "rejects bundles with duplicate deployment strategy spec names", - bundle: &convert.RegistryV1{ - CSV: makeCSV( - withStrategyDeploymentSpecs( - v1alpha1.StrategyDeploymentSpec{Name: "test-deployment-one"}, - v1alpha1.StrategyDeploymentSpec{Name: "test-deployment-two"}, - v1alpha1.StrategyDeploymentSpec{Name: "test-deployment-one"}, - ), - ), - }, - expectedErrs: []error{ - errors.New("cluster service version contains duplicate strategy deployment spec 'test-deployment-one'"), - }, - }, { - name: "errors are ordered by deployment strategy spec name", - bundle: &convert.RegistryV1{ - CSV: makeCSV( - withStrategyDeploymentSpecs( - v1alpha1.StrategyDeploymentSpec{Name: "test-deployment-a"}, - v1alpha1.StrategyDeploymentSpec{Name: "test-deployment-b"}, - v1alpha1.StrategyDeploymentSpec{Name: "test-deployment-c"}, - v1alpha1.StrategyDeploymentSpec{Name: "test-deployment-b"}, - v1alpha1.StrategyDeploymentSpec{Name: "test-deployment-a"}, - ), - ), - }, - expectedErrs: []error{ - errors.New("cluster service version contains duplicate strategy deployment spec 'test-deployment-a'"), - errors.New("cluster service version contains duplicate strategy deployment spec 'test-deployment-b'"), - }, - }, - } { - t.Run(tc.name, func(t *testing.T) { - errs := convert.CheckDeploymentSpecUniqueness(tc.bundle) - require.Equal(t, tc.expectedErrs, errs) - }) - } -} - -func Test_CRDResourceUniqueness(t *testing.T) { - for _, tc := range []struct { - name string - bundle *convert.RegistryV1 - expectedErrs []error - }{ - { - name: "accepts bundles with unique custom resource definition resources", - bundle: &convert.RegistryV1{ - CRDs: []apiextensionsv1.CustomResourceDefinition{ - {ObjectMeta: metav1.ObjectMeta{Name: "a.crd.something"}}, - {ObjectMeta: metav1.ObjectMeta{Name: "b.crd.something"}}, - }, - }, - expectedErrs: []error{}, - }, { - name: "rejects bundles with duplicate custom resource definition resources", - bundle: &convert.RegistryV1{CRDs: []apiextensionsv1.CustomResourceDefinition{ - {ObjectMeta: metav1.ObjectMeta{Name: "a.crd.something"}}, - {ObjectMeta: metav1.ObjectMeta{Name: "a.crd.something"}}, - }}, - expectedErrs: []error{ - errors.New("bundle contains duplicate custom resource definition 'a.crd.something'"), - }, - }, { - name: "errors are ordered by custom resource definition name", - bundle: &convert.RegistryV1{CRDs: []apiextensionsv1.CustomResourceDefinition{ - {ObjectMeta: metav1.ObjectMeta{Name: "c.crd.something"}}, - {ObjectMeta: metav1.ObjectMeta{Name: "c.crd.something"}}, - {ObjectMeta: metav1.ObjectMeta{Name: "a.crd.something"}}, - {ObjectMeta: metav1.ObjectMeta{Name: "a.crd.something"}}, - }}, - expectedErrs: []error{ - errors.New("bundle contains duplicate custom resource definition 'a.crd.something'"), - errors.New("bundle contains duplicate custom resource definition 'c.crd.something'"), - }, - }, - } { - t.Run(tc.name, func(t *testing.T) { - err := convert.CheckCRDResourceUniqueness(tc.bundle) - require.Equal(t, tc.expectedErrs, err) - }) - } -} - -func Test_CheckOwnedCRDExistence(t *testing.T) { - for _, tc := range []struct { - name string - bundle *convert.RegistryV1 - expectedErrs []error - }{ - { - name: "accepts bundles with existing owned custom resource definition resources", - bundle: &convert.RegistryV1{ - CRDs: []apiextensionsv1.CustomResourceDefinition{ - {ObjectMeta: metav1.ObjectMeta{Name: "a.crd.something"}}, - {ObjectMeta: metav1.ObjectMeta{Name: "b.crd.something"}}, - }, - CSV: makeCSV( - withOwnedCRDs( - v1alpha1.CRDDescription{Name: "a.crd.something"}, - v1alpha1.CRDDescription{Name: "b.crd.something"}, - ), - ), - }, - expectedErrs: []error{}, - }, { - name: "rejects bundles with missing owned custom resource definition resources", - bundle: &convert.RegistryV1{ - CRDs: []apiextensionsv1.CustomResourceDefinition{}, - CSV: makeCSV( - withOwnedCRDs(v1alpha1.CRDDescription{Name: "a.crd.something"}), - ), - }, - expectedErrs: []error{ - errors.New("cluster service definition references owned custom resource definition 'a.crd.something' not found in bundle"), - }, - }, { - name: "errors are ordered by owned custom resource definition name", - bundle: &convert.RegistryV1{ - CRDs: []apiextensionsv1.CustomResourceDefinition{}, - CSV: makeCSV( - withOwnedCRDs( - v1alpha1.CRDDescription{Name: "a.crd.something"}, - v1alpha1.CRDDescription{Name: "c.crd.something"}, - v1alpha1.CRDDescription{Name: "b.crd.something"}, - ), - ), - }, - expectedErrs: []error{ - errors.New("cluster service definition references owned custom resource definition 'a.crd.something' not found in bundle"), - errors.New("cluster service definition references owned custom resource definition 'b.crd.something' not found in bundle"), - errors.New("cluster service definition references owned custom resource definition 'c.crd.something' not found in bundle"), - }, - }, - } { - t.Run(tc.name, func(t *testing.T) { - errs := convert.CheckOwnedCRDExistence(tc.bundle) - require.Equal(t, tc.expectedErrs, errs) - }) - } -} - -func Test_CheckPackageNameNotEmpty(t *testing.T) { - for _, tc := range []struct { - name string - bundle *convert.RegistryV1 - expectedErrs []error - }{ - { - name: "accepts bundles with non-empty package name", - bundle: &convert.RegistryV1{ - PackageName: "not-empty", - }, - }, { - name: "rejects bundles with empty package name", - bundle: &convert.RegistryV1{}, - expectedErrs: []error{ - errors.New("package name is empty"), - }, - }, - } { - t.Run(tc.name, func(t *testing.T) { - errs := convert.CheckPackageNameNotEmpty(tc.bundle) - require.Equal(t, tc.expectedErrs, errs) - }) - } -} - -type csvOption func(version *v1alpha1.ClusterServiceVersion) - -func withStrategyDeploymentSpecs(strategyDeploymentSpecs ...v1alpha1.StrategyDeploymentSpec) csvOption { - return func(csv *v1alpha1.ClusterServiceVersion) { - csv.Spec.InstallStrategy.StrategySpec.DeploymentSpecs = strategyDeploymentSpecs - } -} - -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{} - for _, opt := range opts { - opt(&csv) - } - return csv -} 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..293a86b46 --- /dev/null +++ b/internal/operator-controller/rukpak/render/certprovider_test.go @@ -0,0 +1,117 @@ +package render_test + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/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{})}, 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{})}, 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..b2d32d7bd --- /dev/null +++ b/internal/operator-controller/rukpak/render/certproviders/certmanager.go @@ -0,0 +1,117 @@ +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" +) + +func WithCertManagerCertificateProvider() render.Option { + return func(o *render.Options) { + o.CertificateProvider = CertManagerCertificateProvider{} + } +} + +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/certproviders/openshift_serviceca.go b/internal/operator-controller/rukpak/render/certproviders/openshift_serviceca.go new file mode 100644 index 000000000..7a487b8e0 --- /dev/null +++ b/internal/operator-controller/rukpak/render/certproviders/openshift_serviceca.go @@ -0,0 +1,56 @@ +package certproviders + +import ( + admissionregistrationv1 "k8s.io/api/admissionregistration/v1" + corev1 "k8s.io/api/core/v1" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/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" +) + +var _ render.CertificateProvider = (*OpenshiftServiceCACertificateProvider)(nil) + +type OpenshiftServiceCACertificateProvider struct{} + +func (o OpenshiftServiceCACertificateProvider) InjectCABundle(obj client.Object, cfg render.CertificateProvisionerConfig) error { + switch obj.(type) { + case *admissionregistrationv1.ValidatingWebhookConfiguration: + o.addCAInjectionAnnotation(obj) + case *admissionregistrationv1.MutatingWebhookConfiguration: + o.addCAInjectionAnnotation(obj) + case *apiextensionsv1.CustomResourceDefinition: + o.addCAInjectionAnnotation(obj) + case *corev1.Service: + o.addServingSecretNameAnnotation(obj, cfg.CertName) + } + return nil +} + +func (o OpenshiftServiceCACertificateProvider) AdditionalObjects(_ render.CertificateProvisionerConfig) ([]unstructured.Unstructured, error) { + return nil, nil +} + +func (o OpenshiftServiceCACertificateProvider) GetCertSecretInfo(cfg render.CertificateProvisionerConfig) render.CertSecretInfo { + return render.CertSecretInfo{ + SecretName: cfg.CertName, + CertificateKey: "tls.crt", + PrivateKeyKey: "tls.key", + } +} + +func (o OpenshiftServiceCACertificateProvider) addCAInjectionAnnotation(obj client.Object) { + injectionAnnotation := map[string]string{ + "service.beta.openshift.io/inject-cabundle": "true", + } + obj.SetAnnotations(util.MergeMaps(obj.GetAnnotations(), injectionAnnotation)) +} + +func (o OpenshiftServiceCACertificateProvider) addServingSecretNameAnnotation(obj client.Object, certName string) { + injectionAnnotation := map[string]string{ + "service.beta.openshift.io/serving-cert-secret-name": certName, + } + obj.SetAnnotations(util.MergeMaps(obj.GetAnnotations(), injectionAnnotation)) +} diff --git a/internal/operator-controller/rukpak/render/certproviders/openshift_serviceca_test.go b/internal/operator-controller/rukpak/render/certproviders/openshift_serviceca_test.go new file mode 100644 index 000000000..c03419747 --- /dev/null +++ b/internal/operator-controller/rukpak/render/certproviders/openshift_serviceca_test.go @@ -0,0 +1,130 @@ +package certproviders_test + +import ( + "testing" + + "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" + "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" +) + +func Test_OpenshiftServiceCACertificateProvider_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{ + "service.beta.openshift.io/inject-cabundle": "true", + }, + }, + }, + }, + { + 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{ + "service.beta.openshift.io/inject-cabundle": "true", + }, + }, + }, + }, + { + 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{ + "service.beta.openshift.io/inject-cabundle": "true", + }, + }, + }, + }, + { + name: "injects certificate annotation in service resource", + obj: &corev1.Service{}, + cfg: render.CertificateProvisionerConfig{ + WebhookServiceName: "webhook-service", + Namespace: "namespace", + CertName: "cert-name", + }, + expectedObj: &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + "service.beta.openshift.io/serving-cert-secret-name": "cert-name", + }, + }, + }, + }, + { + name: "ignores other objects", + obj: &corev1.Secret{}, + cfg: render.CertificateProvisionerConfig{ + WebhookServiceName: "webhook-service", + Namespace: "namespace", + CertName: "cert-name", + }, + expectedObj: &corev1.Secret{}, + }, + } { + t.Run(tc.name, func(t *testing.T) { + certProvier := certproviders.OpenshiftServiceCACertificateProvider{} + require.NoError(t, certProvier.InjectCABundle(tc.obj, tc.cfg)) + require.Equal(t, tc.expectedObj, tc.obj) + }) + } +} + +func Test_OpenshiftServiceCACertificateProvider_AdditionalObjects(t *testing.T) { + certProvier := certproviders.OpenshiftServiceCACertificateProvider{} + objs, err := certProvier.AdditionalObjects(render.CertificateProvisionerConfig{ + WebhookServiceName: "webhook-service", + Namespace: "namespace", + CertName: "cert-name", + }) + require.NoError(t, err) + require.Nil(t, objs) +} + +func Test_OpenshiftServiceCACertificateProvider_GetCertSecretInfo(t *testing.T) { + certProvier := certproviders.OpenshiftServiceCACertificateProvider{} + 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) +} diff --git a/internal/operator-controller/rukpak/render/generators/generators.go b/internal/operator-controller/rukpak/render/generators/generators.go new file mode 100644 index 000000000..31f8857cd --- /dev/null +++ b/internal/operator-controller/rukpak/render/generators/generators.go @@ -0,0 +1,548 @@ +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{ + BundleCSVServiceAccountGenerator, + BundleCSVPermissionsGenerator, + BundleCSVClusterPermissionsGenerator, +} + +// BundleCSVDeploymentGenerator generates all deployments defined in rv1's cluster service version (CSV). The generated +// resource aim to have parity with OLMv0 generated Deployment resources: +// - olm.targetNamespaces annotation is set with the opts.TargetNamespace value +// - the deployment spec's revision history limit is set to 1 +// - merges csv annotations to the deployment template's annotations +func BundleCSVDeploymentGenerator(rv1 *render.RegistryV1, opts render.Options) ([]client.Object, error) { + 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 + // See https://github.com/operator-framework/operator-lifecycle-manager/blob/dfd0b2bea85038d3c0d65348bc812d297f16b8d2/pkg/controller/install/deployment.go#L142 + annotations := util.MergeMaps(rv1.CSV.Annotations, depSpec.Spec.Template.Annotations) + + // In OLMv0 CSVs are annotated with the OperatorGroup's .spec.targetNamespaces + // See https://github.com/operator-framework/operator-lifecycle-manager/blob/dfd0b2bea85038d3c0d65348bc812d297f16b8d2/pkg/controller/operators/olm/operatorgroup.go#L279 + // When the CSVs annotations are copied to the deployment template's annotations, they bring with it this annotation + annotations["olm.targetNamespaces"] = strings.Join(opts.TargetNamespaces, ",") + depSpec.Spec.Template.Annotations = annotations + + // Hardcode the deployment with RevisionHistoryLimit=1 to maintain parity with OLMv0 behaviour. + // 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)) + + 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 +} + +// BundleCSVPermissionsGenerator generates the Roles and RoleBindings based on bundle's cluster service version +// permission spec. If the bundle is being installed in AllNamespaces mode (opts.TargetNamespaces = [”]) +// no resources will be generated as these permissions will be promoted to ClusterRole/Bunding(s) +func BundleCSVPermissionsGenerator(rv1 *render.RegistryV1, opts render.Options) ([]client.Object, error) { + if rv1 == nil { + return nil, fmt.Errorf("bundle cannot be nil") + } + + // If we're in AllNamespaces mode permissions will be treated as clusterPermissions + if len(opts.TargetNamespaces) == 1 && opts.TargetNamespaces[0] == "" { + return nil, nil + } + + permissions := rv1.CSV.Spec.InstallStrategy.StrategySpec.Permissions + + objs := make([]client.Object, 0, 2*len(opts.TargetNamespaces)*len(permissions)) + for _, ns := range opts.TargetNamespaces { + for _, permission := range permissions { + saName := saNameOrDefault(permission.ServiceAccountName) + name, err := opts.UniqueNameGenerator(fmt.Sprintf("%s-%s", rv1.CSV.Name, saName), permission) + if err != nil { + return nil, err + } + + objs = append(objs, + CreateRoleResource(name, ns, WithRules(permission.Rules...)), + CreateRoleBindingResource( + name, + ns, + WithSubjects(rbacv1.Subject{Kind: "ServiceAccount", Namespace: opts.InstallNamespace, Name: saName}), + WithRoleRef(rbacv1.RoleRef{APIGroup: rbacv1.GroupName, Kind: "Role", Name: name}), + ), + ) + } + } + return objs, nil +} + +// BundleCSVClusterPermissionsGenerator generates ClusterRoles and ClusterRoleBindings based on the bundle's +// cluster service version clusterPermission spec. If the bundle is being installed in AllNamespaces mode +// (opts.TargetNamespaces = [”]), the CSV's permission spec will be promoted to ClusterRole and ClusterRoleBinding +// resources. To keep parity with OLMv0, these will also include an extra rule to get, list, watch namespaces +// (see https://github.com/operator-framework/operator-lifecycle-manager/blob/dfd0b2bea85038d3c0d65348bc812d297f16b8d2/pkg/controller/operators/olm/operatorgroup.go#L539) +func BundleCSVClusterPermissionsGenerator(rv1 *render.RegistryV1, opts render.Options) ([]client.Object, error) { + if rv1 == nil { + return nil, fmt.Errorf("bundle cannot be nil") + } + clusterPermissions := rv1.CSV.Spec.InstallStrategy.StrategySpec.ClusterPermissions + + // If we're in AllNamespaces mode, promote the permissions to clusterPermissions + if len(opts.TargetNamespaces) == 1 && opts.TargetNamespaces[0] == "" { + for _, p := range rv1.CSV.Spec.InstallStrategy.StrategySpec.Permissions { + p.Rules = append(p.Rules, rbacv1.PolicyRule{ + Verbs: []string{"get", "list", "watch"}, + APIGroups: []string{corev1.GroupName}, + Resources: []string{"namespaces"}, + }) + clusterPermissions = append(clusterPermissions, p) + } + } + + objs := make([]client.Object, 0, 2*len(clusterPermissions)) + for _, permission := range clusterPermissions { + saName := saNameOrDefault(permission.ServiceAccountName) + name, err := opts.UniqueNameGenerator(fmt.Sprintf("%s-%s", rv1.CSV.Name, saName), permission) + if err != nil { + return nil, err + } + objs = append(objs, + CreateClusterRoleResource(name, WithRules(permission.Rules...)), + CreateClusterRoleBindingResource( + name, + WithSubjects(rbacv1.Subject{Kind: "ServiceAccount", Namespace: opts.InstallNamespace, Name: saName}), + WithRoleRef(rbacv1.RoleRef{APIGroup: rbacv1.GroupName, Kind: "ClusterRole", Name: name}), + ), + ) + } + return objs, nil +} + +// BundleCSVServiceAccountGenerator generates ServiceAccount resources based on the bundle's cluster service version +// permission and clusterPermission spec. One ServiceAccount resource is created / referenced service account (i.e. +// if multiple permissions reference the same service account, only one resource will be generated). +// If a clusterPermission, or permission, references an empty (”) service account, this is considered to be the +// namespace 'default' service account. A resource for the namespace 'default' service account is not generated. +func BundleCSVServiceAccountGenerator(rv1 *render.RegistryV1, opts render.Options) ([]client.Object, error) { + if rv1 == nil { + return nil, fmt.Errorf("bundle cannot be nil") + } + allPermissions := append( + rv1.CSV.Spec.InstallStrategy.StrategySpec.Permissions, + rv1.CSV.Spec.InstallStrategy.StrategySpec.ClusterPermissions..., + ) + + serviceAccountNames := sets.Set[string]{} + for _, permission := range allPermissions { + serviceAccountNames.Insert(saNameOrDefault(permission.ServiceAccountName)) + } + + objs := make([]client.Object, 0, len(serviceAccountNames)) + for _, serviceAccountName := range serviceAccountNames.UnsortedList() { + // no need to generate the default service account + if serviceAccountName != "default" { + objs = append(objs, CreateServiceAccountResource(serviceAccountName, opts.InstallNamespace)) + } + } + return objs, nil +} + +// 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 { + 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 +} + +// BundleAdditionalResourcesGenerator generates resources for the additional resources included in the +// bundle. If the bundle resource is namespace scoped, its namespace will be set to the value of opts.InstallNamespace. +func BundleAdditionalResourcesGenerator(rv1 *render.RegistryV1, opts render.Options) ([]client.Object, error) { + if rv1 == nil { + return nil, fmt.Errorf("bundle cannot be nil") + } + objs := make([]client.Object, 0, len(rv1.Others)) + for _, res := range rv1.Others { + supported, namespaced := registrybundle.IsSupported(res.GetKind()) + if !supported { + return nil, fmt.Errorf("bundle contains unsupported resource: Name: %v, Kind: %v", res.GetName(), res.GetKind()) + } + + obj := res.DeepCopy() + if namespaced { + obj.SetNamespace(opts.InstallNamespace) + } + + objs = append(objs, obj) + } + 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) + webhookResource := CreateValidatingWebhookConfigurationResource( + wh.GenerateName, + opts.InstallNamespace, + WithValidatingWebhooks( + admissionregistrationv1.ValidatingWebhook{ + Name: wh.GenerateName, + 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) + webhookResource := CreateMutatingWebhookConfigurationResource( + wh.GenerateName, + opts.InstallNamespace, + WithMutatingWebhooks( + admissionregistrationv1.MutatingWebhook{ + Name: wh.GenerateName, + 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 new file mode 100644 index 000000000..d47c84abb --- /dev/null +++ b/internal/operator-controller/rukpak/render/generators/generators_test.go @@ -0,0 +1,2298 @@ +package generators_test + +import ( + "cmp" + "fmt" + "reflect" + "slices" + "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/util/intstr" + "k8s.io/utils/ptr" + "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/render/generators" + . "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/util/testing" +) + +func Test_BundleCSVRBACResourceGenerator_HasCorrectGenerators(t *testing.T) { + expectedResourceGenerators := []render.ResourceGenerator{ + generators.BundleCSVServiceAccountGenerator, + generators.BundleCSVPermissionsGenerator, + generators.BundleCSVClusterPermissionsGenerator, + } + actualResourceGenerators := generators.BundleCSVRBACResourceGenerator + + require.Equal(t, len(expectedResourceGenerators), len(actualResourceGenerators)) + for i := range expectedResourceGenerators { + require.Equal(t, reflect.ValueOf(expectedResourceGenerators[i]).Pointer(), reflect.ValueOf(actualResourceGenerators[i]).Pointer(), "bundle validator has unexpected validation function") + } +} + +func Test_ResourceGenerators(t *testing.T) { + g := render.ResourceGenerators{ + func(rv1 *render.RegistryV1, opts render.Options) ([]client.Object, error) { + return []client.Object{&corev1.Service{}}, nil + }, + func(rv1 *render.RegistryV1, opts render.Options) ([]client.Object, error) { + return []client.Object{&corev1.ConfigMap{}}, nil + }, + } + + objs, err := g.GenerateResources(&render.RegistryV1{}, render.Options{}) + require.NoError(t, err) + require.Equal(t, []client.Object{&corev1.Service{}, &corev1.ConfigMap{}}, objs) +} + +func Test_ResourceGenerators_Errors(t *testing.T) { + g := render.ResourceGenerators{ + func(rv1 *render.RegistryV1, opts render.Options) ([]client.Object, error) { + return []client.Object{&corev1.Service{}}, nil + }, + func(rv1 *render.RegistryV1, opts render.Options) ([]client.Object, error) { + return nil, fmt.Errorf("generator error") + }, + } + + objs, err := g.GenerateResources(&render.RegistryV1{}, render.Options{}) + require.Nil(t, objs) + require.Error(t, err) + require.Contains(t, err.Error(), "generator error") +} + +func Test_BundleCSVDeploymentGenerator_Succeeds(t *testing.T) { + for _, tc := range []struct { + name string + bundle *render.RegistryV1 + opts render.Options + expectedResources []client.Object + }{ + { + name: "generates deployment resources", + bundle: &render.RegistryV1{ + CSV: MakeCSV( + WithAnnotations(map[string]string{ + "csv": "annotation", + }), + WithStrategyDeploymentSpecs( + v1alpha1.StrategyDeploymentSpec{ + Name: "deployment-one", + Label: map[string]string{ + "bar": "foo", + }, + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + "pod": "annotation", + }, + }, + Spec: corev1.PodSpec{ + ServiceAccountName: "some-service-account", + }, + }, + }, + }, + v1alpha1.StrategyDeploymentSpec{ + Name: "deployment-two", + Spec: appsv1.DeploymentSpec{}, + }, + ), + ), + }, + opts: render.Options{ + InstallNamespace: "install-namespace", + TargetNamespaces: []string{"watch-namespace-one", "watch-namespace-two"}, + }, + expectedResources: []client.Object{ + &appsv1.Deployment{ + TypeMeta: metav1.TypeMeta{ + Kind: "Deployment", + APIVersion: appsv1.SchemeGroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Namespace: "install-namespace", + Name: "deployment-one", + Labels: map[string]string{ + "bar": "foo", + }, + }, + Spec: appsv1.DeploymentSpec{ + RevisionHistoryLimit: ptr.To(int32(1)), + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + "csv": "annotation", + "olm.targetNamespaces": "watch-namespace-one,watch-namespace-two", + "pod": "annotation", + }, + }, + Spec: corev1.PodSpec{ + ServiceAccountName: "some-service-account", + }, + }, + }, + }, + &appsv1.Deployment{ + TypeMeta: metav1.TypeMeta{ + Kind: "Deployment", + APIVersion: appsv1.SchemeGroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Namespace: "install-namespace", + Name: "deployment-two", + }, + Spec: appsv1.DeploymentSpec{ + RevisionHistoryLimit: ptr.To(int32(1)), + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + "csv": "annotation", + "olm.targetNamespaces": "watch-namespace-one,watch-namespace-two", + }, + }, + }, + }, + }, + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + objs, err := generators.BundleCSVDeploymentGenerator(tc.bundle, tc.opts) + require.NoError(t, err) + require.Equal(t, tc.expectedResources, objs) + }) + } +} + +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) + require.Error(t, err) + require.Contains(t, err.Error(), "bundle cannot be nil") +} + +func Test_BundleCSVPermissionsGenerator_Succeeds(t *testing.T) { + fakeUniqueNameGenerator := func(base string, _ interface{}) (string, error) { + return base, nil + } + + for _, tc := range []struct { + name string + opts render.Options + bundle *render.RegistryV1 + expectedResources []client.Object + }{ + { + name: "does not generate any resources when in AllNamespaces mode (target namespace is [''])", + opts: render.Options{ + InstallNamespace: "install-namespace", + TargetNamespaces: []string{""}, + UniqueNameGenerator: fakeUniqueNameGenerator, + }, + bundle: &render.RegistryV1{ + CSV: MakeCSV( + WithName("csv"), + WithPermissions( + v1alpha1.StrategyDeploymentPermissions{ + ServiceAccountName: "service-account-one", + Rules: []rbacv1.PolicyRule{ + { + APIGroups: []string{""}, + Resources: []string{"namespaces"}, + Verbs: []string{"get", "list", "watch"}, + }, + }, + }, + ), + ), + }, + expectedResources: nil, + }, + { + name: "generates role and rolebinding for permission service-account when in Single/OwnNamespace mode (target namespace contains a single namespace)", + opts: render.Options{ + InstallNamespace: "install-namespace", + TargetNamespaces: []string{"watch-namespace"}, + UniqueNameGenerator: fakeUniqueNameGenerator, + }, + bundle: &render.RegistryV1{ + CSV: MakeCSV( + WithName("csv"), + WithPermissions( + v1alpha1.StrategyDeploymentPermissions{ + ServiceAccountName: "service-account-one", + Rules: []rbacv1.PolicyRule{ + { + APIGroups: []string{""}, + Resources: []string{"namespaces"}, + Verbs: []string{"get", "list", "watch"}, + }, { + APIGroups: []string{"appsv1"}, + Resources: []string{"deployments"}, + Verbs: []string{"create"}, + }, + }, + }, + ), + ), + }, + expectedResources: []client.Object{ + &rbacv1.Role{ + TypeMeta: metav1.TypeMeta{ + Kind: "Role", + APIVersion: rbacv1.SchemeGroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Namespace: "watch-namespace", + Name: "csv-service-account-one", + }, + Rules: []rbacv1.PolicyRule{ + { + APIGroups: []string{""}, + Resources: []string{"namespaces"}, + Verbs: []string{"get", "list", "watch"}, + }, { + APIGroups: []string{"appsv1"}, + Resources: []string{"deployments"}, + Verbs: []string{"create"}, + }, + }, + }, + &rbacv1.RoleBinding{ + TypeMeta: metav1.TypeMeta{ + Kind: "RoleBinding", + APIVersion: rbacv1.SchemeGroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Namespace: "watch-namespace", + Name: "csv-service-account-one", + }, + Subjects: []rbacv1.Subject{ + { + Kind: "ServiceAccount", + APIGroup: "", + Name: "service-account-one", + Namespace: "install-namespace", + }, + }, + RoleRef: rbacv1.RoleRef{ + APIGroup: "rbac.authorization.k8s.io", + Kind: "Role", + Name: "csv-service-account-one", + }, + }, + }, + }, + { + name: "generates role and rolebinding for permission service-account for each target namespace when in MultiNamespace install mode (target namespace contains multiple namespaces)", + opts: render.Options{ + InstallNamespace: "install-namespace", + TargetNamespaces: []string{"watch-namespace", "watch-namespace-two"}, + UniqueNameGenerator: fakeUniqueNameGenerator, + }, + bundle: &render.RegistryV1{ + CSV: MakeCSV( + WithName("csv"), + WithPermissions( + v1alpha1.StrategyDeploymentPermissions{ + ServiceAccountName: "service-account-one", + Rules: []rbacv1.PolicyRule{ + { + APIGroups: []string{""}, + Resources: []string{"namespaces"}, + Verbs: []string{"get", "list", "watch"}, + }, { + APIGroups: []string{"appsv1"}, + Resources: []string{"deployments"}, + Verbs: []string{"create"}, + }, + }, + }, + ), + ), + }, + expectedResources: []client.Object{ + &rbacv1.Role{ + TypeMeta: metav1.TypeMeta{ + Kind: "Role", + APIVersion: rbacv1.SchemeGroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Namespace: "watch-namespace", + Name: "csv-service-account-one", + }, + Rules: []rbacv1.PolicyRule{ + { + APIGroups: []string{""}, + Resources: []string{"namespaces"}, + Verbs: []string{"get", "list", "watch"}, + }, { + APIGroups: []string{"appsv1"}, + Resources: []string{"deployments"}, + Verbs: []string{"create"}, + }, + }, + }, + &rbacv1.RoleBinding{ + TypeMeta: metav1.TypeMeta{ + Kind: "RoleBinding", + APIVersion: rbacv1.SchemeGroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Namespace: "watch-namespace", + Name: "csv-service-account-one", + }, + Subjects: []rbacv1.Subject{ + { + Kind: "ServiceAccount", + APIGroup: "", + Name: "service-account-one", + Namespace: "install-namespace", + }, + }, + RoleRef: rbacv1.RoleRef{ + APIGroup: "rbac.authorization.k8s.io", + Kind: "Role", + Name: "csv-service-account-one", + }, + }, + &rbacv1.Role{ + TypeMeta: metav1.TypeMeta{ + Kind: "Role", + APIVersion: rbacv1.SchemeGroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Namespace: "watch-namespace-two", + Name: "csv-service-account-one", + }, + Rules: []rbacv1.PolicyRule{ + { + APIGroups: []string{""}, + Resources: []string{"namespaces"}, + Verbs: []string{"get", "list", "watch"}, + }, { + APIGroups: []string{"appsv1"}, + Resources: []string{"deployments"}, + Verbs: []string{"create"}, + }, + }, + }, + &rbacv1.RoleBinding{ + TypeMeta: metav1.TypeMeta{ + Kind: "RoleBinding", + APIVersion: rbacv1.SchemeGroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Namespace: "watch-namespace-two", + Name: "csv-service-account-one", + }, + Subjects: []rbacv1.Subject{ + { + Kind: "ServiceAccount", + APIGroup: "", + Name: "service-account-one", + Namespace: "install-namespace", + }, + }, + RoleRef: rbacv1.RoleRef{ + APIGroup: "rbac.authorization.k8s.io", + Kind: "Role", + Name: "csv-service-account-one", + }, + }, + }, + }, + { + name: "generates role and rolebinding for each permission service-account", + opts: render.Options{ + InstallNamespace: "install-namespace", + TargetNamespaces: []string{"watch-namespace"}, + UniqueNameGenerator: fakeUniqueNameGenerator, + }, + bundle: &render.RegistryV1{ + CSV: MakeCSV( + WithName("csv"), + WithPermissions( + v1alpha1.StrategyDeploymentPermissions{ + ServiceAccountName: "service-account-one", + Rules: []rbacv1.PolicyRule{ + { + APIGroups: []string{""}, + Resources: []string{"namespaces"}, + Verbs: []string{"get", "list", "watch"}, + }, + }, + }, + v1alpha1.StrategyDeploymentPermissions{ + ServiceAccountName: "service-account-two", + Rules: []rbacv1.PolicyRule{ + { + APIGroups: []string{"appsv1"}, + Resources: []string{"deployments"}, + Verbs: []string{"create"}, + }, + }, + }, + ), + ), + }, + expectedResources: []client.Object{ + &rbacv1.Role{ + TypeMeta: metav1.TypeMeta{ + Kind: "Role", + APIVersion: rbacv1.SchemeGroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Namespace: "watch-namespace", + Name: "csv-service-account-one", + }, + Rules: []rbacv1.PolicyRule{ + { + APIGroups: []string{""}, + Resources: []string{"namespaces"}, + Verbs: []string{"get", "list", "watch"}, + }, + }, + }, + &rbacv1.RoleBinding{ + TypeMeta: metav1.TypeMeta{ + Kind: "RoleBinding", + APIVersion: rbacv1.SchemeGroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Namespace: "watch-namespace", + Name: "csv-service-account-one", + }, + Subjects: []rbacv1.Subject{ + { + Kind: "ServiceAccount", + APIGroup: "", + Name: "service-account-one", + Namespace: "install-namespace", + }, + }, + RoleRef: rbacv1.RoleRef{ + APIGroup: "rbac.authorization.k8s.io", + Kind: "Role", + Name: "csv-service-account-one", + }, + }, + &rbacv1.Role{ + TypeMeta: metav1.TypeMeta{ + Kind: "Role", + APIVersion: rbacv1.SchemeGroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Namespace: "watch-namespace", + Name: "csv-service-account-two", + }, + Rules: []rbacv1.PolicyRule{ + { + APIGroups: []string{"appsv1"}, + Resources: []string{"deployments"}, + Verbs: []string{"create"}, + }, + }, + }, + &rbacv1.RoleBinding{ + TypeMeta: metav1.TypeMeta{ + Kind: "RoleBinding", + APIVersion: rbacv1.SchemeGroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Namespace: "watch-namespace", + Name: "csv-service-account-two", + }, + Subjects: []rbacv1.Subject{ + { + Kind: "ServiceAccount", + APIGroup: "", + Name: "service-account-two", + Namespace: "install-namespace", + }, + }, + RoleRef: rbacv1.RoleRef{ + APIGroup: "rbac.authorization.k8s.io", + Kind: "Role", + Name: "csv-service-account-two", + }, + }, + }, + }, + { + name: "treats empty service account as 'default' service account", + opts: render.Options{ + InstallNamespace: "install-namespace", + TargetNamespaces: []string{"watch-namespace"}, + UniqueNameGenerator: fakeUniqueNameGenerator, + }, + bundle: &render.RegistryV1{ + CSV: MakeCSV( + WithName("csv"), + WithPermissions( + v1alpha1.StrategyDeploymentPermissions{ + ServiceAccountName: "", + Rules: []rbacv1.PolicyRule{ + { + APIGroups: []string{""}, + Resources: []string{"namespaces"}, + Verbs: []string{"get", "list", "watch"}, + }, + }, + }, + ), + ), + }, + expectedResources: []client.Object{ + &rbacv1.Role{ + TypeMeta: metav1.TypeMeta{ + Kind: "Role", + APIVersion: rbacv1.SchemeGroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Namespace: "watch-namespace", + Name: "csv-default", + }, + Rules: []rbacv1.PolicyRule{ + { + APIGroups: []string{""}, + Resources: []string{"namespaces"}, + Verbs: []string{"get", "list", "watch"}, + }, + }, + }, + &rbacv1.RoleBinding{ + TypeMeta: metav1.TypeMeta{ + Kind: "RoleBinding", + APIVersion: rbacv1.SchemeGroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Namespace: "watch-namespace", + Name: "csv-default", + }, + Subjects: []rbacv1.Subject{ + { + Kind: "ServiceAccount", + APIGroup: "", + Name: "default", + Namespace: "install-namespace", + }, + }, + RoleRef: rbacv1.RoleRef{ + APIGroup: "rbac.authorization.k8s.io", + Kind: "Role", + Name: "csv-default", + }, + }, + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + objs, err := generators.BundleCSVPermissionsGenerator(tc.bundle, tc.opts) + require.NoError(t, err) + for i := range objs { + require.Equal(t, tc.expectedResources[i], objs[i], "failed to find expected resource at index %d", i) + } + require.Equal(t, len(tc.expectedResources), len(objs)) + }) + } +} + +func Test_BundleCSVPermissionGenerator_FailsOnNil(t *testing.T) { + objs, err := generators.BundleCSVPermissionsGenerator(nil, render.Options{}) + require.Nil(t, objs) + require.Error(t, err) + require.Contains(t, err.Error(), "bundle cannot be nil") +} + +func Test_BundleCSVClusterPermissionsGenerator_Succeeds(t *testing.T) { + fakeUniqueNameGenerator := func(base string, _ interface{}) (string, error) { + return base, nil + } + + for _, tc := range []struct { + name string + opts render.Options + bundle *render.RegistryV1 + expectedResources []client.Object + }{ + { + name: "promotes permissions to clusters permissions and adds namespace policy rule when in AllNamespaces mode (target namespace is [''])", + opts: render.Options{ + InstallNamespace: "install-namespace", + TargetNamespaces: []string{""}, + UniqueNameGenerator: fakeUniqueNameGenerator, + }, + bundle: &render.RegistryV1{ + CSV: MakeCSV( + WithName("csv"), + WithPermissions( + v1alpha1.StrategyDeploymentPermissions{ + ServiceAccountName: "service-account-one", + Rules: []rbacv1.PolicyRule{ + { + APIGroups: []string{""}, + Resources: []string{"namespaces"}, + Verbs: []string{"get", "list", "watch"}, + }, + }, + }, + v1alpha1.StrategyDeploymentPermissions{ + ServiceAccountName: "service-account-two", + Rules: []rbacv1.PolicyRule{ + { + APIGroups: []string{"appsv1"}, + Resources: []string{"deployments"}, + Verbs: []string{"create"}, + }, + }, + }, + ), + ), + }, + expectedResources: []client.Object{ + &rbacv1.ClusterRole{ + TypeMeta: metav1.TypeMeta{ + Kind: "ClusterRole", + APIVersion: rbacv1.SchemeGroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "csv-service-account-one", + }, + Rules: []rbacv1.PolicyRule{ + { + APIGroups: []string{""}, + Resources: []string{"namespaces"}, + Verbs: []string{"get", "list", "watch"}, + }, { + Verbs: []string{"get", "list", "watch"}, + APIGroups: []string{corev1.GroupName}, + Resources: []string{"namespaces"}, + }, + }, + }, + &rbacv1.ClusterRoleBinding{ + TypeMeta: metav1.TypeMeta{ + Kind: "ClusterRoleBinding", + APIVersion: rbacv1.SchemeGroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "csv-service-account-one", + }, + Subjects: []rbacv1.Subject{ + { + Kind: "ServiceAccount", + APIGroup: "", + Name: "service-account-one", + Namespace: "install-namespace", + }, + }, + RoleRef: rbacv1.RoleRef{ + APIGroup: "rbac.authorization.k8s.io", + Kind: "ClusterRole", + Name: "csv-service-account-one", + }, + }, + &rbacv1.ClusterRole{ + TypeMeta: metav1.TypeMeta{ + Kind: "ClusterRole", + APIVersion: rbacv1.SchemeGroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "csv-service-account-two", + }, + Rules: []rbacv1.PolicyRule{ + { + APIGroups: []string{"appsv1"}, + Resources: []string{"deployments"}, + Verbs: []string{"create"}, + }, { + Verbs: []string{"get", "list", "watch"}, + APIGroups: []string{corev1.GroupName}, + Resources: []string{"namespaces"}, + }, + }, + }, + &rbacv1.ClusterRoleBinding{ + TypeMeta: metav1.TypeMeta{ + Kind: "ClusterRoleBinding", + APIVersion: rbacv1.SchemeGroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "csv-service-account-two", + }, + Subjects: []rbacv1.Subject{ + { + Kind: "ServiceAccount", + APIGroup: "", + Name: "service-account-two", + Namespace: "install-namespace", + }, + }, + RoleRef: rbacv1.RoleRef{ + APIGroup: "rbac.authorization.k8s.io", + Kind: "ClusterRole", + Name: "csv-service-account-two", + }, + }, + }, + }, + { + name: "generates clusterroles and clusterrolebindings for clusterpermissions", + opts: render.Options{ + InstallNamespace: "install-namespace", + TargetNamespaces: []string{"watch-namespace"}, + UniqueNameGenerator: fakeUniqueNameGenerator, + }, + bundle: &render.RegistryV1{ + CSV: MakeCSV( + WithName("csv"), + WithClusterPermissions( + v1alpha1.StrategyDeploymentPermissions{ + ServiceAccountName: "service-account-one", + Rules: []rbacv1.PolicyRule{ + { + APIGroups: []string{""}, + Resources: []string{"namespaces"}, + Verbs: []string{"get", "list", "watch"}, + }, + }, + }, + v1alpha1.StrategyDeploymentPermissions{ + ServiceAccountName: "service-account-two", + Rules: []rbacv1.PolicyRule{ + { + APIGroups: []string{"appsv1"}, + Resources: []string{"deployments"}, + Verbs: []string{"create"}, + }, + }, + }, + ), + ), + }, + expectedResources: []client.Object{ + &rbacv1.ClusterRole{ + TypeMeta: metav1.TypeMeta{ + Kind: "ClusterRole", + APIVersion: rbacv1.SchemeGroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "csv-service-account-one", + }, + Rules: []rbacv1.PolicyRule{ + { + APIGroups: []string{""}, + Resources: []string{"namespaces"}, + Verbs: []string{"get", "list", "watch"}, + }, + }, + }, + &rbacv1.ClusterRoleBinding{ + TypeMeta: metav1.TypeMeta{ + Kind: "ClusterRoleBinding", + APIVersion: rbacv1.SchemeGroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "csv-service-account-one", + }, + Subjects: []rbacv1.Subject{ + { + Kind: "ServiceAccount", + APIGroup: "", + Name: "service-account-one", + Namespace: "install-namespace", + }, + }, + RoleRef: rbacv1.RoleRef{ + APIGroup: "rbac.authorization.k8s.io", + Kind: "ClusterRole", + Name: "csv-service-account-one", + }, + }, + &rbacv1.ClusterRole{ + TypeMeta: metav1.TypeMeta{ + Kind: "ClusterRole", + APIVersion: rbacv1.SchemeGroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "csv-service-account-two", + }, + Rules: []rbacv1.PolicyRule{ + { + APIGroups: []string{"appsv1"}, + Resources: []string{"deployments"}, + Verbs: []string{"create"}, + }, + }, + }, + &rbacv1.ClusterRoleBinding{ + TypeMeta: metav1.TypeMeta{ + Kind: "ClusterRoleBinding", + APIVersion: rbacv1.SchemeGroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "csv-service-account-two", + }, + Subjects: []rbacv1.Subject{ + { + Kind: "ServiceAccount", + APIGroup: "", + Name: "service-account-two", + Namespace: "install-namespace", + }, + }, + RoleRef: rbacv1.RoleRef{ + APIGroup: "rbac.authorization.k8s.io", + Kind: "ClusterRole", + Name: "csv-service-account-two", + }, + }, + }, + }, + { + name: "treats empty service accounts as 'default' service account", + opts: render.Options{ + InstallNamespace: "install-namespace", + TargetNamespaces: []string{"watch-namespace"}, + UniqueNameGenerator: fakeUniqueNameGenerator, + }, + bundle: &render.RegistryV1{ + CSV: MakeCSV( + WithName("csv"), + WithClusterPermissions( + v1alpha1.StrategyDeploymentPermissions{ + ServiceAccountName: "", + Rules: []rbacv1.PolicyRule{ + { + APIGroups: []string{""}, + Resources: []string{"namespaces"}, + Verbs: []string{"get", "list", "watch"}, + }, + }, + }, + ), + ), + }, + expectedResources: []client.Object{ + &rbacv1.ClusterRole{ + TypeMeta: metav1.TypeMeta{ + Kind: "ClusterRole", + APIVersion: rbacv1.SchemeGroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "csv-default", + }, + Rules: []rbacv1.PolicyRule{ + { + APIGroups: []string{""}, + Resources: []string{"namespaces"}, + Verbs: []string{"get", "list", "watch"}, + }, + }, + }, + &rbacv1.ClusterRoleBinding{ + TypeMeta: metav1.TypeMeta{ + Kind: "ClusterRoleBinding", + APIVersion: rbacv1.SchemeGroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "csv-default", + }, + Subjects: []rbacv1.Subject{ + { + Kind: "ServiceAccount", + APIGroup: "", + Name: "default", + Namespace: "install-namespace", + }, + }, + RoleRef: rbacv1.RoleRef{ + APIGroup: "rbac.authorization.k8s.io", + Kind: "ClusterRole", + Name: "csv-default", + }, + }, + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + objs, err := generators.BundleCSVClusterPermissionsGenerator(tc.bundle, tc.opts) + require.NoError(t, err) + for i := range objs { + require.Equal(t, tc.expectedResources[i], objs[i], "failed to find expected resource at index %d", i) + } + require.Equal(t, len(tc.expectedResources), len(objs)) + }) + } +} + +func Test_BundleCSVClusterPermissionGenerator_FailsOnNil(t *testing.T) { + objs, err := generators.BundleCSVClusterPermissionsGenerator(nil, render.Options{}) + require.Nil(t, objs) + require.Error(t, err) + require.Contains(t, err.Error(), "bundle cannot be nil") +} + +func Test_BundleCSVServiceAccountGenerator_Succeeds(t *testing.T) { + for _, tc := range []struct { + name string + opts render.Options + bundle *render.RegistryV1 + expectedResources []client.Object + }{ + { + name: "generates unique set of clusterpermissions and permissions service accounts in the install namespace", + opts: render.Options{ + InstallNamespace: "install-namespace", + }, + bundle: &render.RegistryV1{ + CSV: MakeCSV( + WithName("csv"), + WithPermissions( + v1alpha1.StrategyDeploymentPermissions{ + ServiceAccountName: "service-account-1", + Rules: []rbacv1.PolicyRule{ + { + APIGroups: []string{""}, + Resources: []string{"namespaces"}, + Verbs: []string{"get", "list", "watch"}, + }, + }, + }, + v1alpha1.StrategyDeploymentPermissions{ + ServiceAccountName: "service-account-2", + Rules: []rbacv1.PolicyRule{ + { + APIGroups: []string{"appsv1"}, + Resources: []string{"deployments"}, + Verbs: []string{"create"}, + }, + }, + }, + ), + WithClusterPermissions( + v1alpha1.StrategyDeploymentPermissions{ + ServiceAccountName: "service-account-2", + Rules: []rbacv1.PolicyRule{ + { + APIGroups: []string{""}, + Resources: []string{"namespaces"}, + Verbs: []string{"get", "list", "watch"}, + }, + }, + }, + v1alpha1.StrategyDeploymentPermissions{ + ServiceAccountName: "service-account-3", + Rules: []rbacv1.PolicyRule{ + { + APIGroups: []string{"appsv1"}, + Resources: []string{"deployments"}, + Verbs: []string{"create"}, + }, + }, + }, + ), + ), + }, + expectedResources: []client.Object{ + &corev1.ServiceAccount{ + TypeMeta: metav1.TypeMeta{ + Kind: "ServiceAccount", + APIVersion: corev1.SchemeGroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "service-account-1", + Namespace: "install-namespace", + }, + }, + &corev1.ServiceAccount{ + TypeMeta: metav1.TypeMeta{ + Kind: "ServiceAccount", + APIVersion: corev1.SchemeGroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "service-account-2", + Namespace: "install-namespace", + }, + }, + &corev1.ServiceAccount{ + TypeMeta: metav1.TypeMeta{ + Kind: "ServiceAccount", + APIVersion: corev1.SchemeGroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "service-account-3", + Namespace: "install-namespace", + }, + }, + }, + }, + { + name: "treats empty service accounts as default and doesn't generate them", + opts: render.Options{ + InstallNamespace: "install-namespace", + }, + bundle: &render.RegistryV1{ + CSV: MakeCSV( + WithName("csv"), + WithPermissions( + v1alpha1.StrategyDeploymentPermissions{ + ServiceAccountName: "", + Rules: []rbacv1.PolicyRule{ + { + APIGroups: []string{""}, + Resources: []string{"namespaces"}, + Verbs: []string{"get", "list", "watch"}, + }, + }, + }, + ), + WithClusterPermissions( + v1alpha1.StrategyDeploymentPermissions{ + ServiceAccountName: "", + Rules: []rbacv1.PolicyRule{ + { + APIGroups: []string{""}, + Resources: []string{"namespaces"}, + Verbs: []string{"get", "list", "watch"}, + }, + }, + }, + ), + ), + }, + expectedResources: nil, + }, + } { + t.Run(tc.name, func(t *testing.T) { + objs, err := generators.BundleCSVServiceAccountGenerator(tc.bundle, tc.opts) + require.NoError(t, err) + slices.SortFunc(objs, func(a, b client.Object) int { + return cmp.Compare(a.GetName(), b.GetName()) + }) + for i := range objs { + require.Equal(t, tc.expectedResources[i], objs[i], "failed to find expected resource at index %d", i) + } + require.Equal(t, len(tc.expectedResources), len(objs)) + }) + } +} + +func Test_BundleCSVServiceAccountGenerator_FailsOnNil(t *testing.T) { + objs, err := generators.BundleCSVServiceAccountGenerator(nil, render.Options{}) + require.Nil(t, objs) + require.Error(t, err) + require.Contains(t, err.Error(), "bundle cannot be nil") +} + +func Test_BundleCRDGenerator_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"}}, + }, + } + + objs, err := generators.BundleCRDGenerator(bundle, opts) + require.NoError(t, err) + require.Equal(t, []client.Object{ + &apiextensionsv1.CustomResourceDefinition{ObjectMeta: metav1.ObjectMeta{Name: "crd-one"}}, + &apiextensionsv1.CustomResourceDefinition{ObjectMeta: metav1.ObjectMeta{Name: "crd-two"}}, + }, 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) + require.Error(t, err) + require.Contains(t, err.Error(), "bundle cannot be nil") +} + +func Test_BundleAdditionalResourcesGenerator_Succeeds(t *testing.T) { + opts := render.Options{ + InstallNamespace: "install-namespace", + } + + bundle := &render.RegistryV1{ + Others: []unstructured.Unstructured{ + *ToUnstructuredT(t, + &corev1.Service{ + TypeMeta: metav1.TypeMeta{ + Kind: "Service", + APIVersion: corev1.SchemeGroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "bundled-service", + }, + }, + ), + *ToUnstructuredT(t, + &rbacv1.ClusterRole{ + TypeMeta: metav1.TypeMeta{ + Kind: "ClusterRole", + APIVersion: rbacv1.SchemeGroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "bundled-clusterrole", + }, + }, + ), + }, + } + + objs, err := generators.BundleAdditionalResourcesGenerator(bundle, opts) + require.NoError(t, err) + require.Len(t, objs, 2) +} + +func Test_BundleAdditionalResourcesGenerator_FailsOnNil(t *testing.T) { + objs, err := generators.BundleAdditionalResourcesGenerator(nil, render.Options{}) + require.Nil(t, objs) + require.Error(t, err) + require.Contains(t, err.Error(), "bundle cannot be nil") +} + +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{ + GenerateName: "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{ + GenerateName: "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{ + GenerateName: "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{ + GenerateName: "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{ + 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{ + 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 new file mode 100644 index 000000000..64b4a7fb6 --- /dev/null +++ b/internal/operator-controller/rukpak/render/generators/resources.go @@ -0,0 +1,253 @@ +package generators + +import ( + "fmt" + + admissionregistrationv1 "k8s.io/api/admissionregistration/v1" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +type ResourceCreatorOption = func(client.Object) +type ResourceCreatorOptions []ResourceCreatorOption + +func (r ResourceCreatorOptions) ApplyTo(obj client.Object) client.Object { + if obj == nil { + return nil + } + for _, opt := range r { + if opt != nil { + opt(obj) + } + } + return obj +} + +// WithSubjects applies rbac subjects to ClusterRoleBinding and RoleBinding resources +func WithSubjects(subjects ...rbacv1.Subject) func(client.Object) { + return func(obj client.Object) { + switch o := obj.(type) { + case *rbacv1.RoleBinding: + o.Subjects = subjects + case *rbacv1.ClusterRoleBinding: + o.Subjects = subjects + } + } +} + +// WithRoleRef applies an rbac RoleRef to ClusterRoleBinding and RoleBinding resources +func WithRoleRef(roleRef rbacv1.RoleRef) func(client.Object) { + return func(obj client.Object) { + switch o := obj.(type) { + case *rbacv1.RoleBinding: + o.RoleRef = roleRef + case *rbacv1.ClusterRoleBinding: + o.RoleRef = roleRef + } + } +} + +// WithRules applies rbac PolicyRules to Role and ClusterRole resources +func WithRules(rules ...rbacv1.PolicyRule) func(client.Object) { + return func(obj client.Object) { + switch o := obj.(type) { + case *rbacv1.Role: + o.Rules = rules + case *rbacv1.ClusterRole: + o.Rules = rules + } + } +} + +// WithDeploymentSpec applies a DeploymentSpec to Deployment resources +func WithDeploymentSpec(depSpec appsv1.DeploymentSpec) func(client.Object) { + return func(obj client.Object) { + switch o := obj.(type) { + case *appsv1.Deployment: + o.Spec = depSpec + } + } +} + +// WithLabels applies labels to the metadata of any resource +func WithLabels(labels map[string]string) func(client.Object) { + return func(obj client.Object) { + obj.SetLabels(labels) + } +} + +// 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 { + return ResourceCreatorOptions(opts).ApplyTo( + &corev1.ServiceAccount{ + TypeMeta: metav1.TypeMeta{ + Kind: "ServiceAccount", + APIVersion: corev1.SchemeGroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Namespace: namespace, + Name: name, + }, + }, + ).(*corev1.ServiceAccount) +} + +// CreateRoleResource creates a Role resource with name 'name' and namespace 'namespace' and applying any +// Role related options in opts +func CreateRoleResource(name string, namespace string, opts ...ResourceCreatorOption) *rbacv1.Role { + return ResourceCreatorOptions(opts).ApplyTo( + &rbacv1.Role{ + TypeMeta: metav1.TypeMeta{ + Kind: "Role", + APIVersion: rbacv1.SchemeGroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Namespace: namespace, + Name: name, + }, + }, + ).(*rbacv1.Role) +} + +// CreateClusterRoleResource creates a ClusterRole resource with name 'name' and applying any +// ClusterRole related options in opts +func CreateClusterRoleResource(name string, opts ...ResourceCreatorOption) *rbacv1.ClusterRole { + return ResourceCreatorOptions(opts).ApplyTo( + &rbacv1.ClusterRole{ + TypeMeta: metav1.TypeMeta{ + Kind: "ClusterRole", + APIVersion: rbacv1.SchemeGroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Name: name, + }, + }, + ).(*rbacv1.ClusterRole) +} + +// CreateClusterRoleBindingResource creates a ClusterRoleBinding resource with name 'name' and applying any +// ClusterRoleBinding related options in opts +func CreateClusterRoleBindingResource(name string, opts ...ResourceCreatorOption) *rbacv1.ClusterRoleBinding { + return ResourceCreatorOptions(opts).ApplyTo( + &rbacv1.ClusterRoleBinding{ + TypeMeta: metav1.TypeMeta{ + Kind: "ClusterRoleBinding", + APIVersion: rbacv1.SchemeGroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Name: name, + }, + }, + ).(*rbacv1.ClusterRoleBinding) +} + +// CreateRoleBindingResource creates a RoleBinding resource with name 'name', namespace 'namespace', and applying any +// RoleBinding related options in opts +func CreateRoleBindingResource(name string, namespace string, opts ...ResourceCreatorOption) *rbacv1.RoleBinding { + return ResourceCreatorOptions(opts).ApplyTo( + &rbacv1.RoleBinding{ + TypeMeta: metav1.TypeMeta{ + Kind: "RoleBinding", + APIVersion: rbacv1.SchemeGroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Namespace: namespace, + Name: name, + }, + }, + ).(*rbacv1.RoleBinding) +} + +// CreateDeploymentResource creates a Deployment resource with name 'name', namespace 'namespace', and applying any +// Deployment related options in opts +func CreateDeploymentResource(name string, namespace string, opts ...ResourceCreatorOption) *appsv1.Deployment { + return ResourceCreatorOptions(opts).ApplyTo( + &appsv1.Deployment{ + TypeMeta: metav1.TypeMeta{ + Kind: "Deployment", + APIVersion: appsv1.SchemeGroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Namespace: namespace, + Name: name, + }, + }, + ).(*appsv1.Deployment) +} + +func CreateValidatingWebhookConfigurationResource(generateName 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{ + GenerateName: fmt.Sprintf("%s-", generateName), + Namespace: namespace, + }, + }, + ).(*admissionregistrationv1.ValidatingWebhookConfiguration) +} + +func CreateMutatingWebhookConfigurationResource(generateName 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{ + GenerateName: fmt.Sprintf("%s-", generateName), + Namespace: namespace, + }, + }, + ).(*admissionregistrationv1.MutatingWebhookConfiguration) +} + +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 new file mode 100644 index 000000000..1eef933e5 --- /dev/null +++ b/internal/operator-controller/rukpak/render/generators/resources_test.go @@ -0,0 +1,278 @@ +package generators_test + +import ( + "maps" + "slices" + "strings" + "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" + + "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/render/generators" + "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/util" +) + +func Test_OptionsApplyToExecutesIgnoresNil(t *testing.T) { + opts := []generators.ResourceCreatorOption{ + func(object client.Object) { + object.SetAnnotations(util.MergeMaps(object.GetAnnotations(), map[string]string{"h": ""})) + }, + nil, + func(object client.Object) { + object.SetAnnotations(util.MergeMaps(object.GetAnnotations(), map[string]string{"i": ""})) + }, + nil, + } + + require.Nil(t, generators.ResourceCreatorOptions(nil).ApplyTo(nil)) + require.Nil(t, generators.ResourceCreatorOptions([]generators.ResourceCreatorOption{}).ApplyTo(nil)) + + obj := generators.ResourceCreatorOptions(opts).ApplyTo(&corev1.ConfigMap{}) + require.Equal(t, "hi", strings.Join(slices.Sorted(maps.Keys(obj.GetAnnotations())), "")) +} + +func Test_CreateServiceAccount(t *testing.T) { + svc := generators.CreateServiceAccountResource("my-sa", "my-namespace") + require.NotNil(t, svc) + require.Equal(t, "my-sa", svc.Name) + require.Equal(t, "my-namespace", svc.Namespace) +} + +func Test_CreateRole(t *testing.T) { + role := generators.CreateRoleResource("my-role", "my-namespace") + require.NotNil(t, role) + require.Equal(t, "my-role", role.Name) + require.Equal(t, "my-namespace", role.Namespace) +} + +func Test_CreateRoleBinding(t *testing.T) { + roleBinding := generators.CreateRoleBindingResource("my-role-binding", "my-namespace") + require.NotNil(t, roleBinding) + require.Equal(t, "my-role-binding", roleBinding.Name) + require.Equal(t, "my-namespace", roleBinding.Namespace) +} + +func Test_CreateClusterRole(t *testing.T) { + clusterRole := generators.CreateClusterRoleResource("my-cluster-role") + require.NotNil(t, clusterRole) + require.Equal(t, "my-cluster-role", clusterRole.Name) +} + +func Test_CreateClusterRoleBinding(t *testing.T) { + clusterRoleBinding := generators.CreateClusterRoleBindingResource("my-cluster-role-binding") + require.NotNil(t, clusterRoleBinding) + require.Equal(t, "my-cluster-role-binding", clusterRoleBinding.Name) +} + +func Test_CreateDeployment(t *testing.T) { + deployment := generators.CreateDeploymentResource("my-deployment", "my-namespace") + require.NotNil(t, deployment) + require.Equal(t, "my-deployment", deployment.Name) + 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.GenerateName) + 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.GenerateName) + require.Equal(t, "my-namespace", wh.Namespace) +} + +func Test_WithSubjects(t *testing.T) { + for _, tc := range []struct { + name string + subjects []rbacv1.Subject + }{ + { + name: "empty", + subjects: []rbacv1.Subject{}, + }, { + name: "nil", + subjects: nil, + }, { + name: "single subject", + subjects: []rbacv1.Subject{ + { + APIGroup: rbacv1.GroupName, + Kind: rbacv1.ServiceAccountKind, + Name: "my-sa", + Namespace: "my-namespace", + }, + }, + }, { + name: "multiple subjects", + subjects: []rbacv1.Subject{ + { + APIGroup: rbacv1.GroupName, + Kind: rbacv1.ServiceAccountKind, + Name: "my-sa", + Namespace: "my-namespace", + }, + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + roleBinding := generators.CreateRoleBindingResource("my-role", "my-namespace", generators.WithSubjects(tc.subjects...)) + require.NotNil(t, roleBinding) + require.Equal(t, roleBinding.Subjects, tc.subjects) + + clusterRoleBinding := generators.CreateClusterRoleBindingResource("my-role", generators.WithSubjects(tc.subjects...)) + require.NotNil(t, clusterRoleBinding) + require.Equal(t, clusterRoleBinding.Subjects, tc.subjects) + }) + } +} + +func Test_WithRules(t *testing.T) { + for _, tc := range []struct { + name string + rules []rbacv1.PolicyRule + }{ + { + name: "empty", + rules: []rbacv1.PolicyRule{}, + }, { + name: "nil", + rules: nil, + }, { + name: "single subject", + rules: []rbacv1.PolicyRule{ + { + Verbs: []string{"*"}, + APIGroups: []string{"*"}, + Resources: []string{"*"}, + }, + }, + }, { + name: "multiple subjects", + rules: []rbacv1.PolicyRule{ + { + Verbs: []string{"*"}, + APIGroups: []string{"*"}, + Resources: []string{"*"}, + ResourceNames: []string{"my-resource"}, + }, { + Verbs: []string{"get", "list", "watch"}, + APIGroups: []string{"appsv1"}, + Resources: []string{"deployments", "replicasets", "statefulsets"}, + }, + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + role := generators.CreateRoleResource("my-role", "my-namespace", generators.WithRules(tc.rules...)) + require.NotNil(t, role) + require.Equal(t, role.Rules, tc.rules) + + clusterRole := generators.CreateClusterRoleResource("my-role", generators.WithRules(tc.rules...)) + require.NotNil(t, clusterRole) + require.Equal(t, clusterRole.Rules, tc.rules) + }) + } +} + +func Test_WithRoleRef(t *testing.T) { + roleRef := rbacv1.RoleRef{ + APIGroup: rbacv1.GroupName, + Kind: "Role", + Name: "my-role", + } + + roleBinding := generators.CreateRoleBindingResource("my-role-binding", "my-namespace", generators.WithRoleRef(roleRef)) + require.NotNil(t, roleBinding) + require.Equal(t, roleRef, roleBinding.RoleRef) + + clusterRoleBinding := generators.CreateClusterRoleBindingResource("my-cluster-role-binding", generators.WithRoleRef(roleRef)) + require.NotNil(t, clusterRoleBinding) + require.Equal(t, roleRef, clusterRoleBinding.RoleRef) +} + +func Test_WithLabels(t *testing.T) { + for _, tc := range []struct { + name string + labels map[string]string + }{ + { + name: "empty", + labels: map[string]string{}, + }, { + name: "nil", + labels: nil, + }, { + name: "not empty", + labels: map[string]string{ + "foo": "bar", + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + dep := generators.CreateDeploymentResource("my-deployment", "my-namespace", generators.WithLabels(tc.labels)) + require.NotNil(t, dep) + require.Equal(t, tc.labels, dep.Labels) + }) + } +} + +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 new file mode 100644 index 000000000..0a8f3a427 --- /dev/null +++ b/internal/operator-controller/rukpak/render/render.go @@ -0,0 +1,112 @@ +package render + +import ( + "errors" + + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/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/util" +) + +type RegistryV1 struct { + PackageName string + CSV v1alpha1.ClusterServiceVersion + CRDs []apiextensionsv1.CustomResourceDefinition + Others []unstructured.Unstructured +} + +// BundleValidator validates a RegistryV1 bundle by executing a series of +// checks on it and collecting any errors that were found +type BundleValidator []func(v1 *RegistryV1) []error + +func (v BundleValidator) Validate(rv1 *RegistryV1) error { + var errs []error + for _, validator := range v { + errs = append(errs, validator(rv1)...) + } + return errors.Join(errs...) +} + +// ResourceGenerator generates resources given a registry+v1 bundle and options +type ResourceGenerator func(rv1 *RegistryV1, opts Options) ([]client.Object, error) + +func (g ResourceGenerator) GenerateResources(rv1 *RegistryV1, opts Options) ([]client.Object, error) { + return g(rv1, opts) +} + +// ResourceGenerators aggregates generators. Its GenerateResource method will call all of its generators and return +// generated resources. +type ResourceGenerators []ResourceGenerator + +func (r ResourceGenerators) GenerateResources(rv1 *RegistryV1, opts Options) ([]client.Object, error) { + //nolint:prealloc + var renderedObjects []client.Object + for _, generator := range r { + objs, err := generator.GenerateResources(rv1, opts) + if err != nil { + return nil, err + } + renderedObjects = append(renderedObjects, objs...) + } + return renderedObjects, nil +} + +func (r ResourceGenerators) ResourceGenerator() ResourceGenerator { + return r.GenerateResources +} + +type UniqueNameGenerator func(string, interface{}) (string, error) + +type Options struct { + InstallNamespace string + TargetNamespaces []string + UniqueNameGenerator UniqueNameGenerator + CertificateProvider CertificateProvider +} + +func (o *Options) apply(opts ...Option) *Options { + for _, opt := range opts { + opt(o) + } + return o +} + +type Option func(*Options) + +type BundleRenderer struct { + BundleValidator BundleValidator + ResourceGenerators []ResourceGenerator +} + +func (r BundleRenderer) Render(rv1 RegistryV1, installNamespace string, watchNamespaces []string, opts ...Option) ([]client.Object, error) { + // validate bundle + if err := r.BundleValidator.Validate(&rv1); err != nil { + return nil, err + } + + genOpts := (&Options{ + InstallNamespace: installNamespace, + TargetNamespaces: watchNamespaces, + UniqueNameGenerator: DefaultUniqueNameGenerator, + }).apply(opts...) + + // generate bundle objects + objs, err := ResourceGenerators(r.ResourceGenerators).GenerateResources(&rv1, *genOpts) + if err != nil { + return nil, err + } + + return objs, nil +} + +func DefaultUniqueNameGenerator(base string, o interface{}) (string, error) { + hashStr, err := util.DeepHashObject(o) + if err != nil { + return "", err + } + return util.ObjectNameForBaseAndSuffix(base, hashStr), nil +} diff --git a/internal/operator-controller/rukpak/render/render_test.go b/internal/operator-controller/rukpak/render/render_test.go new file mode 100644 index 000000000..ede57ff69 --- /dev/null +++ b/internal/operator-controller/rukpak/render/render_test.go @@ -0,0 +1,104 @@ +package render_test + +import ( + "errors" + "fmt" + "reflect" + "testing" + + "github.com/stretchr/testify/require" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/render" +) + +func Test_BundleRenderer_NoConfig(t *testing.T) { + renderer := render.BundleRenderer{} + objs, err := renderer.Render(render.RegistryV1{}, "", nil) + require.NoError(t, err) + require.Empty(t, objs) +} + +func Test_BundleRenderer_ValidatesBundle(t *testing.T) { + renderer := render.BundleRenderer{ + BundleValidator: render.BundleValidator{ + func(v1 *render.RegistryV1) []error { + return []error{errors.New("this bundle is invalid")} + }, + }, + } + objs, err := renderer.Render(render.RegistryV1{}, "", nil) + require.Nil(t, objs) + require.Error(t, err) + require.Contains(t, err.Error(), "this bundle is invalid") +} + +func Test_BundleRenderer_CreatesCorrectDefaultOptions(t *testing.T) { + expectedInstallNamespace := "install-namespace" + expectedTargetNamespaces := []string{"ns-one", "ns-two"} + expectedUniqueNameGenerator := render.DefaultUniqueNameGenerator + + renderer := render.BundleRenderer{ + ResourceGenerators: []render.ResourceGenerator{ + func(rv1 *render.RegistryV1, opts render.Options) ([]client.Object, error) { + require.Equal(t, expectedInstallNamespace, opts.InstallNamespace) + require.Equal(t, expectedTargetNamespaces, opts.TargetNamespaces) + require.Equal(t, reflect.ValueOf(expectedUniqueNameGenerator).Pointer(), reflect.ValueOf(render.DefaultUniqueNameGenerator).Pointer(), "options has unexpected default unique name generator") + return nil, nil + }, + }, + } + + _, _ = renderer.Render(render.RegistryV1{}, expectedInstallNamespace, expectedTargetNamespaces) +} + +func Test_BundleRenderer_CallsResourceGenerators(t *testing.T) { + renderer := render.BundleRenderer{ + ResourceGenerators: []render.ResourceGenerator{ + func(rv1 *render.RegistryV1, opts render.Options) ([]client.Object, error) { + return []client.Object{&corev1.Namespace{}, &corev1.Service{}}, nil + }, + func(rv1 *render.RegistryV1, opts render.Options) ([]client.Object, error) { + return []client.Object{&appsv1.Deployment{}}, nil + }, + }, + } + objs, err := renderer.Render(render.RegistryV1{}, "", nil) + require.NoError(t, err) + require.Equal(t, []client.Object{&corev1.Namespace{}, &corev1.Service{}, &appsv1.Deployment{}}, objs) +} + +func Test_BundleRenderer_ReturnsResourceGeneratorErrors(t *testing.T) { + renderer := render.BundleRenderer{ + ResourceGenerators: []render.ResourceGenerator{ + func(rv1 *render.RegistryV1, opts render.Options) ([]client.Object, error) { + return []client.Object{&corev1.Namespace{}, &corev1.Service{}}, nil + }, + func(rv1 *render.RegistryV1, opts render.Options) ([]client.Object, error) { + return nil, fmt.Errorf("generator error") + }, + }, + } + objs, err := renderer.Render(render.RegistryV1{}, "", nil) + require.Nil(t, objs) + require.Error(t, err) + require.Contains(t, err.Error(), "generator error") +} + +func Test_BundleValidatorCallsAllValidationFnsInOrder(t *testing.T) { + actual := "" + val := render.BundleValidator{ + func(v1 *render.RegistryV1) []error { + actual += "h" + return nil + }, + func(v1 *render.RegistryV1) []error { + actual += "i" + return nil + }, + } + require.NoError(t, val.Validate(nil)) + require.Equal(t, "hi", actual) +} diff --git a/internal/operator-controller/rukpak/render/validators/validator.go b/internal/operator-controller/rukpak/render/validators/validator.go new file mode 100644 index 000000000..ee77292a3 --- /dev/null +++ b/internal/operator-controller/rukpak/render/validators/validator.go @@ -0,0 +1,238 @@ +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" +) + +// RegistryV1BundleValidator validates RegistryV1 bundles +var RegistryV1BundleValidator = render.BundleValidator{ + // NOTE: if you update this list, Test_BundleValidatorHasAllValidationFns will fail until + // you bring the same changes over to that test. This helps ensure all validation rules are executed + // while giving us the flexibility to test each validation function individually + CheckDeploymentSpecUniqueness, + CheckCRDResourceUniqueness, + CheckOwnedCRDExistence, + CheckPackageNameNotEmpty, + CheckWebhookDeploymentReferentialIntegrity, + CheckWebhookNameUniqueness, + CheckConversionWebhookCRDReferenceUniqueness, + CheckConversionWebhooksReferenceOwnedCRDs, +} + +// CheckDeploymentSpecUniqueness checks that each strategy deployment spec in the csv has a unique name. +// Errors are sorted by deployment name. +func CheckDeploymentSpecUniqueness(rv1 *render.RegistryV1) []error { + deploymentNameSet := sets.Set[string]{} + duplicateDeploymentNames := sets.Set[string]{} + for _, dep := range rv1.CSV.Spec.InstallStrategy.StrategySpec.DeploymentSpecs { + if deploymentNameSet.Has(dep.Name) { + duplicateDeploymentNames.Insert(dep.Name) + } + deploymentNameSet.Insert(dep.Name) + } + + errs := make([]error, 0, len(duplicateDeploymentNames)) + for _, d := range slices.Sorted(slices.Values(duplicateDeploymentNames.UnsortedList())) { + errs = append(errs, fmt.Errorf("cluster service version contains duplicate strategy deployment spec '%s'", d)) + } + return errs +} + +// CheckOwnedCRDExistence checks bundle owned custom resource definitions declared in the csv exist in the bundle +func CheckOwnedCRDExistence(rv1 *render.RegistryV1) []error { + crdsNames := sets.Set[string]{} + for _, crd := range rv1.CRDs { + crdsNames.Insert(crd.Name) + } + + missingCRDNames := sets.Set[string]{} + for _, crd := range rv1.CSV.Spec.CustomResourceDefinitions.Owned { + if !crdsNames.Has(crd.Name) { + missingCRDNames.Insert(crd.Name) + } + } + + errs := make([]error, 0, len(missingCRDNames)) + for _, crdName := range slices.Sorted(slices.Values(missingCRDNames.UnsortedList())) { + errs = append(errs, fmt.Errorf("cluster service definition references owned custom resource definition '%s' not found in bundle", crdName)) + } + return errs +} + +// CheckCRDResourceUniqueness checks that the bundle CRD names are unique +func CheckCRDResourceUniqueness(rv1 *render.RegistryV1) []error { + crdsNames := sets.Set[string]{} + duplicateCRDNames := sets.Set[string]{} + for _, crd := range rv1.CRDs { + if crdsNames.Has(crd.Name) { + duplicateCRDNames.Insert(crd.Name) + } + crdsNames.Insert(crd.Name) + } + + errs := make([]error, 0, len(duplicateCRDNames)) + for _, crdName := range slices.Sorted(slices.Values(duplicateCRDNames.UnsortedList())) { + errs = append(errs, fmt.Errorf("bundle contains duplicate custom resource definition '%s'", crdName)) + } + return errs +} + +// CheckPackageNameNotEmpty checks that PackageName is not empty +func CheckPackageNameNotEmpty(rv1 *render.RegistryV1) []error { + if rv1.PackageName == "" { + return []error{errors.New("package name is empty")} + } + 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 new file mode 100644 index 000000000..d8a480bf6 --- /dev/null +++ b/internal/operator-controller/rukpak/render/validators/validator_test.go @@ -0,0 +1,839 @@ +package validators_test + +import ( + "errors" + "reflect" + "testing" + + "github.com/stretchr/testify/require" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "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/render/validators" + . "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/util/testing" +) + +func Test_BundleValidatorHasAllValidationFns(t *testing.T) { + expectedValidationFns := []func(v1 *render.RegistryV1) []error{ + validators.CheckDeploymentSpecUniqueness, + validators.CheckCRDResourceUniqueness, + validators.CheckOwnedCRDExistence, + validators.CheckPackageNameNotEmpty, + validators.CheckWebhookDeploymentReferentialIntegrity, + validators.CheckWebhookNameUniqueness, + validators.CheckConversionWebhookCRDReferenceUniqueness, + validators.CheckConversionWebhooksReferenceOwnedCRDs, + } + actualValidationFns := validators.RegistryV1BundleValidator + + require.Equal(t, len(expectedValidationFns), len(actualValidationFns)) + for i := range expectedValidationFns { + require.Equal(t, reflect.ValueOf(expectedValidationFns[i]).Pointer(), reflect.ValueOf(actualValidationFns[i]).Pointer(), "bundle validator has unexpected validation function") + } +} + +func Test_CheckDeploymentSpecUniqueness(t *testing.T) { + for _, tc := range []struct { + name string + bundle *render.RegistryV1 + expectedErrs []error + }{ + { + name: "accepts bundles with unique deployment strategy spec names", + bundle: &render.RegistryV1{ + CSV: MakeCSV( + WithStrategyDeploymentSpecs( + v1alpha1.StrategyDeploymentSpec{Name: "test-deployment-one"}, + v1alpha1.StrategyDeploymentSpec{Name: "test-deployment-two"}, + ), + ), + }, + expectedErrs: []error{}, + }, { + name: "rejects bundles with duplicate deployment strategy spec names", + bundle: &render.RegistryV1{ + CSV: MakeCSV( + WithStrategyDeploymentSpecs( + v1alpha1.StrategyDeploymentSpec{Name: "test-deployment-one"}, + v1alpha1.StrategyDeploymentSpec{Name: "test-deployment-two"}, + v1alpha1.StrategyDeploymentSpec{Name: "test-deployment-one"}, + ), + ), + }, + expectedErrs: []error{ + errors.New("cluster service version contains duplicate strategy deployment spec 'test-deployment-one'"), + }, + }, { + name: "errors are ordered by deployment strategy spec name", + bundle: &render.RegistryV1{ + CSV: MakeCSV( + WithStrategyDeploymentSpecs( + v1alpha1.StrategyDeploymentSpec{Name: "test-deployment-a"}, + v1alpha1.StrategyDeploymentSpec{Name: "test-deployment-b"}, + v1alpha1.StrategyDeploymentSpec{Name: "test-deployment-c"}, + v1alpha1.StrategyDeploymentSpec{Name: "test-deployment-b"}, + v1alpha1.StrategyDeploymentSpec{Name: "test-deployment-a"}, + ), + ), + }, + expectedErrs: []error{ + errors.New("cluster service version contains duplicate strategy deployment spec 'test-deployment-a'"), + errors.New("cluster service version contains duplicate strategy deployment spec 'test-deployment-b'"), + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + errs := validators.CheckDeploymentSpecUniqueness(tc.bundle) + require.Equal(t, tc.expectedErrs, errs) + }) + } +} + +func Test_CRDResourceUniqueness(t *testing.T) { + for _, tc := range []struct { + name string + bundle *render.RegistryV1 + expectedErrs []error + }{ + { + name: "accepts bundles with unique custom resource definition resources", + bundle: &render.RegistryV1{ + CRDs: []apiextensionsv1.CustomResourceDefinition{ + {ObjectMeta: metav1.ObjectMeta{Name: "a.crd.something"}}, + {ObjectMeta: metav1.ObjectMeta{Name: "b.crd.something"}}, + }, + }, + expectedErrs: []error{}, + }, { + name: "rejects bundles with duplicate custom resource definition resources", + bundle: &render.RegistryV1{CRDs: []apiextensionsv1.CustomResourceDefinition{ + {ObjectMeta: metav1.ObjectMeta{Name: "a.crd.something"}}, + {ObjectMeta: metav1.ObjectMeta{Name: "a.crd.something"}}, + }}, + expectedErrs: []error{ + errors.New("bundle contains duplicate custom resource definition 'a.crd.something'"), + }, + }, { + name: "errors are ordered by custom resource definition name", + bundle: &render.RegistryV1{CRDs: []apiextensionsv1.CustomResourceDefinition{ + {ObjectMeta: metav1.ObjectMeta{Name: "c.crd.something"}}, + {ObjectMeta: metav1.ObjectMeta{Name: "c.crd.something"}}, + {ObjectMeta: metav1.ObjectMeta{Name: "a.crd.something"}}, + {ObjectMeta: metav1.ObjectMeta{Name: "a.crd.something"}}, + }}, + expectedErrs: []error{ + errors.New("bundle contains duplicate custom resource definition 'a.crd.something'"), + errors.New("bundle contains duplicate custom resource definition 'c.crd.something'"), + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + err := validators.CheckCRDResourceUniqueness(tc.bundle) + require.Equal(t, tc.expectedErrs, err) + }) + } +} + +func Test_CheckOwnedCRDExistence(t *testing.T) { + for _, tc := range []struct { + name string + bundle *render.RegistryV1 + expectedErrs []error + }{ + { + name: "accepts bundles with existing owned custom resource definition resources", + bundle: &render.RegistryV1{ + CRDs: []apiextensionsv1.CustomResourceDefinition{ + {ObjectMeta: metav1.ObjectMeta{Name: "a.crd.something"}}, + {ObjectMeta: metav1.ObjectMeta{Name: "b.crd.something"}}, + }, + CSV: MakeCSV( + WithOwnedCRDs( + v1alpha1.CRDDescription{Name: "a.crd.something"}, + v1alpha1.CRDDescription{Name: "b.crd.something"}, + ), + ), + }, + expectedErrs: []error{}, + }, { + name: "rejects bundles with missing owned custom resource definition resources", + bundle: &render.RegistryV1{ + CRDs: []apiextensionsv1.CustomResourceDefinition{}, + CSV: MakeCSV( + WithOwnedCRDs(v1alpha1.CRDDescription{Name: "a.crd.something"}), + ), + }, + expectedErrs: []error{ + errors.New("cluster service definition references owned custom resource definition 'a.crd.something' not found in bundle"), + }, + }, { + name: "errors are ordered by owned custom resource definition name", + bundle: &render.RegistryV1{ + CRDs: []apiextensionsv1.CustomResourceDefinition{}, + CSV: MakeCSV( + WithOwnedCRDs( + v1alpha1.CRDDescription{Name: "a.crd.something"}, + v1alpha1.CRDDescription{Name: "c.crd.something"}, + v1alpha1.CRDDescription{Name: "b.crd.something"}, + ), + ), + }, + expectedErrs: []error{ + errors.New("cluster service definition references owned custom resource definition 'a.crd.something' not found in bundle"), + errors.New("cluster service definition references owned custom resource definition 'b.crd.something' not found in bundle"), + errors.New("cluster service definition references owned custom resource definition 'c.crd.something' not found in bundle"), + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + errs := validators.CheckOwnedCRDExistence(tc.bundle) + require.Equal(t, tc.expectedErrs, errs) + }) + } +} + +func Test_CheckPackageNameNotEmpty(t *testing.T) { + for _, tc := range []struct { + name string + bundle *render.RegistryV1 + expectedErrs []error + }{ + { + name: "accepts bundles with non-empty package name", + bundle: &render.RegistryV1{ + PackageName: "not-empty", + }, + }, { + name: "rejects bundles with empty package name", + bundle: &render.RegistryV1{}, + expectedErrs: []error{ + errors.New("package name is empty"), + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + errs := validators.CheckPackageNameNotEmpty(tc.bundle) + require.Equal(t, tc.expectedErrs, errs) + }) + } +} + +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/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/util.go b/internal/operator-controller/rukpak/util/util.go index 8bcfa194e..de96c9c0c 100644 --- a/internal/operator-controller/rukpak/util/util.go +++ b/internal/operator-controller/rukpak/util/util.go @@ -1,12 +1,39 @@ package util import ( + "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" ) +const maxNameLength = 63 + +func ObjectNameForBaseAndSuffix(base string, suffix string) string { + if len(base)+len(suffix) > maxNameLength { + base = base[:maxNameLength-len(suffix)-1] + } + return fmt.Sprintf("%s-%s", base, suffix) +} + +func ToUnstructured(obj client.Object) (*unstructured.Unstructured, error) { + gvk := obj.GetObjectKind().GroupVersionKind() + + 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 8ccb4b74e..f5048abf1 100644 --- a/internal/operator-controller/rukpak/util/util_test.go +++ b/internal/operator-controller/rukpak/util/util_test.go @@ -16,6 +16,12 @@ import ( "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/util" ) +func Test_ObjectNameForBaseAndSuffix(t *testing.T) { + name := util.ObjectNameForBaseAndSuffix("my.object.thing.has.a.really.really.really.really.really.long.name", "suffix") + require.Len(t, name, 63) + require.Equal(t, "my.object.thing.has.a.really.really.really.really.really-suffix", name) +} + func TestMergeMaps(t *testing.T) { tests := []struct { name string