Change:
PDB was upgraded to v1 in 1.21. Despite the changelog saying that the behavior of the v1beta1 implementation should have stayed the same, it appears that the for the field was set to . I believe this is the change in question: When running with a starting config that had a with both a key and a key, and a patch that had a with just a key, the behavior between 1.20 and 1.21 differs. What we get is the key gets deleted, rather than merged with the patch's key to make one unified selector. In 1.20, the behavior we'd get is thus, which is desirable for us because it lets us inject a base assumption about our PDBs into everything we do: This is my repro case: : Compare by using the command from earlier in this report: I also brought this up in Slack: I don't think we want to switch to using just for PDBs, because that wouldn't solve issues where our users want to use without having to re-specify the from our base. Putting aside the fact that changing the behavior of the v1beta1 implementation was probably not intentional, it also seems undesirable to change how selectors work in general. I don't know how widely scoped this change will be across various resource types, too, so we're starting down quite a bit of nasty discovery time. <detailsThis is the version I used to demonstrate my repro case, but I confirmed that this occurs even in kubectl v1.21.0. For my "happy" repro case, this is what the version output looks like: </details<detailsAWS (the hard way) </detailsNo response No response No response No response
There are no sig labels on this issue. Please add an appropriate label by using one of the following commands: - Please see the for a listing of the SIGs, working groups, and committees available. <detailsInstructions for interacting with me using PR comments are available . If you have questions or suggestions related to my behavior, please file an issue against the repository. </details
This issue is currently awaiting triage. If a SIG or subproject determines this is a relevant issue, they will accept it by applying the label and provide further guidance. The label can be by org members by writing in a comment. <detailsInstructions for interacting with me using PR comments are available . If you have questions or suggestions related to my behavior, please file an issue against the repository. </details
as requested, I am tagging you and on this one
We should narrowly revert the strategic merge patch selector behavior for v1beta1 to match pre-1.21 behavior for backwards compatibility. More broadly, it is unexpected for multiple actors to be writing to a single selector. The pre-1.21 strategic merge patch behavior for v1beta1 PDB was already inconsistent w.r.t. merging portions of the selector and replacing others (it would merge and replace ) For that reason, server-side apply treats selector writes as owning the entire selector, and the v1 PodDisruptionBudget API will do the same for strategic merge patch. edit: reworded the inconsistencies with the < 1.21 selector handling
More broadly, it is unexpected for multiple actors to be writing to a single selector. The pre-1.21 strategic merge patch behavior for v1beta1 PDB has problems with data getting left in the selector incorrectly if the same user submits a patch with two keys, and then submits a patch with only a single key. Would that be considered a bug in the implementation, or was there some inherent, design-caused race condition in patch application that I don't understand? Cuz if I were to sequentially apply patches with the old strategic merge, I would think the selector would just accumulate keys, which seems fine to me–but I can see how a race condition would turn it into chaos
edit: I was wrong about the previous behavior... the issue was that it already inconsistently merged the selector... it would merge and replace , which is still pretty incoherent If you really want to tweak field-level things, jsonpatch can still do surgical insertion/appending/removal of specific fields.
Should we expect to use jsonpatch for anything that can have a selector, going forward? If it changed for PDBs then it should change for everything else, I would assume edit: we discussed more in slack. The answer is no, the fields for other resource types' label selectors are not changing, so we only need to treat PDBs as a special case.

Discussion:
}, "selector": { "$ref": "#/definitions/io.k8s.apimachinery.pkg.apis.meta.v1.LabelSelector", <del> "description": "Label query over pods whose evictions are managed by the disruption budget. A null selector selects no pods. An empty selector ({}) also selects no pods, which differs from standard behavior of selecting all pods. In policy/v1, an empty selector will select all pods in the namespace.", "x-kubernetes-patch-strategy": "replace" </del> <ins> "description": "Label query over pods whose evictions are managed by the disruption budget. A null selector selects no pods. An empty selector ({}) also selects no pods, which differs from standard behavior of selecting all pods. In policy/v1, an empty selector will select all pods in the namespace." </ins> } }, "type": "object"

}, "selector": { "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.LabelSelector", <del> "description": "Label query over pods whose evictions are managed by the disruption budget. A null selector selects no pods. An empty selector ({}) also selects no pods, which differs from standard behavior of selecting all pods. In policy/v1, an empty selector will select all pods in the namespace.", "x-kubernetes-patch-strategy": "replace" </del> <ins> "description": "Label query over pods whose evictions are managed by the disruption budget. A null selector selects no pods. An empty selector ({}) also selects no pods, which differs from standard behavior of selecting all pods. In policy/v1, an empty selector will select all pods in the namespace." </ins> } }, "type": "object"

// A null selector selects no pods. // An empty selector ({}) also selects no pods, which differs from standard behavior of selecting all pods. // In policy/v1, an empty selector will select all pods in the namespace. <del> // +patchStrategy=replace </del> // +optional optional k8s.io.apimachinery.pkg.apis.meta.v1.LabelSelector selector = 2;

// A null selector selects no pods. // An empty selector ({}) also selects no pods, which differs from standard behavior of selecting all pods. // In policy/v1, an empty selector will select all pods in the namespace. <del> // +patchStrategy=replace </del> // +optional <del> Selector *metav1.LabelSelector `json:"selector,omitempty" patchStrategy:"replace" protobuf:"bytes,2,opt,name=selector"` </del> <ins> Selector *metav1.LabelSelector `json:"selector,omitempty" protobuf:"bytes,2,opt,name=selector"` </ins> // An eviction is allowed if at most "maxUnavailable" pods selected by // "selector" are unavailable after the eviction, i.e. even in absence of

import ( "context" "fmt" <ins> "reflect" </ins> "testing" "time" <del> "k8s.io/apiextensions-apiserver/test/integration/fixtures" apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" </del> <ins> "github.com/google/go-cmp/cmp" </ins> v1 "k8s.io/api/core/v1" policyv1 "k8s.io/api/policy/v1" "k8s.io/api/policy/v1beta1" <ins> apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" </ins> apiextensionsclientset "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset" <ins> "k8s.io/apiextensions-apiserver/test/integration/fixtures" </ins> metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime/schema" <ins> "k8s.io/apimachinery/pkg/types" </ins> "k8s.io/apimachinery/pkg/util/intstr" "k8s.io/apimachinery/pkg/util/wait" cacheddiscovery "k8s.io/client-go/discovery/cached/memory"

"k8s.io/kubernetes/pkg/controller/disruption" "k8s.io/kubernetes/test/integration/etcd" "k8s.io/kubernetes/test/integration/framework" <ins> "k8s.io/utils/pointer" </ins> ) func setup(t *testing.T) (*kubeapiservertesting.TestServer, *disruption.DisruptionController, informers.SharedInformerFactory, clientset.Interface, *apiextensionsclientset.Clientset, dynamic.Interface) {

t.Fatal(err) } } <ins> func TestPatchCompatibility(t *testing.T) { s, _, _, clientSet, _, _ := setup(t) defer s.TearDownFn() testcases := []struct { name             string version          string startingSelector *metav1.LabelSelector patchType        types.PatchType patch            string force            *bool fieldManager     string expectSelector   *metav1.LabelSelector }{ { name:      "v1beta1-smp", version:   "v1beta1", patchType: types.StrategicMergePatchType, patch:     `{"spec":{"selector":{"matchLabels":{"patchmatch":"true"},"matchExpressions":[{"key":"patchexpression","operator":"In","values":["true"]}]}}}`, // matchLabels portion is merged, matchExpressions portion is replaced (because it's a list with no patchStrategy defined) expectSelector: &metav1.LabelSelector{ MatchLabels:      map[string]string{"basematch": "true", "patchmatch": "true"}, MatchExpressions: []metav1.LabelSelectorRequirement{{Key: "patchexpression", Operator: "In", Values: []string{"true"}}}, }, }, { name:      "v1-smp", version:   "v1", patchType: types.StrategicMergePatchType, patch:     `{"spec":{"selector":{"matchLabels":{"patchmatch":"true"},"matchExpressions":[{"key":"patchexpression","operator":"In","values":["true"]}]}}}`, // matchLabels and matchExpressions are both replaced (because selector patchStrategy=replace in v1) expectSelector: &metav1.LabelSelector{ MatchLabels:      map[string]string{"patchmatch": "true"}, MatchExpressions: []metav1.LabelSelectorRequirement{{Key: "patchexpression", Operator: "In", Values: []string{"true"}}}, }, }, { name:      "v1beta1-mergepatch", version:   "v1beta1", patchType: types.MergePatchType, patch:     `{"spec":{"selector":{"matchLabels":{"patchmatch":"true"},"matchExpressions":[{"key":"patchexpression","operator":"In","values":["true"]}]}}}`, // matchLabels portion is merged, matchExpressions portion is replaced (because it's a list) expectSelector: &metav1.LabelSelector{ MatchLabels:      map[string]string{"basematch": "true", "patchmatch": "true"}, MatchExpressions: []metav1.LabelSelectorRequirement{{Key: "patchexpression", Operator: "In", Values: []string{"true"}}}, }, }, { name:      "v1-mergepatch", version:   "v1", patchType: types.MergePatchType, patch:     `{"spec":{"selector":{"matchLabels":{"patchmatch":"true"},"matchExpressions":[{"key":"patchexpression","operator":"In","values":["true"]}]}}}`, // matchLabels portion is merged, matchExpressions portion is replaced (because it's a list) expectSelector: &metav1.LabelSelector{ MatchLabels:      map[string]string{"basematch": "true", "patchmatch": "true"}, MatchExpressions: []metav1.LabelSelectorRequirement{{Key: "patchexpression", Operator: "In", Values: []string{"true"}}}, }, }, { name:         "v1beta1-apply", version:      "v1beta1", patchType:    types.ApplyPatchType, patch:        `{"apiVersion":"policy/v1beta1","kind":"PodDisruptionBudget","spec":{"selector":{"matchLabels":{"patchmatch":"true"},"matchExpressions":[{"key":"patchexpression","operator":"In","values":["true"]}]}}}`, force:        pointer.Bool(true), fieldManager: "test", // entire selector is replaced (because structType=atomic) expectSelector: &metav1.LabelSelector{ MatchLabels:      map[string]string{"patchmatch": "true"}, MatchExpressions: []metav1.LabelSelectorRequirement{{Key: "patchexpression", Operator: "In", Values: []string{"true"}}}, }, }, { name:         "v1-apply", version:      "v1", patchType:    types.ApplyPatchType, patch:        `{"apiVersion":"policy/v1","kind":"PodDisruptionBudget","spec":{"selector":{"matchLabels":{"patchmatch":"true"},"matchExpressions":[{"key":"patchexpression","operator":"In","values":["true"]}]}}}`, force:        pointer.Bool(true), fieldManager: "test", // entire selector is replaced (because structType=atomic) expectSelector: &metav1.LabelSelector{ MatchLabels:      map[string]string{"patchmatch": "true"}, MatchExpressions: []metav1.LabelSelectorRequirement{{Key: "patchexpression", Operator: "In", Values: []string{"true"}}}, }, }, } for _, tc := range testcases { t.Run(tc.name, func(t *testing.T) { ns := "default" maxUnavailable := int32(2) pdb := &policyv1.PodDisruptionBudget{ ObjectMeta: metav1.ObjectMeta{ Name: "test-pdb", }, Spec: policyv1.PodDisruptionBudgetSpec{ MaxUnavailable: &intstr.IntOrString{Type: intstr.Int, IntVal: maxUnavailable}, Selector: &metav1.LabelSelector{ MatchLabels:      map[string]string{"basematch": "true"}, MatchExpressions: []metav1.LabelSelectorRequirement{{Key: "baseexpression", Operator: "In", Values: []string{"true"}}}, }, }, } if _, err := clientSet.PolicyV1().PodDisruptionBudgets(ns).Create(context.TODO(), pdb, metav1.CreateOptions{}); err != nil { t.Fatalf("Error creating PodDisruptionBudget: %v", err) } defer func() { err := clientSet.PolicyV1().PodDisruptionBudgets(ns).Delete(context.TODO(), pdb.Name, metav1.DeleteOptions{}) if err != nil { t.Fatal(err) } }() var resultSelector *metav1.LabelSelector switch tc.version { case "v1": result, err := clientSet.PolicyV1().PodDisruptionBudgets(ns).Patch(context.TODO(), pdb.Name, tc.patchType, []byte(tc.patch), metav1.PatchOptions{Force: tc.force, FieldManager: tc.fieldManager}) if err != nil { t.Fatal(err) } resultSelector = result.Spec.Selector case "v1beta1": result, err := clientSet.PolicyV1beta1().PodDisruptionBudgets(ns).Patch(context.TODO(), pdb.Name, tc.patchType, []byte(tc.patch), metav1.PatchOptions{Force: tc.force, FieldManager: tc.fieldManager}) if err != nil { t.Fatal(err) } resultSelector = result.Spec.Selector default: t.Error("unknown version") } if !reflect.DeepEqual(resultSelector, tc.expectSelector) { t.Fatalf("unexpected selector:n%s", cmp.Diff(tc.expectSelector, resultSelector)) } }) } } </ins>