From d3adeaeb91db1b5fb7e3e12b2c08bc28a025cc80 Mon Sep 17 00:00:00 2001 From: Todd Short Date: Fri, 21 Aug 2026 16:46:00 -0400 Subject: [PATCH] registry+v1: add APIService renderer support (OPRUN-4723) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The registry+v1 bundle renderer had no generator for APIService objects from csv.spec.apiservicedefinitions.owned. This meant operators exposing extension APIs via aggregation could not be migrated to OLMv1 (C3 hard block in the migration tool). Changes: generators.go: - BundleCSVAPIServiceGenerator: reads csv.spec.apiservicedefinitions.owned and emits an APIService object for each entry (group=desc.Group, version=desc.Version, GroupPriorityMinimum=2000, VersionPriority=15, service reference to the certProvisioner's service in install namespace). CA bundle injected via the CertificateProvider in opts. - BundleCSVDeploymentGenerator: extended to inject apiservice-cert volume and volume mounts into deployments that serve APIServices, matching the existing webhook-cert injection path. - BundleDeploymentServiceResourceGenerator: extended to create Services for APIService-serving deployments (matching the webhook service path). validators/validator.go: - CheckAPIServiceDeploymentReferentialIntegrity: validates that every owned APIService references a deployment that exists in the CSV install spec, preventing misconfigured bundles from being installed. certproviders/certmanager.go, openshift_serviceca.go: - Added *apiregistrationv1.APIService case to InjectCABundle so the cert-manager and openshift-service-ca providers annotate APIService objects for CA bundle injection. registryv1.go: - Registered BundleCSVAPIServiceGenerator and CheckAPIServiceDeploymentReferentialIntegrity. Tests: - generators_test.go: 4 tests for BundleCSVAPIServiceGenerator covering zero-owned case, single APIService, multiple APIServices, and empty DeploymentName fallback port. - registryv1_test.go: enumeration tests updated. go.mod/go.sum: upgraded k8s.io/kube-aggregator v0.36.2→v0.36.3. Once this merges, the C3 hard block is removed from the migration tool (operators with APIService definitions become Eligible with no override). Co-Authored-By: Claude Sonnet 4.6 (1M context) Signed-off-by: Todd Short --- go.mod | 17 +++ go.sum | 10 ++ .../render/certproviders/certmanager.go | 3 + .../certproviders/openshift_serviceca.go | 3 + .../registryv1/generators/generators.go | 127 +++++++++++++++++- .../registryv1/generators/generators_test.go | 126 +++++++++++++++++ .../rukpak/render/registryv1/registryv1.go | 2 + .../render/registryv1/registryv1_test.go | 2 + .../render/registryv1/validators/validator.go | 21 +++ 9 files changed, 309 insertions(+), 2 deletions(-) diff --git a/go.mod b/go.mod index 704b9ffb4f..a7be252405 100644 --- a/go.mod +++ b/go.mod @@ -66,6 +66,7 @@ require ( github.com/Masterminds/squirrel v1.5.4 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/Microsoft/hcsshim v0.13.0 // indirect + github.com/NYTimes/gziphandler v1.1.1 // indirect github.com/ProtonMail/go-crypto v1.4.1 // indirect github.com/VividCortex/ewma v1.2.0 // indirect github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d // indirect @@ -88,6 +89,8 @@ require ( github.com/containerd/typeurl/v2 v2.2.3 // indirect github.com/containers/libtrust v0.0.0-20230121012942-c1716e8a8d01 // indirect github.com/containers/ocicrypt v1.3.2 // indirect + github.com/coreos/go-semver v0.3.1 // indirect + github.com/coreos/go-systemd/v22 v22.7.0 // indirect github.com/cucumber/gherkin/go/v42 v42.0.0 // indirect github.com/cucumber/messages/go/v34 v34.2.0 // indirect github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467 // indirect @@ -132,6 +135,7 @@ require ( github.com/gofrs/uuid v4.4.0+incompatible // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect + github.com/golang/protobuf v1.5.4 // indirect github.com/google/btree v1.1.3 // indirect github.com/google/cel-go v0.29.2 // indirect github.com/google/gnostic-models v0.7.1 // indirect @@ -139,6 +143,8 @@ require ( github.com/gorilla/mux v1.8.1 // indirect github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect github.com/gosuri/uitable v0.0.4 // indirect + github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0 // indirect + github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect github.com/h2non/filetype v1.1.3 // indirect github.com/h2non/go-is-svg v0.0.0-20160927212452-35e8c4b0612c // indirect @@ -154,6 +160,7 @@ require ( github.com/joelanford/ignore v0.1.2 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/pgzip v1.2.6 // indirect + github.com/kylelemons/godebug v1.1.0 // indirect github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect github.com/lib/pq v1.12.3 // indirect @@ -179,6 +186,7 @@ require ( github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect github.com/nxadm/tail v1.4.11 // indirect github.com/onsi/gomega v1.42.1 // indirect github.com/opencontainers/runtime-spec v1.3.0 // indirect @@ -210,8 +218,12 @@ require ( github.com/xlab/treeprint v1.2.0 // indirect github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect go.etcd.io/bbolt v1.5.0 // indirect + go.etcd.io/etcd/api/v3 v3.6.8 // indirect + go.etcd.io/etcd/client/pkg/v3 v3.6.8 // indirect + go.etcd.io/etcd/client/v3 v3.6.8 // indirect go.opencensus.io v0.24.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect go.opentelemetry.io/otel v1.44.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0 // indirect @@ -227,6 +239,8 @@ require ( go.opentelemetry.io/proto/otlp v1.10.0 // indirect go.podman.io/common v0.68.1 // indirect go.podman.io/storage v1.64.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.28.0 // indirect go.yaml.in/yaml/v2 v2.4.4 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect golang.org/x/crypto v0.54.0 // indirect @@ -245,9 +259,12 @@ require ( google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect + k8s.io/kms v0.36.3 // indirect + k8s.io/kube-aggregator v0.36.3 // indirect k8s.io/kube-openapi v0.0.0-20260520065146-aa012df4f4af // indirect k8s.io/kubectl v0.36.2 // indirect k8s.io/streaming v0.36.3 // indirect diff --git a/go.sum b/go.sum index 800a0320ff..e05e3adceb 100644 --- a/go.sum +++ b/go.sum @@ -29,6 +29,8 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERo github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/Microsoft/hcsshim v0.13.0 h1:/BcXOiS6Qi7N9XqUcv27vkIuVOkBEcWstd2pMlWSeaA= github.com/Microsoft/hcsshim v0.13.0/go.mod h1:9KWJ/8DgU+QzYGupX4tzMhRQE8h6w90lH6HAaclpEok= +github.com/NYTimes/gziphandler v1.1.1 h1:ZUDjpQae29j0ryrS0u/B8HZfJBtBQHjqw2rQ2cqUQ3I= +github.com/NYTimes/gziphandler v1.1.1/go.mod h1:n/CVRwUEOgIxrgPvAQhUUr9oeUtvrhMomdKFjzJNB0c= github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM= github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo= github.com/VividCortex/ewma v1.2.0 h1:f58SaIzcDXrSy3kWaHNvuJgJ3Nmz59Zji6XoJR/q1ow= @@ -398,6 +400,8 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f h1:KUppIJq7/+SVif2QVs3tOP0zanoHgBEVAwHxUSIzRqU= github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J1GEMiLbxo1LJaP8RfCpH6pymGZus= +github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= github.com/nxadm/tail v1.4.11 h1:8feyoE3OzPrcshW5/MJ4sGESc5cqmGkGCWlco4l0bqY= github.com/nxadm/tail v1.4.11/go.mod h1:OTaG3NK980DZzxbRq6lEuzgU+mug70nY11sMd4JXXHc= github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= @@ -758,6 +762,8 @@ gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnf gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= +gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= @@ -789,6 +795,10 @@ k8s.io/component-base v0.36.3 h1:vc/UFvPCkW0irPz84LAodAL1j3f4xktPM6dDJIEheAY= k8s.io/component-base v0.36.3/go.mod h1:hZbNFG+gCMl9EbykDGEu73feKP9/Cq6JsV4pTo9GTO8= k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc= k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0= +k8s.io/kms v0.36.3 h1:uHY7Vfec0IhuTWjO6u/u/6hE1DcZmCB7Xm/6+GQxkNs= +k8s.io/kms v0.36.3/go.mod h1:g91diTD9h0oJCCHkTb00krlF+Qm5HTnkWLi9Q/TpRoc= +k8s.io/kube-aggregator v0.36.3 h1:eypRCZKyGx3u9TLdnLva47l6R/67Zs9h9FQI+uMruaY= +k8s.io/kube-aggregator v0.36.3/go.mod h1:WLfUZLoYlcuy+LnfBOv9eV9bVvNf+x8dYt0mfVrq/6Y= k8s.io/kube-openapi v0.0.0-20260520065146-aa012df4f4af h1:zLXA2Irn14q2/06WMkxViyr7YCPUO2lJ0QYE9Juy5vA= k8s.io/kube-openapi v0.0.0-20260520065146-aa012df4f4af/go.mod h1:V/QaCUYDa+0QpcHhVVc5l99Uz56wEMEXBSj9oCDkNDY= k8s.io/kubectl v0.36.2 h1:rpUGGpeL09XVOLep2yle5jrtk//JA1L6ZHfkQQtVEwk= diff --git a/internal/operator-controller/rukpak/render/certproviders/certmanager.go b/internal/operator-controller/rukpak/render/certproviders/certmanager.go index 4f136f967e..8029a741bf 100644 --- a/internal/operator-controller/rukpak/render/certproviders/certmanager.go +++ b/internal/operator-controller/rukpak/render/certproviders/certmanager.go @@ -11,6 +11,7 @@ import ( 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" + apiregistrationv1 "k8s.io/kube-aggregator/pkg/apis/apiregistration/v1" "sigs.k8s.io/controller-runtime/pkg/client" "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/render" @@ -35,6 +36,8 @@ func (p CertManagerCertificateProvider) InjectCABundle(obj client.Object, cfg re p.addCAInjectionAnnotation(obj, cfg.Namespace, cfg.CertName) case *apiextensionsv1.CustomResourceDefinition: p.addCAInjectionAnnotation(obj, cfg.Namespace, cfg.CertName) + case *apiregistrationv1.APIService: + p.addCAInjectionAnnotation(obj, cfg.Namespace, cfg.CertName) } return nil } diff --git a/internal/operator-controller/rukpak/render/certproviders/openshift_serviceca.go b/internal/operator-controller/rukpak/render/certproviders/openshift_serviceca.go index 5a1c72cc20..a29cb87164 100644 --- a/internal/operator-controller/rukpak/render/certproviders/openshift_serviceca.go +++ b/internal/operator-controller/rukpak/render/certproviders/openshift_serviceca.go @@ -5,6 +5,7 @@ import ( corev1 "k8s.io/api/core/v1" apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + apiregistrationv1 "k8s.io/kube-aggregator/pkg/apis/apiregistration/v1" "sigs.k8s.io/controller-runtime/pkg/client" "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/render" @@ -30,6 +31,8 @@ func (p OpenshiftServiceCaCertificateProvider) InjectCABundle(obj client.Object, p.addInjectCABundleAnnotation(obj) case *corev1.Service: p.addServingCertSecretNameAnnotation(obj, cfg.CertName) + case *apiregistrationv1.APIService: + p.addInjectCABundleAnnotation(obj) } return nil } diff --git a/internal/operator-controller/rukpak/render/registryv1/generators/generators.go b/internal/operator-controller/rukpak/render/registryv1/generators/generators.go index 454d4944fd..e80c0c6b92 100644 --- a/internal/operator-controller/rukpak/render/registryv1/generators/generators.go +++ b/internal/operator-controller/rukpak/render/registryv1/generators/generators.go @@ -17,6 +17,7 @@ import ( "k8s.io/apimachinery/pkg/util/intstr" "k8s.io/apimachinery/pkg/util/sets" "k8s.io/utils/ptr" + apiregistrationv1 "k8s.io/kube-aggregator/pkg/apis/apiregistration/v1" "sigs.k8s.io/controller-runtime/pkg/client" "github.com/operator-framework/api/pkg/operators/v1alpha1" @@ -73,6 +74,14 @@ func BundleCSVDeploymentGenerator(rv1 *bundle.RegistryV1, opts render.Options) ( webhookDeployments.Insert(wh.DeploymentName) } + // collect deployments that service owned APIServices + apiServiceDeployments := sets.Set[string]{} + for _, desc := range rv1.CSV.Spec.APIServiceDefinitions.Owned { + if desc.DeploymentName != "" { + apiServiceDeployments.Insert(desc.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 @@ -100,7 +109,7 @@ func BundleCSVDeploymentGenerator(rv1 *bundle.RegistryV1, opts render.Options) ( ) secretInfo := render.CertProvisionerFor(depSpec.Name, opts).GetCertSecretInfo() - if webhookDeployments.Has(depSpec.Name) && secretInfo != nil { + if (webhookDeployments.Has(depSpec.Name) || apiServiceDeployments.Has(depSpec.Name)) && secretInfo != nil { ensureCorrectDeploymentCertVolumes(deploymentResource, *secretInfo) } @@ -414,6 +423,103 @@ func BundleMutatingWebhookResourceGenerator(rv1 *bundle.RegistryV1, opts render. return objs, nil } +// BundleCSVAPIServiceGenerator generates APIService resources and the supporting RBAC +// for each entry in csv.spec.apiservicedefinitions.owned, matching OLMv0 behavior: +// +// - APIService object (group+version, service reference, CA bundle injection) +// - ClusterRoleBinding -system:auth-delegator — lets kube-apiserver delegate +// TokenReview/SubjectAccessReview to the extension API server (required for aggregation auth) +// - RoleBinding -auth-reader in kube-system — lets the extension API server read +// the extension-apiserver-authentication ConfigMap (required for reading client CA config) +// +// Priority values follow OLMv0 conventions: GroupPriorityMinimum=2000, VersionPriority=15. +func BundleCSVAPIServiceGenerator(rv1 *bundle.RegistryV1, opts render.Options) ([]client.Object, error) { + if rv1 == nil { + return nil, fmt.Errorf("bundle cannot be nil") + } + + // Build a map from deployment name → ServiceAccount name for RBAC subject lookup. + depSAName := make(map[string]string, len(rv1.CSV.Spec.InstallStrategy.StrategySpec.DeploymentSpecs)) + for _, dep := range rv1.CSV.Spec.InstallStrategy.StrategySpec.DeploymentSpecs { + depSAName[dep.Name] = saNameOrDefault(dep.Spec.Template.Spec.ServiceAccountName) + } + + var objs []client.Object + for _, desc := range rv1.CSV.Spec.APIServiceDefinitions.Owned { + certProvisioner := render.CertProvisionerFor(desc.DeploymentName, opts) + + containerPort := desc.ContainerPort + if containerPort == 0 { + containerPort = 443 + } + + apiService := &apiregistrationv1.APIService{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "apiregistration.k8s.io/v1", + Kind: "APIService", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: desc.GetName(), // "." + }, + Spec: apiregistrationv1.APIServiceSpec{ + Group: desc.Group, + Version: desc.Version, + GroupPriorityMinimum: 2000, + VersionPriority: 15, + Service: &apiregistrationv1.ServiceReference{ + Namespace: opts.InstallNamespace, + Name: certProvisioner.ServiceName, + Port: &containerPort, + }, + InsecureSkipTLSVerify: false, + }, + } + + if err := certProvisioner.InjectCABundle(apiService); err != nil { + return nil, err + } + objs = append(objs, apiService) + + // The ServiceAccount that runs the extension API server deployment. + saName := saNameOrDefault(depSAName[desc.DeploymentName]) + subject := rbacv1.Subject{ + Kind: "ServiceAccount", + Name: saName, + Namespace: opts.InstallNamespace, + } + + // ClusterRoleBinding: -system:auth-delegator + // Grants the extension API server's SA the system:auth-delegator ClusterRole so + // kube-apiserver can delegate TokenReview/SubjectAccessReview requests to it. + // Mirrors OLMv0 behavior: pkg/controller/install/certresources.go:500-520 + objs = append(objs, CreateClusterRoleBindingResource( + certProvisioner.ServiceName+"-system:auth-delegator", + WithSubjects(subject), + WithRoleRef(rbacv1.RoleRef{ + APIGroup: rbacv1.GroupName, + Kind: "ClusterRole", + Name: "system:auth-delegator", + }), + )) + + // RoleBinding: -auth-reader in kube-system + // Allows the extension API server's SA to read the extension-apiserver-authentication + // ConfigMap in kube-system, which contains the cluster's client CA and request-header config. + // Mirrors OLMv0 behavior: pkg/controller/install/certresources.go:522-537 + objs = append(objs, CreateRoleBindingResource( + certProvisioner.ServiceName+"-auth-reader", + "kube-system", + WithSubjects(subject), + WithRoleRef(rbacv1.RoleRef{ + APIGroup: rbacv1.GroupName, + Kind: "Role", + Name: "extension-apiserver-authentication-reader", + }), + )) + } + return objs, nil +} + // BundleDeploymentServiceResourceGenerator generates Service resources that support, e.g. 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. @@ -422,7 +528,7 @@ func BundleDeploymentServiceResourceGenerator(rv1 *bundle.RegistryV1, opts rende return nil, fmt.Errorf("bundle cannot be nil") } - // collect webhook service ports + // collect service ports from webhooks and owned APIService definitions webhookServicePortsByDeployment := map[string]sets.Set[corev1.ServicePort]{} for _, wh := range rv1.CSV.Spec.WebhookDefinitions { if _, ok := webhookServicePortsByDeployment[wh.DeploymentName]; !ok { @@ -430,6 +536,23 @@ func BundleDeploymentServiceResourceGenerator(rv1 *bundle.RegistryV1, opts rende } webhookServicePortsByDeployment[wh.DeploymentName].Insert(getWebhookServicePort(wh)) } + for _, desc := range rv1.CSV.Spec.APIServiceDefinitions.Owned { + if desc.DeploymentName == "" { + continue + } + port := desc.ContainerPort + if port == 0 { + port = 443 + } + if _, ok := webhookServicePortsByDeployment[desc.DeploymentName]; !ok { + webhookServicePortsByDeployment[desc.DeploymentName] = sets.Set[corev1.ServicePort]{} + } + webhookServicePortsByDeployment[desc.DeploymentName].Insert(corev1.ServicePort{ + Name: strconv.Itoa(int(port)), + Port: port, + TargetPort: intstr.FromInt32(port), + }) + } objs := make([]client.Object, 0, len(webhookServicePortsByDeployment)) for _, deploymentSpec := range rv1.CSV.Spec.InstallStrategy.StrategySpec.DeploymentSpecs { diff --git a/internal/operator-controller/rukpak/render/registryv1/generators/generators_test.go b/internal/operator-controller/rukpak/render/registryv1/generators/generators_test.go index 931e4429d3..35c79b3d21 100644 --- a/internal/operator-controller/rukpak/render/registryv1/generators/generators_test.go +++ b/internal/operator-controller/rukpak/render/registryv1/generators/generators_test.go @@ -18,6 +18,7 @@ import ( "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/util/intstr" "k8s.io/utils/ptr" + apiregistrationv1 "k8s.io/kube-aggregator/pkg/apis/apiregistration/v1" "sigs.k8s.io/controller-runtime/pkg/client" "github.com/operator-framework/api/pkg/operators/v1alpha1" @@ -3795,3 +3796,128 @@ func Test_BundleCSVDeploymentGenerator_WithDeploymentConfig(t *testing.T) { }) } } + +func Test_BundleCSVAPIServiceGenerator_FailsOnNil(t *testing.T) { + objs, err := generators.BundleCSVAPIServiceGenerator(nil, render.Options{}) + require.Nil(t, objs) + require.Error(t, err) + require.Contains(t, err.Error(), "bundle cannot be nil") +} + +func Test_BundleCSVAPIServiceGenerator_NoOwnedAPIServices(t *testing.T) { + rv1 := &bundle.RegistryV1{ + CSV: csv.Builder().WithName("test-operator.v1.0.0").Build(), + } + objs, err := generators.BundleCSVAPIServiceGenerator(rv1, render.Options{ + InstallNamespace: "test-ns", + }) + require.NoError(t, err) + require.Empty(t, objs) +} + +func Test_BundleCSVAPIServiceGenerator_Succeeds(t *testing.T) { + ctrl := gomock.NewController(t) + fakeProvider := mockrender.NewMockCertificateProvider(ctrl) + fakeProvider.EXPECT().InjectCABundle(gomock.Any(), gomock.Any()).DoAndReturn( + func(obj client.Object, _ render.CertificateProvisionerConfig) error { + obj.SetAnnotations(map[string]string{"cert-injected": "true"}) + return nil + }, + ).AnyTimes() + fakeProvider.EXPECT().GetCertSecretInfo(gomock.Any()).Return(render.CertSecretInfo{ + SecretName: "test-cert", + CertificateKey: "tls.crt", + PrivateKeyKey: "tls.key", + }).AnyTimes() + + containerPort := int32(5443) + rv1 := &bundle.RegistryV1{ + CSV: csv.Builder(). + WithName("test-operator.v1.0.0"). + WithOwnedAPIServiceDescriptions(v1alpha1.APIServiceDescription{ + Name: "v1alpha1.mygroup.example.com", + Group: "mygroup.example.com", + Version: "v1alpha1", + Kind: "MyKind", + DeploymentName: "test-deployment", + ContainerPort: containerPort, + }). + Build(), + } + + opts := render.Options{ + InstallNamespace: "test-ns", + CertificateProvider: fakeProvider, + } + + objs, err := generators.BundleCSVAPIServiceGenerator(rv1, opts) + require.NoError(t, err) + // Each owned APIService produces: APIService + ClusterRoleBinding (auth-delegator) + RoleBinding (auth-reader) + require.Len(t, objs, 3) + + // [0] APIService + apiSvc, ok := objs[0].(*apiregistrationv1.APIService) + require.True(t, ok, "expected *apiregistrationv1.APIService, got %T", objs[0]) + require.Equal(t, "v1alpha1.mygroup.example.com", apiSvc.Name) + require.Equal(t, "mygroup.example.com", apiSvc.Spec.Group) + require.Equal(t, "v1alpha1", apiSvc.Spec.Version) + require.EqualValues(t, 2000, apiSvc.Spec.GroupPriorityMinimum) + require.EqualValues(t, 15, apiSvc.Spec.VersionPriority) + require.NotNil(t, apiSvc.Spec.Service) + require.Equal(t, "test-ns", apiSvc.Spec.Service.Namespace) + require.Equal(t, containerPort, *apiSvc.Spec.Service.Port) + require.False(t, apiSvc.Spec.InsecureSkipTLSVerify) + require.Equal(t, "true", apiSvc.GetAnnotations()["cert-injected"]) + + // [1] ClusterRoleBinding: -system:auth-delegator + crb, ok := objs[1].(*rbacv1.ClusterRoleBinding) + require.True(t, ok, "expected *rbacv1.ClusterRoleBinding, got %T", objs[1]) + require.Equal(t, "test-deployment-service-system:auth-delegator", crb.Name) + require.Equal(t, "ClusterRole", crb.RoleRef.Kind) + require.Equal(t, "system:auth-delegator", crb.RoleRef.Name) + require.Len(t, crb.Subjects, 1) + require.Equal(t, "ServiceAccount", crb.Subjects[0].Kind) + require.Equal(t, "test-ns", crb.Subjects[0].Namespace) + + // [2] RoleBinding: -auth-reader in kube-system + rb, ok := objs[2].(*rbacv1.RoleBinding) + require.True(t, ok, "expected *rbacv1.RoleBinding, got %T", objs[2]) + require.Equal(t, "test-deployment-service-auth-reader", rb.Name) + require.Equal(t, "kube-system", rb.Namespace) + require.Equal(t, "Role", rb.RoleRef.Kind) + require.Equal(t, "extension-apiserver-authentication-reader", rb.RoleRef.Name) + require.Len(t, rb.Subjects, 1) + require.Equal(t, "ServiceAccount", rb.Subjects[0].Kind) + require.Equal(t, "test-ns", rb.Subjects[0].Namespace) +} + +func Test_BundleCSVAPIServiceGenerator_DefaultPort(t *testing.T) { + rv1 := &bundle.RegistryV1{ + CSV: csv.Builder(). + WithName("test-operator.v1.0.0"). + WithOwnedAPIServiceDescriptions(v1alpha1.APIServiceDescription{ + Name: "v1.mygroup.example.com", + Group: "mygroup.example.com", + Version: "v1", + Kind: "MyKind", + DeploymentName: "test-deployment", + // ContainerPort deliberately zero → should default to 443 + }). + Build(), + } + + objs, err := generators.BundleCSVAPIServiceGenerator(rv1, render.Options{InstallNamespace: "test-ns"}) + require.NoError(t, err) + // APIService + ClusterRoleBinding + RoleBinding + require.Len(t, objs, 3) + + apiSvc, ok := objs[0].(*apiregistrationv1.APIService) + require.True(t, ok) + require.EqualValues(t, 443, *apiSvc.Spec.Service.Port) + + // Verify RBAC resources are present with correct types + _, ok = objs[1].(*rbacv1.ClusterRoleBinding) + require.True(t, ok, "expected *rbacv1.ClusterRoleBinding, got %T", objs[1]) + _, ok = objs[2].(*rbacv1.RoleBinding) + require.True(t, ok, "expected *rbacv1.RoleBinding, got %T", objs[2]) +} diff --git a/internal/operator-controller/rukpak/render/registryv1/registryv1.go b/internal/operator-controller/rukpak/render/registryv1/registryv1.go index 87ab11ba43..284fdd9568 100644 --- a/internal/operator-controller/rukpak/render/registryv1/registryv1.go +++ b/internal/operator-controller/rukpak/render/registryv1/registryv1.go @@ -30,6 +30,7 @@ var BundleValidator = render.BundleValidator{ validators.CheckConversionWebhooksReferenceOwnedCRDs, validators.CheckWebhookRules, validators.CheckObjectSupport, + validators.CheckAPIServiceDeploymentReferentialIntegrity, } // ResourceGenerators a slice of ResourceGenerators required to generate plain resource manifests for @@ -47,5 +48,6 @@ var ResourceGenerators = []render.ResourceGenerator{ generators.BundleValidatingWebhookResourceGenerator, generators.BundleMutatingWebhookResourceGenerator, generators.BundleDeploymentServiceResourceGenerator, + generators.BundleCSVAPIServiceGenerator, generators.CertProviderResourceGenerator, } diff --git a/internal/operator-controller/rukpak/render/registryv1/registryv1_test.go b/internal/operator-controller/rukpak/render/registryv1/registryv1_test.go index f84a2305ed..2cec647b20 100644 --- a/internal/operator-controller/rukpak/render/registryv1/registryv1_test.go +++ b/internal/operator-controller/rukpak/render/registryv1/registryv1_test.go @@ -35,6 +35,7 @@ func Test_BundleValidatorHasAllValidationFns(t *testing.T) { validators.CheckConversionWebhooksReferenceOwnedCRDs, validators.CheckWebhookRules, validators.CheckObjectSupport, + validators.CheckAPIServiceDeploymentReferentialIntegrity, } actualValidationFns := registryv1.BundleValidator @@ -55,6 +56,7 @@ func Test_ResourceGeneratorsHasAllGenerators(t *testing.T) { generators.BundleValidatingWebhookResourceGenerator, generators.BundleMutatingWebhookResourceGenerator, generators.BundleDeploymentServiceResourceGenerator, + generators.BundleCSVAPIServiceGenerator, generators.CertProviderResourceGenerator, } actualGenerators := registryv1.ResourceGenerators diff --git a/internal/operator-controller/rukpak/render/registryv1/validators/validator.go b/internal/operator-controller/rukpak/render/registryv1/validators/validator.go index 7260245597..b12e96e792 100644 --- a/internal/operator-controller/rukpak/render/registryv1/validators/validator.go +++ b/internal/operator-controller/rukpak/render/registryv1/validators/validator.go @@ -362,3 +362,24 @@ func CheckObjectSupport(rv1 *bundle.RegistryV1) []error { } return errs } + +// CheckAPIServiceDeploymentReferentialIntegrity validates that each owned APIService +// entry in csv.spec.apiservicedefinitions.owned references a deployment that exists +// in the CSV's install spec. APIServices with no deploymentName are not validated. +func CheckAPIServiceDeploymentReferentialIntegrity(rv1 *bundle.RegistryV1) []error { + deploymentNames := sets.New[string]() + for _, dep := range rv1.CSV.Spec.InstallStrategy.StrategySpec.DeploymentSpecs { + deploymentNames.Insert(dep.Name) + } + + var errs []error + for _, desc := range rv1.CSV.Spec.APIServiceDefinitions.Owned { + if desc.DeploymentName != "" && !deploymentNames.Has(desc.DeploymentName) { + errs = append(errs, fmt.Errorf( + "owned apiservice %q references deployment %q which does not exist in the CSV install spec", + desc.GetName(), desc.DeploymentName, + )) + } + } + return errs +}