kubectl apply: fix --dry-run=client to show merged result

When a resource exists on the server, kubectl apply --dry-run=client
was outputting the unchanged server state instead of showing what
would result from applying the manifest.

Fix by computing the three-way merge patch (same as real apply) and
then applying it locally to the current server state.

Kubernetes-commit: aea05ad180edaffbb1f09b41b62d452779ed1da1
This commit is contained in:
Manuel Grandeit 2025-11-29 20:15:36 +01:00 committed by Kubernetes Publisher
parent 451021fde4
commit c9b8fdf5d8
3 changed files with 131 additions and 28 deletions

View file

@ -725,36 +725,43 @@ See https://kubernetes.io/docs/reference/using-api/server-side-apply/#conflicts`
return err
}
if o.DryRunStrategy != cmdutil.DryRunClient {
metadata, _ := meta.Accessor(info.Object)
annotationMap := metadata.GetAnnotations()
if _, ok := annotationMap[corev1.LastAppliedConfigAnnotation]; !ok {
fmt.Fprintf(o.ErrOut, warningNoLastAppliedConfigAnnotation, info.ObjectName(), corev1.LastAppliedConfigAnnotation, o.cmdBaseName)
}
metadata, _ := meta.Accessor(info.Object)
annotationMap := metadata.GetAnnotations()
if _, ok := annotationMap[corev1.LastAppliedConfigAnnotation]; !ok {
fmt.Fprintf(o.ErrOut, warningNoLastAppliedConfigAnnotation, info.ObjectName(), corev1.LastAppliedConfigAnnotation, o.cmdBaseName) //nolint:errcheck
}
patcher, err := newPatcher(o, info, helper)
patcher, err := newPatcher(o, info, helper)
if err != nil {
return err
}
var patchBytes []byte
var patchedObject runtime.Object
if o.DryRunStrategy != cmdutil.DryRunClient {
patchBytes, patchedObject, err = patcher.Patch(info.Object, modified, info.Source, info.Namespace, info.Name, o.ErrOut)
} else {
patchBytes, patchedObject, err = patcher.PatchLocal(info.Object, modified, o.ErrOut)
}
if err != nil {
return cmdutil.AddSourceToErr(fmt.Sprintf("applying patch:\n%s\nto:\n%v\nfor:", patchBytes, info), info.Source, err)
}
info.Refresh(patchedObject, true) //nolint:errcheck
WarnIfDeleting(info.Object, o.ErrOut)
if string(patchBytes) == "{}" && !o.shouldPrintObject() {
printer, err := o.ToPrinter("unchanged")
if err != nil {
return err
}
patchBytes, patchedObject, err := patcher.Patch(info.Object, modified, info.Source, info.Namespace, info.Name, o.ErrOut)
if err != nil {
return cmdutil.AddSourceToErr(fmt.Sprintf("applying patch:\n%s\nto:\n%v\nfor:", patchBytes, info), info.Source, err)
}
info.Refresh(patchedObject, true)
WarnIfDeleting(info.Object, o.ErrOut)
if string(patchBytes) == "{}" && !o.shouldPrintObject() {
printer, err := o.ToPrinter("unchanged")
if err != nil {
return err
}
if err = printer.PrintObj(info.Object, o.Out); err != nil {
return err
}
return nil
if err = printer.PrintObj(info.Object, o.Out); err != nil {
return err
}
return nil
}
if o.shouldPrintObject() {

View file

@ -2204,6 +2204,77 @@ func TestDontAllowForceApplyWithServerSide(t *testing.T) {
t.Fatalf(`expected error "%s"`, expectedError)
}
func TestApplyDryRunClientMergesWithServerState(t *testing.T) {
// This test verifies that --dry-run=client performs a proper three-way merge:
// - Values from the manifest should overwrite server values
// - Server-only values (not in manifest) should be preserved
//
// Server state: port=9999, clusterIP=10.0.0.42
// Last applied: port=9999 (no clusterIP - it's server-assigned)
// New manifest: port=80 (no clusterIP)
//
// Expected result: port=80 (from manifest), clusterIP=10.0.0.42 (preserved from server)
cmdtesting.InitTestErrorHandler(t)
lastApplied := `{"apiVersion":"v1","kind":"Service","metadata":{"name":"test-service","namespace":"test"},"spec":{"ports":[{"port":9999,"protocol":"TCP"}]}}`
serverState := &unstructured.Unstructured{
Object: map[string]any{
"apiVersion": "v1",
"kind": "Service",
"metadata": map[string]any{
"name": "test-service",
"namespace": "test",
"annotations": map[string]any{
corev1.LastAppliedConfigAnnotation: lastApplied,
},
},
"spec": map[string]any{
"ports": []any{map[string]any{"port": int64(9999), "protocol": "TCP"}},
"clusterIP": "10.0.0.42",
},
},
}
serverStateBytes, err := runtime.Encode(unstructured.UnstructuredJSONScheme, serverState)
require.NoError(t, err)
tf := cmdtesting.NewTestFactory().WithNamespace("test")
defer tf.Cleanup()
tf.UnstructuredClient = &fake.RESTClient{
NegotiatedSerializer: resource.UnstructuredPlusDefaultContentConfig().NegotiatedSerializer,
Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) {
if req.Method == http.MethodGet && req.URL.Path == "/namespaces/test/services/test-service" {
return &http.Response{StatusCode: http.StatusOK, Header: cmdtesting.DefaultHeader(), Body: io.NopCloser(bytes.NewReader(serverStateBytes))}, nil
}
t.Fatalf("unexpected request: %s %s", req.Method, req.URL.Path)
return nil, nil
}),
}
tf.ClientConfigVal = cmdtesting.DefaultClientConfig()
ioStreams, _, outBuf, errBuf := genericiooptions.NewTestIOStreams()
cmd := NewCmdApply("kubectl", tf, ioStreams)
require.NoError(t, cmd.Flags().Set("filename", filenameSVC))
require.NoError(t, cmd.Flags().Set("dry-run", "client"))
require.NoError(t, cmd.Flags().Set("output", "json"))
cmd.Run(cmd, []string{})
require.Empty(t, errBuf.String())
result := &unstructured.Unstructured{}
require.NoError(t, result.UnmarshalJSON(outBuf.Bytes()))
ports, _, _ := unstructured.NestedSlice(result.Object, "spec", "ports")
require.Len(t, ports, 1)
port, _, _ := unstructured.NestedInt64(ports[0].(map[string]any), "port")
assert.Equal(t, int64(80), port, "port should come from manifest (was 9999 on server)")
clusterIP, found, _ := unstructured.NestedString(result.Object, "spec", "clusterIP")
assert.True(t, found, "clusterIP should be preserved from server")
assert.Equal(t, "10.0.0.42", clusterIP)
}
func TestDontAllowApplyWithPodGeneratedName(t *testing.T) {
expectedError := "error: from testing-: cannot use generate name with apply"
cmdutil.BehaviorOnFatal(func(str string, code int) {

View file

@ -24,6 +24,7 @@ import (
"time"
"github.com/jonboulle/clockwork"
jsonpatch "gopkg.in/evanphx/json-patch.v4"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@ -114,7 +115,7 @@ func (p *Patcher) delete(namespace, name string) error {
return err
}
func (p *Patcher) patchSimple(obj runtime.Object, modified []byte, namespace, name string, errOut io.Writer) ([]byte, runtime.Object, error) {
func (p *Patcher) patchSimple(obj runtime.Object, modified []byte, namespace, name string, errOut io.Writer, localApply bool) ([]byte, runtime.Object, error) {
// Serialize the current configuration of the object from the server.
current, err := runtime.Encode(unstructured.UnstructuredJSONScheme, obj)
if err != nil {
@ -195,6 +196,24 @@ func (p *Patcher) patchSimple(obj runtime.Object, modified []byte, namespace, na
return patch, obj, nil
}
if localApply {
var patchedBytes []byte
if patchType == types.StrategicMergePatchType {
versionedObj, _ := scheme.Scheme.New(p.Mapping.GroupVersionKind)
patchedBytes, err = strategicpatch.StrategicMergePatch(current, patch, versionedObj)
} else {
patchedBytes, err = jsonpatch.MergePatch(current, patch)
}
if err != nil {
return nil, nil, fmt.Errorf("applying patch locally: %w", err)
}
patchedObj, _, err := unstructured.UnstructuredJSONScheme.Decode(patchedBytes, nil, nil)
if err != nil {
return nil, nil, fmt.Errorf("decoding locally patched object: %w", err)
}
return patch, patchedObj, nil
}
if p.ResourceVersion != nil {
patch, err = addResourceVersion(patch, *p.ResourceVersion)
if err != nil {
@ -357,7 +376,7 @@ func (p *Patcher) buildStrategicMergeFromBuiltins(versionedObj runtime.Object, o
// the final patched object. On failure, returns an error.
func (p *Patcher) Patch(current runtime.Object, modified []byte, source, namespace, name string, errOut io.Writer) ([]byte, runtime.Object, error) {
var getErr error
patchBytes, patchObject, err := p.patchSimple(current, modified, namespace, name, errOut)
patchBytes, patchObject, err := p.patchSimple(current, modified, namespace, name, errOut, false)
if p.Retries == 0 {
p.Retries = maxPatchRetry
}
@ -369,7 +388,7 @@ func (p *Patcher) Patch(current runtime.Object, modified []byte, source, namespa
if getErr != nil {
return nil, nil, getErr
}
patchBytes, patchObject, err = p.patchSimple(current, modified, namespace, name, errOut)
patchBytes, patchObject, err = p.patchSimple(current, modified, namespace, name, errOut, false)
}
if err != nil {
if (apierrors.IsConflict(err) || apierrors.IsInvalid(err)) && p.Force {
@ -381,6 +400,12 @@ func (p *Patcher) Patch(current runtime.Object, modified []byte, source, namespa
return patchBytes, patchObject, err
}
// PatchLocal computes and applies the patch locally without sending to the server.
// Used for --dry-run=client.
func (p *Patcher) PatchLocal(current runtime.Object, modified []byte, errOut io.Writer) ([]byte, runtime.Object, error) {
return p.patchSimple(current, modified, "", "", errOut, true)
}
func (p *Patcher) deleteAndCreate(original runtime.Object, modified []byte, namespace, name string) ([]byte, runtime.Object, error) {
if err := p.delete(namespace, name); err != nil {
return modified, nil, err