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

propagate explicit nulls in apply #40630

Merged
merged 3 commits into from
Feb 1, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
38 changes: 38 additions & 0 deletions hack/make-rules/test-cmd-util.sh
Original file line number Diff line number Diff line change
Expand Up @@ -917,6 +917,40 @@ run_kubectl_create_filter_tests() {
kubectl delete pods selector-test-pod
}

run_kubectl_apply_deployments_tests() {
## kubectl apply should propagate user defined null values
# Pre-Condition: no Deployments, ReplicaSets, Pods exist
kube::test::get_object_assert deployments "{{range.items}}{{$id_field}}:{{end}}" ''
kube::test::get_object_assert replicasets "{{range.items}}{{$id_field}}:{{end}}" ''
kube::test::get_object_assert pods "{{range.items}}{{$id_field}}:{{end}}" ''
# apply base deployment
kubectl apply -f hack/testdata/null-propagation/deployment-l1.yaml "${kube_flags[@]}"
# check right deployment exists
kube::test::get_object_assert 'deployments my-depl' "{{${id_field}}}" 'my-depl'
# check right labels exists
kube::test::get_object_assert 'deployments my-depl' "{{.spec.template.metadata.labels.l1}}" 'l1'
kube::test::get_object_assert 'deployments my-depl' "{{.spec.selector.matchLabels.l1}}" 'l1'
kube::test::get_object_assert 'deployments my-depl' "{{.metadata.labels.l1}}" 'l1'

# apply new deployment with new template labels
kubectl apply -f hack/testdata/null-propagation/deployment-l2.yaml "${kube_flags[@]}"
# check right labels exists
kube::test::get_object_assert 'deployments my-depl' "{{.spec.template.metadata.labels.l1}}" '<no value>'
kube::test::get_object_assert 'deployments my-depl' "{{.spec.selector.matchLabels.l1}}" '<no value>'
kube::test::get_object_assert 'deployments my-depl' "{{.metadata.labels.l1}}" '<no value>'
kube::test::get_object_assert 'deployments my-depl' "{{.spec.template.metadata.labels.l2}}" 'l2'
kube::test::get_object_assert 'deployments my-depl' "{{.spec.selector.matchLabels.l2}}" 'l2'
kube::test::get_object_assert 'deployments my-depl' "{{.metadata.labels.l2}}" 'l2'

# cleanup
# need to explicitly remove replicasets and pods because we changed the deployment selector and orphaned things
kubectl delete deployments,rs,pods --all --cascade=false --grace-period=0
# Post-Condition: no Deployments, ReplicaSets, Pods exist
kube::test::get_object_assert deployments "{{range.items}}{{$id_field}}:{{end}}" ''
kube::test::get_object_assert replicasets "{{range.items}}{{$id_field}}:{{end}}" ''
kube::test::get_object_assert pods "{{range.items}}{{$id_field}}:{{end}}" ''
}

# Runs tests for --save-config tests.
run_save_config_tests() {
## Configuration annotations should be set when --save-config is enabled
Expand Down Expand Up @@ -2718,6 +2752,10 @@ runTests() {
run_kubectl_create_filter_tests
fi

if kube::test::if_supports_resource "${deployments}" ; then
run_kubectl_apply_deployments_tests
fi

###############
# Kubectl get #
###############
Expand Down
13 changes: 13 additions & 0 deletions hack/testdata/null-propagation/deployment-l1.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
apiVersion: extensions/v1beta1
kind: Deployment
metadata:
name: my-depl
spec:
template:
metadata:
labels:
l1: l1
spec:
containers:
- name: nginx
image: gcr.io/google-containers/nginx:1.7.9
17 changes: 17 additions & 0 deletions hack/testdata/null-propagation/deployment-l2.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
apiVersion: extensions/v1beta1
kind: Deployment
metadata:
name: my-depl
# We expect this field to be defaulted to the new label l2
labels: null
spec:
# We expect this field to be defaulted to the new label l2
selector: null
template:
metadata:
labels:
l2: l2
spec:
containers:
- name: nginx
image: gcr.io/google-containers/nginx:1.7.9
88 changes: 88 additions & 0 deletions pkg/kubectl/cmd/apply_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import (
"k8s.io/kubernetes/pkg/api"
"k8s.io/kubernetes/pkg/api/annotations"
"k8s.io/kubernetes/pkg/api/testapi"
"k8s.io/kubernetes/pkg/apis/extensions"
cmdtesting "k8s.io/kubernetes/pkg/kubectl/cmd/testing"
cmdutil "k8s.io/kubernetes/pkg/kubectl/cmd/util"
)
Expand Down Expand Up @@ -443,3 +444,90 @@ func testApplyMultipleObjects(t *testing.T, asList bool) {
t.Fatalf("unexpected output: %s\nexpected: %s OR %s", buf.String(), expectOne, expectTwo)
}
}

const (
filenameDeployObjServerside = "../../../test/fixtures/pkg/kubectl/cmd/apply/deploy-serverside.yaml"
filenameDeployObjClientside = "../../../test/fixtures/pkg/kubectl/cmd/apply/deploy-clientside.yaml"
)

func readDeploymentFromFile(t *testing.T, file string) []byte {
raw := readBytesFromFile(t, file)
obj := &extensions.Deployment{}
if err := runtime.DecodeInto(testapi.Extensions.Codec(), raw, obj); err != nil {
t.Fatal(err)
}
objJSON, err := runtime.Encode(testapi.Extensions.Codec(), obj)
if err != nil {
t.Fatal(err)
}
return objJSON
}

func TestApplyNULLPreservation(t *testing.T) {
initTestErrorHandler(t)
deploymentName := "nginx-deployment"
deploymentPath := "/namespaces/test/deployments/" + deploymentName

verifiedPatch := false
deploymentBytes := readDeploymentFromFile(t, filenameDeployObjServerside)

f, tf, _, _ := cmdtesting.NewTestFactory()
tf.UnstructuredClient = &fake.RESTClient{
APIRegistry: api.Registry,
NegotiatedSerializer: unstructuredSerializer,
Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) {
switch p, m := req.URL.Path, req.Method; {
case p == deploymentPath && m == "GET":
body := ioutil.NopCloser(bytes.NewReader(deploymentBytes))
return &http.Response{StatusCode: 200, Header: defaultHeader(), Body: body}, nil
case p == deploymentPath && m == "PATCH":
patch, err := ioutil.ReadAll(req.Body)
if err != nil {
t.Fatal(err)
}

patchMap := map[string]interface{}{}
if err := json.Unmarshal(patch, &patchMap); err != nil {
t.Fatal(err)
}
annotationMap := walkMapPath(t, patchMap, []string{"metadata", "annotations"})
if _, ok := annotationMap[annotations.LastAppliedConfigAnnotation]; !ok {
t.Fatalf("patch does not contain annotation:\n%s\n", patch)
}
strategy := walkMapPath(t, patchMap, []string{"spec", "strategy"})
if value, ok := strategy["rollingUpdate"]; !ok || value != nil {
t.Fatalf("patch did not retain null value in key: rollingUpdate:\n%s\n", patch)
}
verifiedPatch = true

// The real API server would had returned the patched object but Kubectl
// is ignoring the actual return object.
// TODO: Make this match actual server behavior by returning the patched object.
body := ioutil.NopCloser(bytes.NewReader(deploymentBytes))
return &http.Response{StatusCode: 200, Header: defaultHeader(), Body: body}, nil
default:
t.Fatalf("unexpected request: %#v\n%#v", req.URL, req)
return nil, nil
}
}),
}
tf.Namespace = "test"
tf.ClientConfig = defaultClientConfig()
buf := bytes.NewBuffer([]byte{})
errBuf := bytes.NewBuffer([]byte{})

cmd := NewCmdApply(f, buf, errBuf)
cmd.Flags().Set("filename", filenameDeployObjClientside)
cmd.Flags().Set("output", "name")

cmd.Run(cmd, []string{})

expected := "deployment/" + deploymentName + "\n"
if buf.String() != expected {
t.Fatalf("unexpected output: %s\nexpected: %s", buf.String(), expected)
}

if !verifiedPatch {
t.Fatal("No server-side patch call detected")
}
}
14 changes: 14 additions & 0 deletions pkg/kubectl/cmd/testing/fake.go
Original file line number Diff line number Diff line change
Expand Up @@ -669,5 +669,19 @@ func testDynamicResources() []*discovery.APIGroupResources {
},
},
},
{
Group: metav1.APIGroup{
Name: "extensions",
Versions: []metav1.GroupVersionForDiscovery{
{Version: "v1beta1"},
},
PreferredVersion: metav1.GroupVersionForDiscovery{Version: "v1beta1"},
},
VersionedResources: map[string][]metav1.APIResource{
"v1beta1": {
{Name: "deployments", Namespaced: true, Kind: "Deployment"},
},
},
},
}
}
28 changes: 18 additions & 10 deletions staging/src/k8s.io/apimachinery/pkg/util/strategicpatch/patch.go
Original file line number Diff line number Diff line change
Expand Up @@ -548,7 +548,7 @@ func StrategicMergeMapPatch(original, patch JSONMap, dataStruct interface{}) (JS
if err != nil {
return nil, err
}
return mergeMap(original, patch, t, true)
return mergeMap(original, patch, t, true, true)
}

func getTagStructType(dataStruct interface{}) (reflect.Type, error) {
Expand All @@ -574,7 +574,10 @@ var errBadPatchTypeFmt = "unknown patch type: %s in map: %v"
// both the original map and the patch because getting a deep copy of a map in
// golang is highly non-trivial.
// flag mergeDeleteList controls if using the parallel list to delete or keeping the list.
func mergeMap(original, patch map[string]interface{}, t reflect.Type, mergeDeleteList bool) (map[string]interface{}, error) {
// If patch contains any null field (e.g. field_1: null) that is not
// present in original, then to propagate it to the end result use
// ignoreUnmatchedNulls == false.
func mergeMap(original, patch map[string]interface{}, t reflect.Type, mergeDeleteList, ignoreUnmatchedNulls bool) (map[string]interface{}, error) {
if v, ok := patch[directiveMarker]; ok {
if v == replaceDirective {
// If the patch contains "$patch: replace", don't merge it, just use the
Expand Down Expand Up @@ -619,13 +622,17 @@ func mergeMap(original, patch map[string]interface{}, t reflect.Type, mergeDelet
}

// If the value of this key is null, delete the key if it exists in the
// original. Otherwise, skip it.
// original. Otherwise, check if we want to preserve it or skip it.
// Preserving the null value is useful when we want to send an explicit
// delete to the API server.
if patchV == nil {
if _, ok := original[k]; ok {
delete(original, k)
}

continue
if ignoreUnmatchedNulls {
continue
}
}

_, ok := original[k]
Expand Down Expand Up @@ -654,7 +661,7 @@ func mergeMap(original, patch map[string]interface{}, t reflect.Type, mergeDelet
typedOriginal := original[k].(map[string]interface{})
typedPatch := patchV.(map[string]interface{})
var err error
original[k], err = mergeMap(typedOriginal, typedPatch, fieldType, mergeDeleteList)
original[k], err = mergeMap(typedOriginal, typedPatch, fieldType, mergeDeleteList, ignoreUnmatchedNulls)
if err != nil {
return nil, err
}
Expand All @@ -667,7 +674,7 @@ func mergeMap(original, patch map[string]interface{}, t reflect.Type, mergeDelet
typedOriginal := original[k].([]interface{})
typedPatch := patchV.([]interface{})
var err error
original[k], err = mergeSlice(typedOriginal, typedPatch, elemType, fieldPatchMergeKey, mergeDeleteList, isDeleteList)
original[k], err = mergeSlice(typedOriginal, typedPatch, elemType, fieldPatchMergeKey, mergeDeleteList, isDeleteList, ignoreUnmatchedNulls)
if err != nil {
return nil, err
}
Expand All @@ -688,7 +695,7 @@ func mergeMap(original, patch map[string]interface{}, t reflect.Type, mergeDelet
// Merge two slices together. Note: This may modify both the original slice and
// the patch because getting a deep copy of a slice in golang is highly
// non-trivial.
func mergeSlice(original, patch []interface{}, elemType reflect.Type, mergeKey string, mergeDeleteList, isDeleteList bool) ([]interface{}, error) {
func mergeSlice(original, patch []interface{}, elemType reflect.Type, mergeKey string, mergeDeleteList, isDeleteList, ignoreUnmatchedNulls bool) ([]interface{}, error) {
if len(original) == 0 && len(patch) == 0 {
return original, nil
}
Expand Down Expand Up @@ -779,7 +786,7 @@ func mergeSlice(original, patch []interface{}, elemType reflect.Type, mergeKey s
var mergedMaps interface{}
var err error
// Merge into original.
mergedMaps, err = mergeMap(originalMap, typedV, elemType, mergeDeleteList)
mergedMaps, err = mergeMap(originalMap, typedV, elemType, mergeDeleteList, ignoreUnmatchedNulls)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -1282,7 +1289,8 @@ func mapsOfMapsHaveConflicts(typedLeft, typedRight map[string]interface{}, struc
// configurations. Conflicts are defined as keys changed differently from original to modified
// than from original to current. In other words, a conflict occurs if modified changes any key
// in a way that is different from how it is changed in current (e.g., deleting it, changing its
// value).
// value). We also propagate values fields that do not exist in original but are explicitly
// defined in modified.
func CreateThreeWayMergePatch(original, modified, current []byte, dataStruct interface{}, overwrite bool, fns ...PreconditionFunc) ([]byte, error) {
originalMap := map[string]interface{}{}
if len(original) > 0 {
Expand Down Expand Up @@ -1324,7 +1332,7 @@ func CreateThreeWayMergePatch(original, modified, current []byte, dataStruct int
return nil, err
}

patchMap, err := mergeMap(deletionsMap, deltaMap, t, false)
patchMap, err := mergeMap(deletionsMap, deltaMap, t, false, false)
if err != nil {
return nil, err
}
Expand Down