Skip to content

Commit 8acd651

Browse files
author
Per G. da Silva
committed
Refactor BundleValidator to a concrete class
Signed-off-by: Per G. da Silva <pegoncal@redhat.com>
1 parent 3c2fcb4 commit 8acd651

2 files changed

Lines changed: 1760 additions & 0 deletions

File tree

Lines changed: 356 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,356 @@
1+
package validator
2+
3+
import (
4+
"cmp"
5+
"errors"
6+
"fmt"
7+
"maps"
8+
"slices"
9+
"strings"
10+
11+
"k8s.io/apimachinery/pkg/util/sets"
12+
"k8s.io/apimachinery/pkg/util/validation"
13+
14+
"github.com/operator-framework/api/pkg/operators/v1alpha1"
15+
16+
"github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/bundle"
17+
)
18+
19+
var (
20+
// forbiddenWebhookRuleAPIGroups contain the API groups that are forbidden for webhook configuration rules in OLMv1
21+
forbiddenWebhookRuleAPIGroups = sets.New("olm.operatorframework.io", "*")
22+
23+
// forbiddenAdmissionRegistrationResources contain the resources that are forbidden for webhook configuration rules
24+
// for the admissionregistration.k8s.io api group
25+
forbiddenAdmissionRegistrationResources = sets.New(
26+
"*",
27+
"mutatingwebhookconfiguration",
28+
"mutatingwebhookconfigurations",
29+
"validatingwebhookconfiguration",
30+
"validatingwebhookconfigurations",
31+
)
32+
)
33+
34+
// BundleValidator does static validation on registry+v1 bundles and their ClusterServiceVersion resource.
35+
type BundleValidator struct{}
36+
37+
func (v BundleValidator) Validate(rv1 *bundle.RegistryV1) []error {
38+
validators := []func(rv1 *bundle.RegistryV1) []error{
39+
v.CheckDeploymentSpecUniqueness,
40+
v.CheckDeploymentNameIsDNS1123SubDomain,
41+
v.CheckCRDResourceUniqueness,
42+
v.CheckOwnedCRDExistence,
43+
v.CheckPackageNameNotEmpty,
44+
v.CheckConversionWebhookSupport,
45+
v.CheckWebhookDeploymentReferentialIntegrity,
46+
v.CheckWebhookNameUniqueness,
47+
v.CheckWebhookNameIsDNS1123SubDomain,
48+
v.CheckConversionWebhookCRDReferenceUniqueness,
49+
v.CheckConversionWebhooksReferenceOwnedCRDs,
50+
v.CheckWebhookRules,
51+
}
52+
var errs []error
53+
for _, validator := range validators {
54+
errs = append(errs, validator(rv1)...)
55+
}
56+
return errs
57+
}
58+
59+
// CheckDeploymentSpecUniqueness checks that each strategy deployment spec in the csv has a unique name.
60+
// Errors are sorted by deployment name.
61+
func (v BundleValidator) CheckDeploymentSpecUniqueness(rv1 *bundle.RegistryV1) []error {
62+
deploymentNameSet := sets.Set[string]{}
63+
duplicateDeploymentNames := sets.Set[string]{}
64+
for _, dep := range rv1.CSV.Spec.InstallStrategy.StrategySpec.DeploymentSpecs {
65+
if deploymentNameSet.Has(dep.Name) {
66+
duplicateDeploymentNames.Insert(dep.Name)
67+
}
68+
deploymentNameSet.Insert(dep.Name)
69+
}
70+
71+
errs := make([]error, 0, len(duplicateDeploymentNames))
72+
for _, d := range slices.Sorted(slices.Values(duplicateDeploymentNames.UnsortedList())) {
73+
errs = append(errs, fmt.Errorf("cluster service version contains duplicate strategy deployment spec '%s'", d))
74+
}
75+
return errs
76+
}
77+
78+
// CheckDeploymentNameIsDNS1123SubDomain checks each deployment strategy spec name complies with the Kubernetes
79+
// resource naming conversions
80+
func (v BundleValidator) CheckDeploymentNameIsDNS1123SubDomain(rv1 *bundle.RegistryV1) []error {
81+
deploymentNameErrMap := map[string][]string{}
82+
for _, dep := range rv1.CSV.Spec.InstallStrategy.StrategySpec.DeploymentSpecs {
83+
errs := validation.IsDNS1123Subdomain(dep.Name)
84+
if len(errs) > 0 {
85+
slices.Sort(errs)
86+
deploymentNameErrMap[dep.Name] = errs
87+
}
88+
}
89+
90+
errs := make([]error, 0, len(deploymentNameErrMap))
91+
for _, dep := range slices.Sorted(maps.Keys(deploymentNameErrMap)) {
92+
errs = append(errs, fmt.Errorf("invalid cluster service version strategy deployment name '%s': %s", dep, strings.Join(deploymentNameErrMap[dep], ", ")))
93+
}
94+
return errs
95+
}
96+
97+
// CheckOwnedCRDExistence checks bundle owned custom resource definitions declared in the csv exist in the bundle
98+
func (v BundleValidator) CheckOwnedCRDExistence(rv1 *bundle.RegistryV1) []error {
99+
crdsNames := sets.Set[string]{}
100+
for _, crd := range rv1.CRDs {
101+
crdsNames.Insert(crd.Name)
102+
}
103+
104+
missingCRDNames := sets.Set[string]{}
105+
for _, crd := range rv1.CSV.Spec.CustomResourceDefinitions.Owned {
106+
if !crdsNames.Has(crd.Name) {
107+
missingCRDNames.Insert(crd.Name)
108+
}
109+
}
110+
111+
errs := make([]error, 0, len(missingCRDNames))
112+
for _, crdName := range slices.Sorted(slices.Values(missingCRDNames.UnsortedList())) {
113+
errs = append(errs, fmt.Errorf("cluster service definition references owned custom resource definition '%s' not found in bundle", crdName))
114+
}
115+
return errs
116+
}
117+
118+
// CheckCRDResourceUniqueness checks that the bundle CRD names are unique
119+
func (v BundleValidator) CheckCRDResourceUniqueness(rv1 *bundle.RegistryV1) []error {
120+
crdsNames := sets.Set[string]{}
121+
duplicateCRDNames := sets.Set[string]{}
122+
for _, crd := range rv1.CRDs {
123+
if crdsNames.Has(crd.Name) {
124+
duplicateCRDNames.Insert(crd.Name)
125+
}
126+
crdsNames.Insert(crd.Name)
127+
}
128+
129+
errs := make([]error, 0, len(duplicateCRDNames))
130+
for _, crdName := range slices.Sorted(slices.Values(duplicateCRDNames.UnsortedList())) {
131+
errs = append(errs, fmt.Errorf("bundle contains duplicate custom resource definition '%s'", crdName))
132+
}
133+
return errs
134+
}
135+
136+
// CheckPackageNameNotEmpty checks that PackageName is not empty
137+
func (v BundleValidator) CheckPackageNameNotEmpty(rv1 *bundle.RegistryV1) []error {
138+
if rv1.PackageName == "" {
139+
return []error{errors.New("package name is empty")}
140+
}
141+
return nil
142+
}
143+
144+
// CheckConversionWebhookSupport checks that if the bundle cluster service version declares conversion webhook definitions,
145+
// that the bundle also only supports AllNamespaces install mode. This keeps parity with OLMv0 behavior for conversion webhooks,
146+
// https://github.com/operator-framework/operator-lifecycle-manager/blob/dfd0b2bea85038d3c0d65348bc812d297f16b8d2/pkg/controller/install/webhook.go#L193
147+
func (v BundleValidator) CheckConversionWebhookSupport(rv1 *bundle.RegistryV1) []error {
148+
var conversionWebhookNames []string
149+
for _, wh := range rv1.CSV.Spec.WebhookDefinitions {
150+
if wh.Type == v1alpha1.ConversionWebhook {
151+
conversionWebhookNames = append(conversionWebhookNames, wh.GenerateName)
152+
}
153+
}
154+
155+
if len(conversionWebhookNames) > 0 {
156+
supportedInstallModes := sets.Set[v1alpha1.InstallModeType]{}
157+
for _, mode := range rv1.CSV.Spec.InstallModes {
158+
if mode.Supported {
159+
supportedInstallModes.Insert(mode.Type)
160+
}
161+
}
162+
163+
if len(supportedInstallModes) != 1 || !supportedInstallModes.Has(v1alpha1.InstallModeTypeAllNamespaces) {
164+
sortedModes := slices.Sorted(slices.Values(supportedInstallModes.UnsortedList()))
165+
errs := make([]error, len(conversionWebhookNames))
166+
for i, webhookName := range conversionWebhookNames {
167+
errs[i] = fmt.Errorf("bundle contains conversion webhook %q and supports install modes %v - conversion webhooks are only supported for bundles that only support AllNamespaces install mode", webhookName, sortedModes)
168+
}
169+
return errs
170+
}
171+
}
172+
173+
return nil
174+
}
175+
176+
// CheckWebhookDeploymentReferentialIntegrity checks that each webhook definition in the csv
177+
// references an existing strategy deployment spec. Errors are sorted by strategy deployment spec name,
178+
// webhook type, and webhook name.
179+
func (v BundleValidator) CheckWebhookDeploymentReferentialIntegrity(rv1 *bundle.RegistryV1) []error {
180+
webhooksByDeployment := map[string][]v1alpha1.WebhookDescription{}
181+
for _, wh := range rv1.CSV.Spec.WebhookDefinitions {
182+
webhooksByDeployment[wh.DeploymentName] = append(webhooksByDeployment[wh.DeploymentName], wh)
183+
}
184+
185+
for _, depl := range rv1.CSV.Spec.InstallStrategy.StrategySpec.DeploymentSpecs {
186+
delete(webhooksByDeployment, depl.Name)
187+
}
188+
189+
var errs []error
190+
// Loop through sorted keys to keep error messages ordered by deployment name
191+
for _, deploymentName := range slices.Sorted(maps.Keys(webhooksByDeployment)) {
192+
webhookDefns := webhooksByDeployment[deploymentName]
193+
slices.SortFunc(webhookDefns, func(a, b v1alpha1.WebhookDescription) int {
194+
return cmp.Or(cmp.Compare(a.Type, b.Type), cmp.Compare(a.GenerateName, b.GenerateName))
195+
})
196+
for _, webhookDef := range webhookDefns {
197+
errs = append(errs, fmt.Errorf("webhook of type '%s' with name '%s' references non-existent deployment '%s'", webhookDef.Type, webhookDef.GenerateName, webhookDef.DeploymentName))
198+
}
199+
}
200+
return errs
201+
}
202+
203+
// CheckWebhookNameUniqueness checks that each webhook definition of each type (validating, mutating, or conversion)
204+
// has a unique name. Webhooks of different types can have the same name. Errors are sorted by webhook type
205+
// and name.
206+
func (v BundleValidator) CheckWebhookNameUniqueness(rv1 *bundle.RegistryV1) []error {
207+
webhookNameSetByType := map[v1alpha1.WebhookAdmissionType]sets.Set[string]{}
208+
duplicateWebhooksByType := map[v1alpha1.WebhookAdmissionType]sets.Set[string]{}
209+
for _, wh := range rv1.CSV.Spec.WebhookDefinitions {
210+
if _, ok := webhookNameSetByType[wh.Type]; !ok {
211+
webhookNameSetByType[wh.Type] = sets.Set[string]{}
212+
}
213+
if webhookNameSetByType[wh.Type].Has(wh.GenerateName) {
214+
if _, ok := duplicateWebhooksByType[wh.Type]; !ok {
215+
duplicateWebhooksByType[wh.Type] = sets.Set[string]{}
216+
}
217+
duplicateWebhooksByType[wh.Type].Insert(wh.GenerateName)
218+
}
219+
webhookNameSetByType[wh.Type].Insert(wh.GenerateName)
220+
}
221+
222+
var errs []error
223+
for _, whType := range slices.Sorted(maps.Keys(duplicateWebhooksByType)) {
224+
for _, webhookName := range slices.Sorted(slices.Values(duplicateWebhooksByType[whType].UnsortedList())) {
225+
errs = append(errs, fmt.Errorf("duplicate webhook '%s' of type '%s'", webhookName, whType))
226+
}
227+
}
228+
return errs
229+
}
230+
231+
// CheckConversionWebhooksReferenceOwnedCRDs checks defined conversion webhooks reference bundle owned CRDs.
232+
// Errors are sorted by webhook name and CRD name.
233+
func (v BundleValidator) CheckConversionWebhooksReferenceOwnedCRDs(rv1 *bundle.RegistryV1) []error {
234+
//nolint:prealloc
235+
var conversionWebhooks []v1alpha1.WebhookDescription
236+
for _, wh := range rv1.CSV.Spec.WebhookDefinitions {
237+
if wh.Type != v1alpha1.ConversionWebhook {
238+
continue
239+
}
240+
conversionWebhooks = append(conversionWebhooks, wh)
241+
}
242+
243+
if len(conversionWebhooks) == 0 {
244+
return nil
245+
}
246+
247+
ownedCRDNames := sets.Set[string]{}
248+
for _, crd := range rv1.CSV.Spec.CustomResourceDefinitions.Owned {
249+
ownedCRDNames.Insert(crd.Name)
250+
}
251+
252+
slices.SortFunc(conversionWebhooks, func(a, b v1alpha1.WebhookDescription) int {
253+
return cmp.Compare(a.GenerateName, b.GenerateName)
254+
})
255+
256+
var errs []error
257+
for _, webhook := range conversionWebhooks {
258+
webhookCRDs := webhook.ConversionCRDs
259+
slices.Sort(webhookCRDs)
260+
for _, crd := range webhookCRDs {
261+
if !ownedCRDNames.Has(crd) {
262+
errs = append(errs, fmt.Errorf("conversion webhook '%s' references custom resource definition '%s' not owned bundle", webhook.GenerateName, crd))
263+
}
264+
}
265+
}
266+
return errs
267+
}
268+
269+
// CheckConversionWebhookCRDReferenceUniqueness checks no two (or more) conversion webhooks reference the same CRD.
270+
func (v BundleValidator) CheckConversionWebhookCRDReferenceUniqueness(rv1 *bundle.RegistryV1) []error {
271+
// collect webhooks by crd
272+
crdToWh := map[string][]string{}
273+
for _, wh := range rv1.CSV.Spec.WebhookDefinitions {
274+
if wh.Type != v1alpha1.ConversionWebhook {
275+
continue
276+
}
277+
for _, crd := range wh.ConversionCRDs {
278+
crdToWh[crd] = append(crdToWh[crd], wh.GenerateName)
279+
}
280+
}
281+
282+
// remove crds with single webhook
283+
maps.DeleteFunc(crdToWh, func(crd string, whs []string) bool {
284+
return len(whs) == 1
285+
})
286+
287+
errs := make([]error, 0, len(crdToWh))
288+
orderedCRDs := slices.Sorted(maps.Keys(crdToWh))
289+
for _, crd := range orderedCRDs {
290+
orderedWhs := strings.Join(slices.Sorted(slices.Values(crdToWh[crd])), ",")
291+
errs = append(errs, fmt.Errorf("conversion webhooks [%s] reference same custom resource definition '%s'", orderedWhs, crd))
292+
}
293+
return errs
294+
}
295+
296+
// CheckWebhookNameIsDNS1123SubDomain checks each webhook configuration name complies with the Kubernetes resource naming conversions
297+
func (v BundleValidator) CheckWebhookNameIsDNS1123SubDomain(rv1 *bundle.RegistryV1) []error {
298+
invalidWebhooksByType := map[v1alpha1.WebhookAdmissionType]map[string][]string{}
299+
for _, wh := range rv1.CSV.Spec.WebhookDefinitions {
300+
if _, ok := invalidWebhooksByType[wh.Type]; !ok {
301+
invalidWebhooksByType[wh.Type] = map[string][]string{}
302+
}
303+
errs := validation.IsDNS1123Subdomain(wh.GenerateName)
304+
if len(errs) > 0 {
305+
slices.Sort(errs)
306+
invalidWebhooksByType[wh.Type][wh.GenerateName] = errs
307+
}
308+
}
309+
310+
var errs []error
311+
for _, whType := range slices.Sorted(maps.Keys(invalidWebhooksByType)) {
312+
for _, webhookName := range slices.Sorted(maps.Keys(invalidWebhooksByType[whType])) {
313+
errs = append(errs, fmt.Errorf("webhook of type '%s' has invalid name '%s': %s", whType, webhookName, strings.Join(invalidWebhooksByType[whType][webhookName], ",")))
314+
}
315+
}
316+
return errs
317+
}
318+
319+
// CheckWebhookRules ensures webhook rules do not reference forbidden API groups or resources in line with OLMv0 behavior
320+
// The following are forbidden, rules targeting:
321+
// - all API groups (i.e. '*')
322+
// - OLMv1 API group (i.e. 'olm.operatorframework.io')
323+
// - all resources under the 'admissionregistration.k8s.io' API group
324+
// - the 'ValidatingWebhookConfiguration' resource under the 'admissionregistration.k8s.io' API group
325+
// - the 'MutatingWebhookConfiguration' resource under the 'admissionregistration.k8s.io' API group
326+
//
327+
// These boundaries attempt to reduce the blast radius of faulty webhooks and avoid deadlocks preventing the user
328+
// from deleting OLMv1 resources installing and managing the faulty webhook, or deleting faulty admission webhook
329+
// configurations.
330+
// See https://github.com/operator-framework/operator-lifecycle-manager/blob/ccf0c4c91f1e7673e87f3a18947f9a1f88d48438/pkg/controller/install/webhook.go#L19
331+
// for more details
332+
func (v BundleValidator) CheckWebhookRules(rv1 *bundle.RegistryV1) []error {
333+
var errs []error
334+
for _, wh := range rv1.CSV.Spec.WebhookDefinitions {
335+
// Rules are not used for conversion webhooks
336+
if wh.Type == v1alpha1.ConversionWebhook {
337+
continue
338+
}
339+
webhookName := wh.GenerateName
340+
for _, rule := range wh.Rules {
341+
for _, apiGroup := range rule.APIGroups {
342+
if forbiddenWebhookRuleAPIGroups.Has(apiGroup) {
343+
errs = append(errs, fmt.Errorf("webhook %q contains forbidden rule: admission webhook rules cannot reference API group %q", webhookName, apiGroup))
344+
}
345+
if apiGroup == "admissionregistration.k8s.io" {
346+
for _, resource := range rule.Resources {
347+
if forbiddenAdmissionRegistrationResources.Has(strings.ToLower(resource)) {
348+
errs = append(errs, fmt.Errorf("webhook %q contains forbidden rule: admission webhook rules cannot reference resource %q for API group %q", webhookName, resource, apiGroup))
349+
}
350+
}
351+
}
352+
}
353+
}
354+
}
355+
return errs
356+
}

0 commit comments

Comments
 (0)