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 +}