mirror of
https://github.com/kubernetes/kubectl.git
synced 2026-04-28 01:28:48 -04:00
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:
parent
451021fde4
commit
c9b8fdf5d8
3 changed files with 131 additions and 28 deletions
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue