Skip to content

Commit 45ef19c

Browse files
authored
feat: add ability to ignore field in a resource by json path (#488)
* add ability to ignore field in a resource by json path * use custom Equal function * ignore field or backfill * fix unit test * linter * minor changes
1 parent 8a861e3 commit 45ef19c

File tree

6 files changed

+367
-6
lines changed

6 files changed

+367
-6
lines changed

go.mod

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,11 @@ require (
1919
github.com/cert-manager/cert-manager v1.16.2
2020
github.com/elastic/crd-ref-docs v0.1.0
2121
github.com/evanphx/json-patch v5.9.0+incompatible
22+
github.com/evanphx/json-patch/v5 v5.9.0
2223
github.com/fluxcd/flagger v1.38.0
2324
github.com/gin-gonic/gin v1.10.0
2425
github.com/go-logr/logr v1.4.2
26+
github.com/go-openapi/jsonpointer v0.21.0
2527
github.com/gobuffalo/flect v1.0.2
2628
github.com/gofrs/flock v0.12.1
2729
github.com/golangci/golangci-lint v1.63.4
@@ -37,7 +39,7 @@ require (
3739
github.com/opencost/opencost/core v0.0.0-20241216191657-30e5d9a27f41
3840
github.com/orcaman/concurrent-map/v2 v2.0.1
3941
github.com/pkg/errors v0.9.1
40-
github.com/pluralsh/console/go/client v1.46.4
42+
github.com/pluralsh/console/go/client v1.46.6
4143
github.com/pluralsh/controller-reconcile-helper v0.1.0
4244
github.com/pluralsh/gophoenix v0.1.3-0.20231201014135-dff1b4309e34
4345
github.com/pluralsh/polly v0.3.3
@@ -214,7 +216,6 @@ require (
214216
github.com/envoyproxy/go-control-plane/envoy v1.32.4 // indirect
215217
github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect
216218
github.com/ettle/strcase v0.2.0 // indirect
217-
github.com/evanphx/json-patch/v5 v5.9.0 // indirect
218219
github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f // indirect
219220
github.com/fatih/camelcase v1.0.0 // indirect
220221
github.com/fatih/color v1.18.0 // indirect
@@ -237,7 +238,6 @@ require (
237238
github.com/go-logr/stdr v1.2.2 // indirect
238239
github.com/go-logr/zapr v1.3.0 // indirect
239240
github.com/go-ole/go-ole v1.3.0 // indirect
240-
github.com/go-openapi/jsonpointer v0.21.0 // indirect
241241
github.com/go-openapi/jsonreference v0.21.0 // indirect
242242
github.com/go-openapi/swag v0.23.0 // indirect
243243
github.com/go-playground/locales v0.14.1 // indirect

go.sum

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1802,8 +1802,8 @@ github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZ
18021802
github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qRg=
18031803
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo=
18041804
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=
1805-
github.com/pluralsh/console/go/client v1.46.4 h1:p9RXX5p0WQw4Zi545HVHyadXFy0trih1Y34otJvAblg=
1806-
github.com/pluralsh/console/go/client v1.46.4/go.mod h1:8XlMMN3LLAN9JZo69f8X/XN7Qt1+aaKpgTvvQGfSiEU=
1805+
github.com/pluralsh/console/go/client v1.46.6 h1:G1JRXqz3uVoHdioFXsrpvVMJgxBCxVjncszO1eC9Xag=
1806+
github.com/pluralsh/console/go/client v1.46.6/go.mod h1:8XlMMN3LLAN9JZo69f8X/XN7Qt1+aaKpgTvvQGfSiEU=
18071807
github.com/pluralsh/controller-reconcile-helper v0.1.0 h1:BV3dYZFH5rn8ZvZjtpkACSv/GmLEtRftNQj/Y4ddHEo=
18081808
github.com/pluralsh/controller-reconcile-helper v0.1.0/go.mod h1:RxAbvSB4/jkvx616krCdNQXPbpGJXW3J1L3rASxeFOA=
18091809
github.com/pluralsh/gophoenix v0.1.3-0.20231201014135-dff1b4309e34 h1:ab2PN+6if/Aq3/sJM0AVdy1SYuMAnq4g20VaKhTm/Bw=
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
package service
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"math"
8+
"reflect"
9+
10+
jsonpatch "github.com/evanphx/json-patch/v5"
11+
"github.com/go-openapi/jsonpointer"
12+
apierrors "k8s.io/apimachinery/pkg/api/errors"
13+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
14+
"k8s.io/apimachinery/pkg/types"
15+
"sigs.k8s.io/controller-runtime/pkg/client"
16+
)
17+
18+
func IgnoreJSONPaths(obj unstructured.Unstructured, ignorePaths []string) (unstructured.Unstructured, error) {
19+
ops := make([]map[string]string, 0)
20+
for _, path := range ignorePaths {
21+
ops = append(ops, map[string]string{
22+
"op": "remove",
23+
"path": path,
24+
})
25+
}
26+
27+
patchJSON, err := json.Marshal(ops)
28+
if err != nil {
29+
return obj, fmt.Errorf("failed to marshal json patch representation: %w", err)
30+
}
31+
32+
raw, err := json.Marshal(obj.Object)
33+
if err != nil {
34+
return obj, fmt.Errorf("failed to marshal base kubernetes object for patch: %w", err)
35+
}
36+
37+
patch, err := jsonpatch.DecodePatch(patchJSON)
38+
if err != nil {
39+
return obj, fmt.Errorf("failed to decode json patch object: %w", err)
40+
}
41+
42+
modified, err := patch.Apply(raw)
43+
if err != nil {
44+
return obj, fmt.Errorf("failed to apply patch: %w", err)
45+
}
46+
47+
var result map[string]interface{}
48+
if err := json.Unmarshal(modified, &result); err != nil {
49+
return obj, fmt.Errorf("failed to unmarshal patched object: %w", err)
50+
}
51+
52+
obj.Object = result
53+
return obj, nil
54+
}
55+
56+
func BackFillJSONPaths(ctx context.Context, c client.Client, obj unstructured.Unstructured, ignorePaths []string) (unstructured.Unstructured, error) {
57+
current := &unstructured.Unstructured{}
58+
current.SetGroupVersionKind(obj.GroupVersionKind())
59+
60+
key := types.NamespacedName{
61+
Name: obj.GetName(),
62+
Namespace: obj.GetNamespace(),
63+
}
64+
65+
if err := c.Get(ctx, key, current); err != nil {
66+
if apierrors.IsNotFound(err) {
67+
return obj, nil // Object doesn't exist, skip
68+
}
69+
return unstructured.Unstructured{}, fmt.Errorf("failed to get live object: %w", err)
70+
}
71+
72+
// Compare and overwrite ignored paths
73+
for _, path := range ignorePaths {
74+
ptr, err := jsonpointer.New(path)
75+
if err != nil {
76+
return unstructured.Unstructured{}, fmt.Errorf("invalid JSON pointer %q: %w", path, err)
77+
}
78+
79+
liveVal, _, err := ptr.Get(current.Object)
80+
if err != nil {
81+
continue // Ignore missing path
82+
}
83+
84+
desiredVal, _, err := ptr.Get(obj.Object)
85+
if err != nil || !flexEqual(liveVal, desiredVal) {
86+
// Set the desired object's field to the live value
87+
_, err = ptr.Set(obj.Object, liveVal)
88+
if err != nil {
89+
return unstructured.Unstructured{}, fmt.Errorf("failed to set path %s: %w", path, err)
90+
}
91+
}
92+
}
93+
94+
return obj, nil
95+
}
96+
97+
type normalizerKey struct {
98+
Kind string
99+
Name string
100+
Namespace string
101+
BackFill bool
102+
}
103+
104+
func matchesKey(obj unstructured.Unstructured, key normalizerKey) (bool, bool) {
105+
if key.Kind != "" && obj.GetKind() != key.Kind {
106+
return false, key.BackFill
107+
}
108+
if key.Name != "" && obj.GetName() != key.Name {
109+
return false, key.BackFill
110+
}
111+
if key.Namespace != "" && obj.GetNamespace() != key.Namespace {
112+
return false, key.BackFill
113+
}
114+
return true, key.BackFill
115+
}
116+
117+
// flexEqual compares two values with special handling for numeric types.
118+
// This is necessary because JSON unmarshalling (used in Unstructured objects)
119+
// treats all numbers as float64 by default, while Kubernetes API types (like int32/int64)
120+
// retain their original integer type when fetched using controller-runtime's client.
121+
//
122+
// For example:
123+
// - A value like `replicas: 1` from YAML becomes float64(1.0)
124+
// - The same field from the Kubernetes API may be int64(1) or int32(1)
125+
// - reflect.DeepEqual would return false even though they represent the same number
126+
//
127+
// This function ensures such semantically equal numeric values are treated as equal.
128+
func flexEqual(a, b interface{}) bool {
129+
// Compare numeric types with type coercion
130+
if isNumber(a) && isNumber(b) {
131+
af := toFloat64(a)
132+
bf := toFloat64(b)
133+
return math.Abs(af-bf) < 1e-9 // tolerate float precision errors
134+
}
135+
136+
// Fall back to deep equal
137+
return reflect.DeepEqual(a, b)
138+
}
139+
140+
func isNumber(v interface{}) bool {
141+
switch v.(type) {
142+
case int, int8, int16, int32, int64,
143+
uint, uint8, uint16, uint32, uint64,
144+
float32, float64:
145+
return true
146+
default:
147+
return false
148+
}
149+
}
150+
151+
func toFloat64(v interface{}) float64 {
152+
switch n := v.(type) {
153+
case int:
154+
return float64(n)
155+
case int8:
156+
return float64(n)
157+
case int16:
158+
return float64(n)
159+
case int32:
160+
return float64(n)
161+
case int64:
162+
return float64(n)
163+
case uint:
164+
return float64(n)
165+
case uint8:
166+
return float64(n)
167+
case uint16:
168+
return float64(n)
169+
case uint32:
170+
return float64(n)
171+
case uint64:
172+
return float64(n)
173+
case float32:
174+
return float64(n)
175+
case float64:
176+
return n
177+
default:
178+
return math.NaN()
179+
}
180+
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
package service_test
2+
3+
import (
4+
"context"
5+
6+
"github.com/pluralsh/deployment-operator/pkg/controller/service"
7+
corev1 "k8s.io/api/core/v1"
8+
"k8s.io/apimachinery/pkg/api/errors"
9+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
10+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
11+
"k8s.io/apimachinery/pkg/runtime"
12+
"k8s.io/apimachinery/pkg/types"
13+
"sigs.k8s.io/yaml"
14+
15+
. "github.com/onsi/ginkgo/v2"
16+
. "github.com/onsi/gomega"
17+
)
18+
19+
var _ = Describe("DiffNormalizers", Ordered, func() {
20+
Context("BackFillJSONPaths", func() {
21+
const (
22+
resourceName = "default-ignore-json-paths"
23+
namespace = "default"
24+
)
25+
desiredPodYAML := `
26+
apiVersion: v1
27+
kind: Pod
28+
metadata:
29+
name: default-ignore-json-paths
30+
namespace: default
31+
spec:
32+
containers:
33+
- name: app
34+
image: nginx:latest
35+
`
36+
ctx := context.Background()
37+
38+
typeNamespacedName := types.NamespacedName{
39+
Name: resourceName,
40+
Namespace: "default",
41+
}
42+
43+
var desiredPod corev1.Pod
44+
Expect(yaml.Unmarshal([]byte(desiredPodYAML), &desiredPod)).To(Succeed())
45+
desiredObj, err := runtime.DefaultUnstructuredConverter.ToUnstructured(&desiredPod)
46+
Expect(err).ToNot(HaveOccurred())
47+
desired := unstructured.Unstructured{Object: desiredObj}
48+
49+
livePod := &corev1.Pod{}
50+
51+
BeforeAll(func() {
52+
By("creating the custom resource for the Kind Pod")
53+
err := kClient.Get(ctx, typeNamespacedName, livePod)
54+
if err != nil && errors.IsNotFound(err) {
55+
resource := &corev1.Pod{
56+
ObjectMeta: metav1.ObjectMeta{
57+
Name: resourceName,
58+
Namespace: namespace,
59+
},
60+
Spec: corev1.PodSpec{
61+
Containers: []corev1.Container{
62+
{
63+
Name: "app",
64+
Image: "nginx:1.25",
65+
},
66+
},
67+
},
68+
}
69+
Expect(kClient.Create(ctx, resource)).To(Succeed())
70+
}
71+
})
72+
73+
AfterAll(func() {
74+
resource := &corev1.Pod{}
75+
err := kClient.Get(ctx, typeNamespacedName, resource)
76+
Expect(err).NotTo(HaveOccurred())
77+
78+
By("Cleanup the specific resource instance Pod")
79+
Expect(kClient.Delete(ctx, resource)).To(Succeed())
80+
})
81+
82+
It("should back fill image", func() {
83+
ignorePaths := []string{"/spec/containers/0/image"}
84+
desired, err := service.BackFillJSONPaths(context.Background(), kClient, desired, ignorePaths)
85+
Expect(err).ToNot(HaveOccurred())
86+
87+
expectedPod := &corev1.Pod{}
88+
err = runtime.DefaultUnstructuredConverter.FromUnstructuredWithValidation(desired.Object, expectedPod, false)
89+
Expect(err).ToNot(HaveOccurred())
90+
Expect(expectedPod.Spec.Containers[0].Image).To(Equal("nginx:1.25"))
91+
92+
})
93+
})
94+
})

0 commit comments

Comments
 (0)