Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

RHOAIENG-17634: chore(odh-nbc/tests): implement diff minimization for k8s objects diffs #503

Open
wants to merge 5 commits into
base: main
Choose a base branch
from

Conversation

jiridanek
Copy link
Member

@jiridanek jiridanek commented Jan 10, 2025

https://issues.redhat.com/browse/RHOAIENG-17634

This is a followup on

Description

Implements the diff minimization when comparing K8s CRs. This is not trivial, because the comparison functions we use are general Go functions, and it's not easy to figure out why that function returns false for a given input.

Here's how it can be done. Create a deep copy of the actual value, apply the differences between actual and expected one by one, and call the comparison function repeatedly to check whether each of the differences does or does not influence the comparison result.

This uses the deep copy functions that K8s provides, and it also uses Go reflection to walk the compared structs, diff them, and modify them. Most of the diffing is done by the go-cmp package.

How Has This Been Tested?

Here's what the failure from the previous PR is looking now.

    Should recreate the Route when deleted [It]
    /Users/jdanek/IdeaProjects/kubeflow/components/odh-notebook-controller/controllers/notebook_controller_test.go:123

    Expected
        v1.Route{TypeMeta:v1.TypeMeta{Kind:"", APIVersion:""}, ObjectMeta:v1.ObjectMeta{Name:"test-notebook", GenerateName:"", Namespace:"default", SelfLink:"", UID:"35eb6144-527a-46f6-8d25-defc99f50b82", ResourceVersion:"216", Generation:1, CreationTimestamp:time.Date(2025, time.January, 10, 16, 14, 29, 0, time.Local), DeletionTimestamp:<nil>, DeletionGracePeriodSeconds:(*int64)(nil), Labels:map[string]string{"notebook-name":"test-notebook"}, Annotations:map[string]string(nil), OwnerReferences:[]v1.OwnerReference{v1.OwnerReference{APIVersion:"kubeflow.org/v1", Kind:"Notebook", Name:"test-notebook", UID:"ba322ae8-6337-43ab-a81a-c04cc165f8e9", Controller:(*bool)(0x140006aff5e), BlockOwnerDeletion:(*bool)(0x140006aff5d)}}, Finalizers:[]string(nil), ManagedFields:[]v1.ManagedFieldsEntry{v1.ManagedFieldsEntry{Manager:"controllers.test", Operation:"Update", APIVersion:"route.openshift.io/v1", Time:time.Date(2025, time.January, 10, 16, 14, 29, 0, time.Local), FieldsType:"FieldsV1", FieldsV1:(*v1.FieldsV1)(0x14000a6b638), Subresource:""}}}, Spec:v1.RouteSpec{Host:"", Subdomain:"", Path:"", To:v1.RouteTargetReference{Kind:"Service", Name:"test-notebook", Weight:(*int32)(0x140006affb8)}, AlternateBackends:[]v1.RouteTargetReference(nil), Port:(*v1.RoutePort)(0x14000134780), TLS:(*v1.TLSConfig)(0x14000416cc0), WildcardPolicy:"None"}, Status:v1.RouteStatus{Ingress:[]v1.RouteIngress{}}}
    to compare identical to
        v1.Route{TypeMeta:v1.TypeMeta{Kind:"", APIVersion:""}, ObjectMeta:v1.ObjectMeta{Name:"test-notebook", GenerateName:"", Namespace:"default", SelfLink:"", UID:"", ResourceVersion:"", Generation:0, CreationTimestamp:time.Date(1, time.January, 1, 0, 0, 0, 0, time.UTC), DeletionTimestamp:<nil>, DeletionGracePeriodSeconds:(*int64)(nil), Labels:map[string]string{"notebook-name":"test-notebook"}, Annotations:map[string]string(nil), OwnerReferences:[]v1.OwnerReference(nil), Finalizers:[]string(nil), ManagedFields:[]v1.ManagedFieldsEntry(nil)}, Spec:v1.RouteSpec{Host:"", Subdomain:"", Path:"", To:v1.RouteTargetReference{Kind:"No Such Service", Name:"test-notebook", Weight:(*int32)(0x140005200a0)}, AlternateBackends:[]v1.RouteTargetReference(nil), Port:(*v1.RoutePort)(0x1400002ea80), TLS:(*v1.TLSConfig)(0x140004170e0), WildcardPolicy:"None"}, Status:v1.RouteStatus{Ingress:[]v1.RouteIngress{}}}
    but it differs in
      v1.Route{
        TypeMeta:   {},
        ObjectMeta: {Name: "test-notebook", Namespace: "default", UID: "35eb6144-527a-46f6-8d25-defc99f50b82", ResourceVersion: "216", ...},
        Spec: v1.RouteSpec{
                Host:      "",
                Subdomain: "",
                Path:      "",
                To: v1.RouteTargetReference{
    -                   Kind:   "Service",
    +                   Kind:   "No Such Service",
                        Name:   "test-notebook",
                        Weight: &100,
                },
                AlternateBackends: nil,
                Port:              &{TargetPort: {Type: 1, StrVal: "http-test-notebook"}},
                ... // 2 identical fields
        },
        Status: {Ingress: {}},
      }
    

    /Users/jdanek/IdeaProjects/kubeflow/components/odh-notebook-controller/controllers/notebook_controller_test.go:133

Very nice!

Merge criteria:

  • The commits are squashed in a cohesive manner and have meaningful messages.
  • Testing instructions have been added in the PR body (for PRs involving changes that are not immediately obvious).
  • The developer has manually tested the changes and verified that the changes work

…comparing CRs in kubeflow notebooks tests

This is the initial refactor to use a new custom matcher.
The matcher has no new features compared to the old way.

I will do the meaningful-diffs part as a next PR (under the same Jira number).
Copy link

openshift-ci bot commented Jan 10, 2025

[APPROVALNOTIFIER] This PR is NOT APPROVED

This pull-request has been approved by:
Once this PR has been reviewed and has the lgtm label, please ask for approval from jiridanek. For more information see the Code Review Process.

The full list of commands accepted by this bot can be found here.

Needs approval from an approver in each of these files:

Approvers can indicate their approval by writing /approve in a comment
Approvers can cancel approval by writing /approve cancel in a comment

@codecov-commenter
Copy link

codecov-commenter commented Jan 10, 2025

Codecov Report

All modified and coverable lines are covered by tests ✅

Project coverage is 70.86%. Comparing base (330d816) to head (b8de303).
Report is 5 commits behind head on main.

Additional details and impacted files
@@             Coverage Diff             @@
##             main     #503       +/-   ##
===========================================
+ Coverage   55.27%   70.86%   +15.58%     
===========================================
  Files           9        7        -2     
  Lines        2276     1335      -941     
===========================================
- Hits         1258      946      -312     
+ Misses        922      324      -598     
+ Partials       96       65       -31     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

… k8s objects diffs

This is a followup to the refactoring before.

The minimal diffs are computed by taking the `actual` value,
making a deep copy of it, applying the differences between
`actual` and `expected` to the deep copy, and calling the comparison function repeatedly.
if !vx.IsValid() || !vy.IsValid() {
// it may happen that more than one OnDiff will break off at the same place
// but that does not matter for correctness, it will just be a bit inefficient
break
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, in this case - this diff isn't stored to the final diff - is it intentional? I'm not sure what are possible cases for these being invalid, so I'm not sure.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it is, line 264, current.Set(vx), will run just like if we got at the end of the path.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(one of) these will be invalid if the structs are not matched up, say both have a map, and one of them has a key and the other one does not; so the vy will be the value in that key and vx will be invalid because that key is missing and there's no value for that; there's a self test for this

// Structs contain different keys, so the comparison has to happen
// on a map as a whole, because the second struct lacks the key from first.
// This is something I forgot to handle originally.
func Test_BeMatchingK8sResource_MismatchedStructs(t *testing.T) {
	someRoute := routev1.Route{
		ObjectMeta: metav1.ObjectMeta{
			Name: "someRouteName",
			Labels: map[string]string{
				"foo": "bar",
			},
		},
	}
	someOtherRoute := routev1.Route{
		ObjectMeta: metav1.ObjectMeta{
			Name:   "someOtherRouteName",
			Labels: nil,
		},
	}
...

@jstourac
Copy link
Member

Thank you @jiridanek for this. It is something that may be useful and can help during a dev or debugging. But I'm still a bit worried that this our implementation may sometimes mask some difference that actually matters. As such, what do you think to print both diffs? So, it would be first the standard diff as we have it now and then another diff that is minimized by this your implementation. WDYT?

@jiridanek
Copy link
Member Author

jiridanek commented Jan 15, 2025

Here's what the diff looks like after the suggested change

  /Users/jdanek/IdeaProjects/kubeflow/components/odh-notebook-controller/controllers/notebook_controller_test.go:52
    Should create a Route to expose the traffic externally [It]
    /Users/jdanek/IdeaProjects/kubeflow/components/odh-notebook-controller/controllers/notebook_controller_test.go:90

    Expected
        v1.Route{TypeMeta:v1.TypeMeta{Kind:"", APIVersion:""}, ObjectMeta:v1.ObjectMeta{Name:"test-notebook", GenerateName:"", Namespace:"default", SelfLink:"", UID:"fed26650-4a03-4f7b-99b1-c8585a8d803b", ResourceVersion:"208", Generation:1, CreationTimestamp:time.Date(2025, time.January, 15, 14, 14, 58, 0, time.Local), DeletionTimestamp:<nil>, DeletionGracePeriodSeconds:(*int64)(nil), Labels:map[string]string{"notebook-name":"test-notebook"}, Annotations:map[string]string(nil), OwnerReferences:[]v1.OwnerReference{v1.OwnerReference{APIVersion:"kubeflow.org/v1", Kind:"Notebook", Name:"test-notebook", UID:"0c22df7e-b44c-4ca8-8568-9a6cda3e09c4", Controller:(*bool)(0x14000db4d88), BlockOwnerDeletion:(*bool)(0x14000db4d87)}}, Finalizers:[]string(nil), ManagedFields:[]v1.ManagedFieldsEntry{v1.ManagedFieldsEntry{Manager:"controllers.test", Operation:"Update", APIVersion:"route.openshift.io/v1", Time:time.Date(2025, time.January, 15, 14, 14, 58, 0, time.Local), FieldsType:"FieldsV1", FieldsV1:(*v1.FieldsV1)(0x14000995260), Subresource:""}}}, Spec:v1.RouteSpec{Host:"", Subdomain:"", Path:"", To:v1.RouteTargetReference{Kind:"Service", Name:"test-notebook", Weight:(*int32)(0x14000db4dd8)}, AlternateBackends:[]v1.RouteTargetReference(nil), Port:(*v1.RoutePort)(0x14000512c20), TLS:(*v1.TLSConfig)(0x1400057b6e0), WildcardPolicy:"None"}, Status:v1.RouteStatus{Ingress:[]v1.RouteIngress{}}}
    to compare identical to
        v1.Route{TypeMeta:v1.TypeMeta{Kind:"", APIVersion:""}, ObjectMeta:v1.ObjectMeta{Name:"test-notebook", GenerateName:"", Namespace:"default", SelfLink:"", UID:"", ResourceVersion:"", Generation:0, CreationTimestamp:time.Date(1, time.January, 1, 0, 0, 0, 0, time.UTC), DeletionTimestamp:<nil>, DeletionGracePeriodSeconds:(*int64)(nil), Labels:map[string]string{"notebook-namde":"test-notebook"}, Annotations:map[string]string(nil), OwnerReferences:[]v1.OwnerReference(nil), Finalizers:[]string(nil), ManagedFields:[]v1.ManagedFieldsEntry(nil)}, Spec:v1.RouteSpec{Host:"", Subdomain:"", Path:"", To:v1.RouteTargetReference{Kind:"No Such Service", Name:"test-notebook", Weight:(*int32)(0x1400000e470)}, AlternateBackends:[]v1.RouteTargetReference(nil), Port:(*v1.RoutePort)(0x1400018a0e0), TLS:(*v1.TLSConfig)(0x1400005c240), WildcardPolicy:"None"}, Status:v1.RouteStatus{Ingress:[]v1.RouteIngress{}}}
    but it differs in
      v1.Route{
        TypeMeta: {},
        ObjectMeta: v1.ObjectMeta{
                ... // 2 identical fields
                Namespace:                  "default",
                SelfLink:                   "",
    -           UID:                        "fed26650-4a03-4f7b-99b1-c8585a8d803b",
    +           UID:                        "",
    -           ResourceVersion:            "208",
    +           ResourceVersion:            "",
    -           Generation:                 1,
    +           Generation:                 0,
    -           CreationTimestamp:          v1.Time{Time: s"2025-01-15 14:14:58 +0100 CET"},
    +           CreationTimestamp:          v1.Time{},
                DeletionTimestamp:          nil,
                DeletionGracePeriodSeconds: nil,
                Labels: map[string]string{
    +                   "notebook-namde": "test-notebook",
    -                   "notebook-name":  "test-notebook",
                },
                Annotations: nil,
    -           OwnerReferences: []v1.OwnerReference{
    -                   {
    -                           APIVersion:         "kubeflow.org/v1",
    -                           Kind:               "Notebook",
    -                           Name:               "test-notebook",
    -                           UID:                "0c22df7e-b44c-4ca8-8568-9a6cda3e09c4",
    -                           Controller:         &true,
    -                           BlockOwnerDeletion: &true,
    -                   },
    -           },
    +           OwnerReferences: nil,
                Finalizers:      nil,
    -           ManagedFields: []v1.ManagedFieldsEntry{
    -                   {
    -                           Manager:    "controllers.test",
    -                           Operation:  "Update",
    -                           APIVersion: "route.openshift.io/v1",
    -                           Time:       s"2025-01-15 14:14:58 +0100 CET",
    -                           FieldsType: "FieldsV1",
    -                           FieldsV1:   s`{"f:metadata":{"f:labels":{".":{},"f:notebook-name":{}},"f:owner`...,
    -                   },
    -           },
    +           ManagedFields: nil,
        },
        Spec: v1.RouteSpec{
                Host:      "",
                Subdomain: "",
                Path:      "",
                To: v1.RouteTargetReference{
    -                   Kind:   "Service",
    +                   Kind:   "No Such Service",
                        Name:   "test-notebook",
                        Weight: &100,
                },
                AlternateBackends: nil,
                Port:              &{TargetPort: {Type: 1, StrVal: "http-test-notebook"}},
                ... // 2 identical fields
        },
        Status: {Ingress: {}},
      }
    
    Minimized diff is
      v1.Route{
        TypeMeta: {},
        ObjectMeta: v1.ObjectMeta{
                ... // 8 identical fields
                DeletionTimestamp:          nil,
                DeletionGracePeriodSeconds: nil,
                Labels: map[string]string{
    +                   "notebook-namde": "test-notebook",
    -                   "notebook-name":  "test-notebook",
                },
                Annotations:     nil,
                OwnerReferences: {{APIVersion: "kubeflow.org/v1", Kind: "Notebook", Name: "test-notebook", UID: "0c22df7e-b44c-4ca8-8568-9a6cda3e09c4", ...}},
                ... // 2 identical fields
        },
        Spec: v1.RouteSpec{
                Host:      "",
                Subdomain: "",
                Path:      "",
                To: v1.RouteTargetReference{
    -                   Kind:   "Service",
    +                   Kind:   "No Such Service",
                        Name:   "test-notebook",
                        Weight: &100,
                },
                AlternateBackends: nil,
                Port:              &{TargetPort: {Type: 1, StrVal: "http-test-notebook"}},
                ... // 2 identical fields
        },
        Status: {Ingress: {}},
      }
    

    /Users/jdanek/IdeaProjects/kubeflow/components/odh-notebook-controller/controllers/notebook_controller_test.go:102

…mized diff

this way we always have all the info when test fails, but it's still not difficult
to find the minimized diff for initial diagnostics, because it's the most obvious thing at the bottom
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants