Skip to content

Commit 242f63f

Browse files
committed
Correct CRD validation to validate CRs against each version of the updated CRD
Signed-off-by: Daniel Franz <[email protected]>
1 parent 1ca6d9e commit 242f63f

19 files changed

+690
-64
lines changed

pkg/controller/operators/catalog/operator.go

+78-52
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import (
3232
"k8s.io/apimachinery/pkg/selection"
3333
utilerrors "k8s.io/apimachinery/pkg/util/errors"
3434
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
35+
"k8s.io/apimachinery/pkg/util/sets"
3536
"k8s.io/apimachinery/pkg/util/validation/field"
3637
"k8s.io/apimachinery/pkg/util/yaml"
3738
batchv1applyconfigurations "k8s.io/client-go/applyconfigurations/batch/v1"
@@ -2077,81 +2078,106 @@ func transitionInstallPlanState(log logrus.FieldLogger, transitioner installPlan
20772078
func validateV1CRDCompatibility(dynamicClient dynamic.Interface, oldCRD *apiextensionsv1.CustomResourceDefinition, newCRD *apiextensionsv1.CustomResourceDefinition) error {
20782079
logrus.Debugf("Comparing %#v to %#v", oldCRD.Spec.Versions, newCRD.Spec.Versions)
20792080

2080-
// If validation schema is unchanged, return right away
2081-
newestSchema := newCRD.Spec.Versions[len(newCRD.Spec.Versions)-1].Schema
2082-
for i, oldVersion := range oldCRD.Spec.Versions {
2083-
if !reflect.DeepEqual(oldVersion.Schema, newestSchema) {
2084-
break
2085-
}
2086-
if i == len(oldCRD.Spec.Versions)-1 {
2087-
// we are on the last iteration
2088-
// schema has not changed between versions at this point.
2089-
return nil
2081+
oldVersionSet := sets.New[string]()
2082+
for _, oldVersion := range oldCRD.Spec.Versions {
2083+
if !oldVersionSet.Has(oldVersion.Name) && oldVersion.Served {
2084+
oldVersionSet.Insert(oldVersion.Name)
20902085
}
20912086
}
20922087

2093-
convertedCRD := &apiextensions.CustomResourceDefinition{}
2094-
if err := apiextensionsv1.Convert_v1_CustomResourceDefinition_To_apiextensions_CustomResourceDefinition(newCRD, convertedCRD, nil); err != nil {
2095-
return err
2096-
}
2097-
for _, version := range oldCRD.Spec.Versions {
2098-
if version.Served {
2099-
gvr := schema.GroupVersionResource{Group: oldCRD.Spec.Group, Version: version.Name, Resource: oldCRD.Spec.Names.Plural}
2100-
err := validateExistingCRs(dynamicClient, gvr, convertedCRD)
2101-
if err != nil {
2088+
validationsMap := make(map[string]*apiextensions.CustomResourceValidation, 0)
2089+
for _, newVersion := range newCRD.Spec.Versions {
2090+
if oldVersionSet.Has(newVersion.Name) && newVersion.Served {
2091+
// If the new CRD's version is present in the cluster and still
2092+
// served then fill the map entry with the new validation
2093+
convertedValidation := &apiextensions.CustomResourceValidation{}
2094+
if err := apiextensionsv1.Convert_v1_CustomResourceValidation_To_apiextensions_CustomResourceValidation(newVersion.Schema, convertedValidation, nil); err != nil {
21022095
return err
21032096
}
2097+
validationsMap[newVersion.Name] = convertedValidation
21042098
}
21052099
}
2106-
2107-
logrus.Debugf("Successfully validated CRD %s\n", newCRD.Name)
2108-
return nil
2100+
return validateExistingCRs(dynamicClient, schema.GroupResource{Group: newCRD.Spec.Group, Resource: newCRD.Spec.Names.Plural}, validationsMap)
21092101
}
21102102

21112103
// Validate all existing served versions against new CRD's validation (if changed)
21122104
func validateV1Beta1CRDCompatibility(dynamicClient dynamic.Interface, oldCRD *apiextensionsv1beta1.CustomResourceDefinition, newCRD *apiextensionsv1beta1.CustomResourceDefinition) error {
21132105
logrus.Debugf("Comparing %#v to %#v", oldCRD.Spec.Validation, newCRD.Spec.Validation)
2114-
2115-
// TODO return early of all versions are equal
2116-
convertedCRD := &apiextensions.CustomResourceDefinition{}
2117-
if err := apiextensionsv1beta1.Convert_v1beta1_CustomResourceDefinition_To_apiextensions_CustomResourceDefinition(newCRD, convertedCRD, nil); err != nil {
2118-
return err
2106+
oldVersionSet := sets.New[string]()
2107+
if len(oldCRD.Spec.Versions) == 0 {
2108+
// apiextensionsv1beta1 special case: if spec.Versions is empty, use the global version and validation
2109+
oldVersionSet.Insert(oldCRD.Spec.Version)
21192110
}
2120-
for _, version := range oldCRD.Spec.Versions {
2121-
if version.Served {
2122-
gvr := schema.GroupVersionResource{Group: oldCRD.Spec.Group, Version: version.Name, Resource: oldCRD.Spec.Names.Plural}
2123-
err := validateExistingCRs(dynamicClient, gvr, convertedCRD)
2124-
if err != nil {
2125-
return err
2126-
}
2111+
for _, oldVersion := range oldCRD.Spec.Versions {
2112+
// collect served versions from spec.Versions if the list is present
2113+
if !oldVersionSet.Has(oldVersion.Name) && oldVersion.Served {
2114+
oldVersionSet.Insert(oldVersion.Name)
21272115
}
21282116
}
21292117

2130-
if oldCRD.Spec.Version != "" {
2131-
gvr := schema.GroupVersionResource{Group: oldCRD.Spec.Group, Version: oldCRD.Spec.Version, Resource: oldCRD.Spec.Names.Plural}
2132-
err := validateExistingCRs(dynamicClient, gvr, convertedCRD)
2133-
if err != nil {
2134-
return err
2118+
validationsMap := make(map[string]*apiextensions.CustomResourceValidation, 0)
2119+
gr := schema.GroupResource{Group: newCRD.Spec.Group, Resource: newCRD.Spec.Names.Plural}
2120+
if len(newCRD.Spec.Versions) == 0 {
2121+
// apiextensionsv1beta1 special case: if spec.Versions of newCRD is empty, use the global version and validation
2122+
if oldVersionSet.Has(newCRD.Spec.Version) {
2123+
convertedValidation := &apiextensions.CustomResourceValidation{}
2124+
if err := apiextensionsv1beta1.Convert_v1beta1_CustomResourceValidation_To_apiextensions_CustomResourceValidation(newCRD.Spec.Validation, convertedValidation, nil); err != nil {
2125+
return err
2126+
}
2127+
validationsMap[newCRD.Spec.Version] = convertedValidation
2128+
}
2129+
}
2130+
for _, newVersion := range newCRD.Spec.Versions {
2131+
if oldVersionSet.Has(newVersion.Name) && newVersion.Served {
2132+
// If the new CRD's version is present in the cluster and still
2133+
// served then fill the map entry with the new validation
2134+
if newCRD.Spec.Validation != nil {
2135+
// apiextensionsv1beta1 special case: spec.Validation and spec.Versions[].Schema are mutually exclusive;
2136+
// if spec.Versions is non-empty and spec.Validation is set then we can validate once against any
2137+
// single existing version.
2138+
convertedValidation := &apiextensions.CustomResourceValidation{}
2139+
if err := apiextensionsv1beta1.Convert_v1beta1_CustomResourceValidation_To_apiextensions_CustomResourceValidation(newCRD.Spec.Validation, convertedValidation, nil); err != nil {
2140+
return err
2141+
}
2142+
return validateExistingCRs(dynamicClient, gr, map[string]*apiextensions.CustomResourceValidation{newVersion.Name: convertedValidation})
2143+
}
2144+
convertedValidation := &apiextensions.CustomResourceValidation{}
2145+
if err := apiextensionsv1beta1.Convert_v1beta1_CustomResourceValidation_To_apiextensions_CustomResourceValidation(newVersion.Schema, convertedValidation, nil); err != nil {
2146+
return err
2147+
}
2148+
validationsMap[newVersion.Name] = convertedValidation
21352149
}
21362150
}
2137-
logrus.Debugf("Successfully validated CRD %s\n", newCRD.Name)
2138-
return nil
2151+
return validateExistingCRs(dynamicClient, gr, validationsMap)
21392152
}
21402153

2141-
func validateExistingCRs(dynamicClient dynamic.Interface, gvr schema.GroupVersionResource, newCRD *apiextensions.CustomResourceDefinition) error {
2142-
// make dynamic client
2143-
crList, err := dynamicClient.Resource(gvr).List(context.TODO(), metav1.ListOptions{})
2144-
if err != nil {
2145-
return fmt.Errorf("error listing resources in GroupVersionResource %#v: %s", gvr, err)
2146-
}
2147-
for _, cr := range crList.Items {
2148-
validator, _, err := validation.NewSchemaValidator(newCRD.Spec.Validation)
2154+
// validateExistingCRs lists all CRs for each version entry in validationsMap, then validates each using the paired validation.
2155+
func validateExistingCRs(dynamicClient dynamic.Interface, gr schema.GroupResource, validationsMap map[string]*apiextensions.CustomResourceValidation) error {
2156+
for version, schemaValidation := range validationsMap {
2157+
// create validator from given crdValidation
2158+
validator, _, err := validation.NewSchemaValidator(schemaValidation)
21492159
if err != nil {
2150-
return fmt.Errorf("error creating validator for schema %#v: %s", newCRD.Spec.Validation, err)
2160+
return fmt.Errorf("error creating validator for schema version %s: %s", version, err)
21512161
}
2152-
err = validation.ValidateCustomResource(field.NewPath(""), cr.UnstructuredContent(), validator).ToAggregate()
2162+
2163+
gvr := schema.GroupVersionResource{Group: gr.Group, Version: version, Resource: gr.Resource}
2164+
crList, err := dynamicClient.Resource(gvr).List(context.TODO(), metav1.ListOptions{})
21532165
if err != nil {
2154-
return fmt.Errorf("error validating custom resource against new schema for %s %s/%s: %v", newCRD.Spec.Names.Kind, cr.GetNamespace(), cr.GetName(), err)
2166+
return fmt.Errorf("error listing resources in GroupVersionResource %#v: %s", gvr, err)
2167+
}
2168+
2169+
// validate each CR against this version schema
2170+
for _, cr := range crList.Items {
2171+
err = validation.ValidateCustomResource(field.NewPath(""), cr.UnstructuredContent(), validator).ToAggregate()
2172+
if err != nil {
2173+
var namespacedName string
2174+
if cr.GetNamespace() == "" {
2175+
namespacedName = cr.GetName()
2176+
} else {
2177+
namespacedName = fmt.Sprintf("%s/%s", cr.GetNamespace(), cr.GetName())
2178+
}
2179+
return fmt.Errorf("error validating %s %q: updated validation is too restrictive: %v", cr.GroupVersionKind(), namespacedName, err)
2180+
}
21552181
}
21562182
}
21572183
return nil

pkg/controller/operators/catalog/operator_test.go

+139-12
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ import (
1414
"time"
1515

1616
"github.com/operator-framework/operator-lifecycle-manager/pkg/controller/install"
17-
"k8s.io/apiextensions-apiserver/pkg/apis/apiextensions"
1817

1918
"github.com/sirupsen/logrus"
2019
"github.com/stretchr/testify/require"
@@ -1459,7 +1458,7 @@ func TestCompetingCRDOwnersExist(t *testing.T) {
14591458
}
14601459
}
14611460

1462-
func TestValidateExistingCRs(t *testing.T) {
1461+
func TestValidateV1Beta1CRDCompatibility(t *testing.T) {
14631462
unstructuredForFile := func(file string) *unstructured.Unstructured {
14641463
data, err := os.ReadFile(file)
14651464
require.NoError(t, err)
@@ -1469,22 +1468,21 @@ func TestValidateExistingCRs(t *testing.T) {
14691468
return k8sFile
14701469
}
14711470

1472-
unversionedCRDForV1beta1File := func(file string) *apiextensions.CustomResourceDefinition {
1471+
unversionedCRDForV1beta1File := func(file string) *apiextensionsv1beta1.CustomResourceDefinition {
14731472
data, err := os.ReadFile(file)
14741473
require.NoError(t, err)
14751474
dec := utilyaml.NewYAMLOrJSONDecoder(strings.NewReader(string(data)), 30)
14761475
k8sFile := &apiextensionsv1beta1.CustomResourceDefinition{}
14771476
require.NoError(t, dec.Decode(k8sFile))
1478-
convertedCRD := &apiextensions.CustomResourceDefinition{}
1479-
require.NoError(t, apiextensionsv1beta1.Convert_v1beta1_CustomResourceDefinition_To_apiextensions_CustomResourceDefinition(k8sFile, convertedCRD, nil))
1480-
return convertedCRD
1477+
return k8sFile
14811478
}
14821479

14831480
tests := []struct {
14841481
name string
14851482
existingObjects []runtime.Object
14861483
gvr schema.GroupVersionResource
1487-
newCRD *apiextensions.CustomResourceDefinition
1484+
oldCRD *apiextensionsv1beta1.CustomResourceDefinition
1485+
newCRD *apiextensionsv1beta1.CustomResourceDefinition
14881486
want error
14891487
}{
14901488
{
@@ -1497,6 +1495,7 @@ func TestValidateExistingCRs(t *testing.T) {
14971495
Version: "v1",
14981496
Resource: "machinepools",
14991497
},
1498+
oldCRD: unversionedCRDForV1beta1File("testdata/hivebug/crd.yaml"),
15001499
newCRD: unversionedCRDForV1beta1File("testdata/hivebug/crd.yaml"),
15011500
},
15021501
{
@@ -1509,16 +1508,144 @@ func TestValidateExistingCRs(t *testing.T) {
15091508
Version: "v1",
15101509
Resource: "machinepools",
15111510
},
1511+
oldCRD: unversionedCRDForV1beta1File("testdata/hivebug/crd.yaml"),
15121512
newCRD: unversionedCRDForV1beta1File("testdata/hivebug/crd.yaml"),
1513-
want: fmt.Errorf("error validating custom resource against new schema for MachinePool /test: [[].spec.clusterDeploymentRef: Invalid value: \"null\": spec.clusterDeploymentRef in body must be of type object: \"null\", [].spec.name: Required value, [].spec.platform: Required value]"),
1513+
want: fmt.Errorf("error validating hive.openshift.io/v1, Kind=MachinePool \"test\": updated validation is too restrictive: [[].spec.clusterDeploymentRef: Invalid value: \"null\": spec.clusterDeploymentRef in body must be of type object: \"null\", [].spec.name: Required value, [].spec.platform: Required value]"),
1514+
},
1515+
{
1516+
name: "backwards incompatible change",
1517+
existingObjects: []runtime.Object{
1518+
unstructuredForFile("testdata/apiextensionsv1beta1/cr.yaml"),
1519+
},
1520+
gvr: schema.GroupVersionResource{
1521+
Group: "cluster.com",
1522+
Version: "v1alpha1",
1523+
Resource: "testcrd",
1524+
},
1525+
oldCRD: unversionedCRDForV1beta1File("testdata/apiextensionsv1beta1/crd.old.yaml"),
1526+
newCRD: unversionedCRDForV1beta1File("testdata/apiextensionsv1beta1/crd.yaml"),
1527+
want: fmt.Errorf("error validating cluster.com/v1alpha1, Kind=testcrd \"my-cr-1\": updated validation is too restrictive: [].spec.scalar: Invalid value: 2: spec.scalar in body should be greater than or equal to 3"),
1528+
},
1529+
{
1530+
name: "unserved version",
1531+
existingObjects: []runtime.Object{
1532+
unstructuredForFile("testdata/apiextensionsv1beta1/cr.yaml"),
1533+
unstructuredForFile("testdata/apiextensionsv1beta1/cr.v2.yaml"),
1534+
},
1535+
gvr: schema.GroupVersionResource{
1536+
Group: "cluster.com",
1537+
Version: "v1alpha1",
1538+
Resource: "testcrd",
1539+
},
1540+
oldCRD: unversionedCRDForV1beta1File("testdata/apiextensionsv1beta1/crd.old.yaml"),
1541+
newCRD: unversionedCRDForV1beta1File("testdata/apiextensionsv1beta1/crd.unserved.yaml"),
1542+
},
1543+
{
1544+
name: "cr not validated against currently unserved version",
1545+
existingObjects: []runtime.Object{
1546+
unstructuredForFile("testdata/apiextensionsv1beta1/cr.yaml"),
1547+
unstructuredForFile("testdata/apiextensionsv1beta1/cr.v2.yaml"),
1548+
},
1549+
oldCRD: unversionedCRDForV1beta1File("testdata/apiextensionsv1beta1/crd.unserved.yaml"),
1550+
newCRD: unversionedCRDForV1beta1File("testdata/apiextensionsv1beta1/crd.yaml"),
1551+
},
1552+
{
1553+
name: "crd with no versions list",
1554+
existingObjects: []runtime.Object{
1555+
unstructuredForFile("testdata/apiextensionsv1beta1/cr.yaml"),
1556+
unstructuredForFile("testdata/apiextensionsv1beta1/cr.v2.yaml"),
1557+
},
1558+
oldCRD: unversionedCRDForV1beta1File("testdata/apiextensionsv1beta1/crd.no-versions-list.old.yaml"),
1559+
newCRD: unversionedCRDForV1beta1File("testdata/apiextensionsv1beta1/crd.no-versions-list.yaml"),
1560+
want: fmt.Errorf("error validating cluster.com/v1alpha1, Kind=testcrd \"my-cr-1\": updated validation is too restrictive: [].spec.scalar: Invalid value: 2: spec.scalar in body should be greater than or equal to 3"),
1561+
},
1562+
}
1563+
for _, tt := range tests {
1564+
t.Run(tt.name, func(t *testing.T) {
1565+
client := fakedynamic.NewSimpleDynamicClient(runtime.NewScheme(), tt.existingObjects...)
1566+
require.Equal(t, tt.want, validateV1Beta1CRDCompatibility(client, tt.oldCRD, tt.newCRD))
1567+
})
1568+
}
1569+
}
1570+
1571+
func TestValidateV1CRDCompatibility(t *testing.T) {
1572+
unstructuredForFile := func(file string) *unstructured.Unstructured {
1573+
data, err := os.ReadFile(file)
1574+
require.NoError(t, err)
1575+
dec := utilyaml.NewYAMLOrJSONDecoder(strings.NewReader(string(data)), 30)
1576+
k8sFile := &unstructured.Unstructured{}
1577+
require.NoError(t, dec.Decode(k8sFile))
1578+
return k8sFile
1579+
}
1580+
1581+
unversionedCRDForV1File := func(file string) *apiextensionsv1.CustomResourceDefinition {
1582+
data, err := os.ReadFile(file)
1583+
require.NoError(t, err)
1584+
dec := utilyaml.NewYAMLOrJSONDecoder(strings.NewReader(string(data)), 30)
1585+
k8sFile := &apiextensionsv1.CustomResourceDefinition{}
1586+
require.NoError(t, dec.Decode(k8sFile))
1587+
return k8sFile
1588+
}
1589+
1590+
tests := []struct {
1591+
name string
1592+
existingCRs []runtime.Object
1593+
gvr schema.GroupVersionResource
1594+
oldCRD *apiextensionsv1.CustomResourceDefinition
1595+
newCRD *apiextensionsv1.CustomResourceDefinition
1596+
want error
1597+
}{
1598+
{
1599+
name: "valid",
1600+
existingCRs: []runtime.Object{
1601+
unstructuredForFile("testdata/apiextensionsv1/crontabs.cr.valid.v1.yaml"),
1602+
unstructuredForFile("testdata/apiextensionsv1/crontabs.cr.valid.v2.yaml"),
1603+
},
1604+
oldCRD: unversionedCRDForV1File("testdata/apiextensionsv1/crontabs.crd.old.yaml"),
1605+
newCRD: unversionedCRDForV1File("testdata/apiextensionsv1/crontabs.crd.yaml"),
1606+
},
1607+
{
1608+
name: "validation failure",
1609+
existingCRs: []runtime.Object{
1610+
unstructuredForFile("testdata/apiextensionsv1/crontabs.cr.valid.v1.yaml"),
1611+
unstructuredForFile("testdata/apiextensionsv1/crontabs.cr.fail.v2.yaml"),
1612+
},
1613+
oldCRD: unversionedCRDForV1File("testdata/apiextensionsv1/crontabs.crd.old.yaml"),
1614+
newCRD: unversionedCRDForV1File("testdata/apiextensionsv1/crontabs.crd.yaml"),
1615+
want: fmt.Errorf("error validating stable.example.com/v2, Kind=CronTab \"my-crontab\": updated validation is too restrictive: [].spec.replicas: Invalid value: 10: spec.replicas in body should be less than or equal to 9"),
1616+
},
1617+
{
1618+
name: "cr not invalidated by unserved version",
1619+
existingCRs: []runtime.Object{
1620+
unstructuredForFile("testdata/apiextensionsv1/crontabs.cr.valid.v1.yaml"),
1621+
unstructuredForFile("testdata/apiextensionsv1/crontabs.cr.valid.v2.yaml"),
1622+
},
1623+
oldCRD: unversionedCRDForV1File("testdata/apiextensionsv1/crontabs.crd.old.yaml"),
1624+
newCRD: unversionedCRDForV1File("testdata/apiextensionsv1/crontabs.crd.unserved.yaml"),
1625+
},
1626+
{
1627+
name: "cr not validated against currently unserved version",
1628+
existingCRs: []runtime.Object{
1629+
unstructuredForFile("testdata/apiextensionsv1/crontabs.cr.valid.v1.yaml"),
1630+
unstructuredForFile("testdata/apiextensionsv1/crontabs.cr.valid.v2.yaml"),
1631+
},
1632+
oldCRD: unversionedCRDForV1File("testdata/apiextensionsv1/crontabs.crd.old.unserved.yaml"),
1633+
newCRD: unversionedCRDForV1File("testdata/apiextensionsv1/crontabs.crd.yaml"),
1634+
},
1635+
{
1636+
name: "validation failure with single CRD version",
1637+
existingCRs: []runtime.Object{
1638+
unstructuredForFile("testdata/apiextensionsv1/single-version-cr.yaml"),
1639+
},
1640+
oldCRD: unversionedCRDForV1File("testdata/apiextensionsv1/single-version-crd.old.yaml"),
1641+
newCRD: unversionedCRDForV1File("testdata/apiextensionsv1/single-version-crd.yaml"),
1642+
want: fmt.Errorf("error validating cluster.com/v1alpha1, Kind=testcrd \"my-cr-1\": updated validation is too restrictive: [].spec.scalar: Invalid value: 100: spec.scalar in body should be less than or equal to 50"),
15141643
},
15151644
}
15161645
for _, tt := range tests {
15171646
t.Run(tt.name, func(t *testing.T) {
1518-
client := fakedynamic.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(), map[schema.GroupVersionResource]string{
1519-
tt.gvr: "UnstructuredList",
1520-
}, tt.existingObjects...)
1521-
require.Equal(t, tt.want, validateExistingCRs(client, tt.gvr, tt.newCRD))
1647+
client := fakedynamic.NewSimpleDynamicClient(runtime.NewScheme(), tt.existingCRs...)
1648+
require.Equal(t, tt.want, validateV1CRDCompatibility(client, tt.oldCRD, tt.newCRD))
15221649
})
15231650
}
15241651
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
apiVersion: stable.example.com/v2
2+
kind: CronTab
3+
metadata:
4+
name: my-crontab
5+
spec:
6+
cronSpec: "* * * * *"
7+
image: ""
8+
replicas: 10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
apiVersion: stable.example.com/v1
2+
kind: CronTab
3+
metadata:
4+
name: my-crontab-v1
5+
spec:
6+
cronSpec: "* * * * *"
7+
image: ""
8+
replicas: 9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
apiVersion: stable.example.com/v2
2+
kind: CronTab
3+
metadata:
4+
name: my-crontab-v2
5+
spec:
6+
cronSpec: "* * * * *"
7+
image: ""
8+
replicas: 9

0 commit comments

Comments
 (0)