Skip to content

Commit 98ccd3d

Browse files
authored
fix: calculate SSA diffs with smd.merge.Updater (#467)
* fix: refactor ssa diff logic Signed-off-by: Leonardo Luz Almeida <[email protected]> * fix: calculate ssa diff with smd.merge.Updater Signed-off-by: Leonardo Luz Almeida <[email protected]> * chore: Add golangci config file Signed-off-by: Leonardo Luz Almeida <[email protected]> * fix: remove wrong param passed to golanci-ghaction Signed-off-by: Leonardo Luz Almeida <[email protected]> * doc: Add doc to the wrapper file Signed-off-by: Leonardo Luz Almeida <[email protected]> * doc: Add instructions about how to extract the openapiv2 document from k8s Signed-off-by: Leonardo Luz Almeida <[email protected]> * better wording Signed-off-by: Leonardo Luz Almeida <[email protected]> * better code comments Signed-off-by: Leonardo Luz Almeida <[email protected]> Signed-off-by: Leonardo Luz Almeida <[email protected]>
1 parent 3951079 commit 98ccd3d

17 files changed

+73856
-73
lines changed

.github/workflows/ci.yaml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,6 @@ jobs:
3131
with:
3232
version: v1.38.0
3333
args: --timeout 5m
34-
skip-go-installation: true
3534
- uses: codecov/[email protected]
3635
with:
3736
token: ${{ secrets.CODECOV_TOKEN }} #required

.golangci.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
run:
2+
skip-files:
3+
- "pkg/diff/internal/fieldmanager/borrowed_.+\\.go$"

pkg/diff/diff.go

Lines changed: 85 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -13,16 +13,19 @@ import (
1313

1414
jsonpatch "github.com/evanphx/json-patch"
1515
corev1 "k8s.io/api/core/v1"
16+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
1617
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
1718
"k8s.io/apimachinery/pkg/runtime"
1819
"k8s.io/apimachinery/pkg/util/jsonmergepatch"
1920
"k8s.io/apimachinery/pkg/util/managedfields"
2021
"k8s.io/apimachinery/pkg/util/strategicpatch"
2122
"k8s.io/client-go/kubernetes/scheme"
2223
"sigs.k8s.io/structured-merge-diff/v4/fieldpath"
24+
"sigs.k8s.io/structured-merge-diff/v4/merge"
2325
"sigs.k8s.io/structured-merge-diff/v4/typed"
2426

2527
"github.com/argoproj/gitops-engine/internal/kubernetes_vendor/pkg/api/v1/endpoints"
28+
"github.com/argoproj/gitops-engine/pkg/diff/internal/fieldmanager"
2629
"github.com/argoproj/gitops-engine/pkg/sync/resource"
2730
jsonutil "github.com/argoproj/gitops-engine/pkg/utils/json"
2831
gescheme "github.com/argoproj/gitops-engine/pkg/utils/kube/scheme"
@@ -121,67 +124,66 @@ func Diff(config, live *unstructured.Unstructured, opts ...Option) (*DiffResult,
121124
// k8s library (https://github.com/kubernetes-sigs/structured-merge-diff).
122125
func StructuredMergeDiff(config, live *unstructured.Unstructured, gvkParser *managedfields.GvkParser, manager string) (*DiffResult, error) {
123126
if live != nil && config != nil {
124-
gvk := config.GetObjectKind().GroupVersionKind()
125-
pt := gescheme.ResolveParseableType(gvk, gvkParser)
126-
return structuredMergeDiff(config, live, pt, manager)
127+
params := &SMDParams{
128+
config: config,
129+
live: live,
130+
gvkParser: gvkParser,
131+
manager: manager,
132+
}
133+
return structuredMergeDiff(params)
127134
}
128135
return handleResourceCreateOrDeleteDiff(config, live)
129136
}
130137

131-
func structuredMergeDiff(config, live *unstructured.Unstructured, pt *typed.ParseableType, manager string) (*DiffResult, error) {
132-
// 1) Build typed value from live and config unstructures
133-
tvLive, err := pt.FromUnstructured(live.Object)
138+
// SMDParams defines the parameters required by the structuredMergeDiff
139+
// function
140+
type SMDParams struct {
141+
config *unstructured.Unstructured
142+
live *unstructured.Unstructured
143+
gvkParser *managedfields.GvkParser
144+
manager string
145+
}
146+
147+
func structuredMergeDiff(p *SMDParams) (*DiffResult, error) {
148+
149+
gvk := p.config.GetObjectKind().GroupVersionKind()
150+
pt := gescheme.ResolveParseableType(gvk, p.gvkParser)
151+
152+
// Build typed value from live and config unstructures
153+
tvLive, err := pt.FromUnstructured(p.live.Object)
134154
if err != nil {
135155
return nil, fmt.Errorf("error building typed value from live resource: %w", err)
136156
}
137-
tvConfig, err := pt.FromUnstructured(config.Object)
157+
tvConfig, err := pt.FromUnstructured(p.config.Object)
138158
if err != nil {
139159
return nil, fmt.Errorf("error building typed value from config resource: %w", err)
140160
}
141161

142-
previousFieldSet := &fieldpath.Set{}
143-
managerFound := false
144-
// 2) Search for manager to find all fields managed by it
145-
// so it can be removed from live state before merging desired
146-
// state (config).
147-
if manager != "" {
148-
for _, m := range live.GetManagedFields() {
149-
if m.Manager == manager {
150-
err := previousFieldSet.FromJSON(bytes.NewReader(m.FieldsV1.Raw))
151-
if err != nil {
152-
return nil, fmt.Errorf("error parsing manager fields from JSON: %w", err)
153-
}
154-
managerFound = true
155-
}
156-
}
162+
// Invoke the apply function to calculate the diff using
163+
// the structured-merge-diff library
164+
mergedLive, err := apply(tvConfig, tvLive, p)
165+
if err != nil {
166+
return nil, fmt.Errorf("error calculating diff: %w", err)
157167
}
158168

159-
// 3) When manager is not found, it means that the resource
160-
// wasn't being synced with the given manager up to this point.
161-
// In this case config fields will be used to clean live state.
162-
if !managerFound {
163-
previousFieldSet, err = tvConfig.ToFieldSet()
169+
// When mergedLive is nil it means that there is no change
170+
if mergedLive == nil {
171+
liveBytes, err := json.Marshal(p.live)
164172
if err != nil {
165-
return nil, fmt.Errorf("error converting config to fieldset: %w", err)
173+
return nil, fmt.Errorf("error marshaling live resource: %w", err)
166174
}
175+
// In this case diff result will have live state for both,
176+
// predicted and live.
177+
return buildDiffResult(liveBytes, liveBytes), nil
167178
}
168179

169-
// 4) Remove previous fields from live
170-
cleanLive := tvLive.RemoveItems(previousFieldSet)
171-
172-
// 5) Merge desired state in clean live
173-
mergedCleanLive, err := cleanLive.Merge(tvConfig)
174-
if err != nil {
175-
return nil, fmt.Errorf("error merging config into clean live: %w", err)
176-
}
177-
178-
// 6) Apply default values in predicted live
179-
predictedLive, err := normalizeTypedValue(mergedCleanLive)
180+
// Normalize merged live
181+
predictedLive, err := normalizeTypedValue(mergedLive)
180182
if err != nil {
181183
return nil, fmt.Errorf("error applying default values in predicted live: %w", err)
182184
}
183185

184-
// 7) Apply default values in live
186+
// Normalize live
185187
taintedLive, err := normalizeTypedValue(tvLive)
186188
if err != nil {
187189
return nil, fmt.Errorf("error applying default values in live: %w", err)
@@ -190,6 +192,49 @@ func structuredMergeDiff(config, live *unstructured.Unstructured, pt *typed.Pars
190192
return buildDiffResult(predictedLive, taintedLive), nil
191193
}
192194

195+
// apply will build all the dependency required to invoke the smd.merge.updater.Apply
196+
// to correctly calculate the diff with the same logic used in k8s with server-side
197+
// apply.
198+
func apply(tvConfig, tvLive *typed.TypedValue, p *SMDParams) (*typed.TypedValue, error) {
199+
200+
// Build the structured-merge-diff Updater
201+
updater := merge.Updater{
202+
Converter: fieldmanager.NewVersionConverter(p.gvkParser, scheme.Scheme, p.config.GroupVersionKind().GroupVersion()),
203+
}
204+
205+
// Build a list of managers and which API version they own
206+
managed, err := fieldmanager.DecodeManagedFields(p.live.GetManagedFields())
207+
if err != nil {
208+
return nil, fmt.Errorf("error decoding managed fields: %w", err)
209+
}
210+
211+
// Use the desired manifest to extract the target resource version
212+
version := fieldpath.APIVersion(p.config.GetAPIVersion())
213+
214+
// The manager string needs to be converted to the internal manager
215+
// key used inside structured-merge-diff apply logic
216+
managerKey, err := buildManagerInfoForApply(p.manager)
217+
if err != nil {
218+
return nil, fmt.Errorf("error building manager info: %w", err)
219+
}
220+
221+
// Finally invoke Apply to execute the same function used in k8s
222+
// server-side applies
223+
mergedLive, _, err := updater.Apply(tvLive, tvConfig, version, managed.Fields(), managerKey, true)
224+
if err != nil {
225+
return nil, fmt.Errorf("error while running updater.Apply: %w", err)
226+
}
227+
return mergedLive, err
228+
}
229+
230+
func buildManagerInfoForApply(manager string) (string, error) {
231+
managerInfo := metav1.ManagedFieldsEntry{
232+
Manager: manager,
233+
Operation: metav1.ManagedFieldsOperationApply,
234+
}
235+
return fieldmanager.BuildManagerIdentifier(&managerInfo)
236+
}
237+
193238
// normalizeTypedValue will prepare the given tv so it can be used in diffs by:
194239
// - removing last-applied-configuration annotation
195240
// - applying default values

pkg/diff/diff_test.go

Lines changed: 88 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,17 +10,21 @@ import (
1010
"testing"
1111

1212
"github.com/argoproj/gitops-engine/pkg/diff/testdata"
13-
"github.com/argoproj/gitops-engine/pkg/utils/kube/scheme"
13+
openapi_v2 "github.com/google/gnostic/openapiv2"
1414
"github.com/stretchr/testify/assert"
1515
"github.com/stretchr/testify/require"
16+
"google.golang.org/protobuf/proto"
1617
appsv1 "k8s.io/api/apps/v1"
1718
corev1 "k8s.io/api/core/v1"
1819
v1 "k8s.io/api/core/v1"
1920
"k8s.io/apimachinery/pkg/api/equality"
2021
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
2122
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
2223
"k8s.io/apimachinery/pkg/runtime"
24+
"k8s.io/apimachinery/pkg/util/intstr"
25+
"k8s.io/apimachinery/pkg/util/managedfields"
2326
"k8s.io/klog/v2/klogr"
27+
openapiproto "k8s.io/kube-openapi/pkg/util/proto"
2428
"sigs.k8s.io/yaml"
2529
)
2630

@@ -747,39 +751,68 @@ func TestUnsortedEndpoints(t *testing.T) {
747751
}
748752
}
749753

754+
func buildGVKParser(t *testing.T) *managedfields.GvkParser {
755+
document := &openapi_v2.Document{}
756+
err := proto.Unmarshal(testdata.OpenAPIV2Doc, document)
757+
if err != nil {
758+
t.Fatalf("error unmarshaling openapi doc: %s", err)
759+
}
760+
models, err := openapiproto.NewOpenAPIData(document)
761+
if err != nil {
762+
t.Fatalf("error building openapi data: %s", err)
763+
}
764+
765+
gvkParser, err := managedfields.NewGVKParser(models, false)
766+
if err != nil {
767+
t.Fatalf("error building gvkParser: %s", err)
768+
}
769+
return gvkParser
770+
}
771+
750772
func TestStructuredMergeDiff(t *testing.T) {
751-
parser := scheme.StaticParser()
752-
svcParseType := parser.Type("io.k8s.api.core.v1.Service")
753-
manager := "argocd-controller"
773+
buildParams := func(live, config *unstructured.Unstructured) *SMDParams {
774+
gvkParser := buildGVKParser(t)
775+
manager := "argocd-controller"
776+
return &SMDParams{
777+
config: config,
778+
live: live,
779+
gvkParser: gvkParser,
780+
manager: manager,
781+
}
782+
}
754783

755784
t.Run("will apply default values", func(t *testing.T) {
756785
// given
786+
t.Parallel()
757787
liveState := StrToUnstructured(testdata.ServiceLiveYAML)
758788
desiredState := StrToUnstructured(testdata.ServiceConfigYAML)
789+
params := buildParams(liveState, desiredState)
759790

760791
// when
761-
result, err := structuredMergeDiff(desiredState, liveState, &svcParseType, manager)
792+
result, err := structuredMergeDiff(params)
762793

763794
// then
764795
require.NoError(t, err)
765796
assert.NotNil(t, result)
766-
assert.False(t, result.Modified)
797+
assert.True(t, result.Modified)
767798
predictedSVC := YamlToSvc(t, result.PredictedLive)
768799
liveSVC := YamlToSvc(t, result.NormalizedLive)
769-
assert.NotNil(t, predictedSVC.Spec.InternalTrafficPolicy)
770-
assert.NotNil(t, liveSVC.Spec.InternalTrafficPolicy)
800+
require.NotNil(t, predictedSVC.Spec.InternalTrafficPolicy)
801+
require.NotNil(t, liveSVC.Spec.InternalTrafficPolicy)
771802
assert.Equal(t, "Cluster", string(*predictedSVC.Spec.InternalTrafficPolicy))
772803
assert.Equal(t, "Cluster", string(*liveSVC.Spec.InternalTrafficPolicy))
773804
assert.Empty(t, predictedSVC.Annotations[AnnotationLastAppliedConfig])
774805
assert.Empty(t, liveSVC.Annotations[AnnotationLastAppliedConfig])
775806
})
776807
t.Run("will remove entries in list", func(t *testing.T) {
777808
// given
809+
t.Parallel()
778810
liveState := StrToUnstructured(testdata.ServiceLiveYAML)
779811
desiredState := StrToUnstructured(testdata.ServiceConfigWith2Ports)
812+
params := buildParams(liveState, desiredState)
780813

781814
// when
782-
result, err := structuredMergeDiff(desiredState, liveState, &svcParseType, manager)
815+
result, err := structuredMergeDiff(params)
783816

784817
// then
785818
require.NoError(t, err)
@@ -790,11 +823,13 @@ func TestStructuredMergeDiff(t *testing.T) {
790823
})
791824
t.Run("will remove previously added fields not present in desired state", func(t *testing.T) {
792825
// given
826+
t.Parallel()
793827
liveState := StrToUnstructured(testdata.LiveServiceWithTypeYAML)
794828
desiredState := StrToUnstructured(testdata.ServiceConfigYAML)
829+
params := buildParams(liveState, desiredState)
795830

796831
// when
797-
result, err := structuredMergeDiff(desiredState, liveState, &svcParseType, manager)
832+
result, err := structuredMergeDiff(params)
798833

799834
// then
800835
require.NoError(t, err)
@@ -805,11 +840,13 @@ func TestStructuredMergeDiff(t *testing.T) {
805840
})
806841
t.Run("will apply service with multiple ports", func(t *testing.T) {
807842
// given
843+
t.Parallel()
808844
liveState := StrToUnstructured(testdata.ServiceLiveYAML)
809845
desiredState := StrToUnstructured(testdata.ServiceConfigWithSamePortsYAML)
846+
params := buildParams(liveState, desiredState)
810847

811848
// when
812-
result, err := structuredMergeDiff(desiredState, liveState, &svcParseType, manager)
849+
result, err := structuredMergeDiff(params)
813850

814851
// then
815852
require.NoError(t, err)
@@ -818,6 +855,36 @@ func TestStructuredMergeDiff(t *testing.T) {
818855
svc := YamlToSvc(t, result.PredictedLive)
819856
assert.Len(t, svc.Spec.Ports, 5)
820857
})
858+
t.Run("will apply deployment defaults correctly", func(t *testing.T) {
859+
// given
860+
t.Parallel()
861+
liveState := StrToUnstructured(testdata.DeploymentLiveYAML)
862+
desiredState := StrToUnstructured(testdata.DeploymentConfigYAML)
863+
params := buildParams(liveState, desiredState)
864+
865+
// when
866+
result, err := structuredMergeDiff(params)
867+
868+
// then
869+
require.NoError(t, err)
870+
assert.NotNil(t, result)
871+
assert.False(t, result.Modified)
872+
deploy := YamlToDeploy(t, result.PredictedLive)
873+
assert.Len(t, deploy.Spec.Template.Spec.Containers, 1)
874+
assert.Equal(t, "0", deploy.Spec.Template.Spec.Containers[0].Resources.Requests.Cpu().String())
875+
assert.Equal(t, "0", deploy.Spec.Template.Spec.Containers[0].Resources.Requests.Memory().String())
876+
assert.Equal(t, "0", deploy.Spec.Template.Spec.Containers[0].Resources.Requests.Storage().String())
877+
assert.Equal(t, "0", deploy.Spec.Template.Spec.Containers[0].Resources.Limits.Cpu().String())
878+
assert.Equal(t, "0", deploy.Spec.Template.Spec.Containers[0].Resources.Limits.Memory().String())
879+
assert.Equal(t, "0", deploy.Spec.Template.Spec.Containers[0].Resources.Limits.Storage().String())
880+
require.NotNil(t, deploy.Spec.Strategy.RollingUpdate)
881+
expectedMaxSurge := &intstr.IntOrString{
882+
Type: intstr.String,
883+
StrVal: "25%",
884+
}
885+
assert.Equal(t, expectedMaxSurge, deploy.Spec.Strategy.RollingUpdate.MaxSurge)
886+
assert.Equal(t, "ClusterFirst", string(deploy.Spec.Template.Spec.DNSPolicy))
887+
})
821888
}
822889

823890
func createSecret(data map[string]string) *unstructured.Unstructured {
@@ -1078,6 +1145,16 @@ func YamlToSvc(t *testing.T, y []byte) *corev1.Service {
10781145
return &svc
10791146
}
10801147

1148+
func YamlToDeploy(t *testing.T, y []byte) *appsv1.Deployment {
1149+
t.Helper()
1150+
deploy := appsv1.Deployment{}
1151+
err := yaml.Unmarshal(y, &deploy)
1152+
if err != nil {
1153+
t.Fatalf("error unmarshaling deployment bytes: %s", err)
1154+
}
1155+
return &deploy
1156+
}
1157+
10811158
func StrToUnstructured(yamlStr string) *unstructured.Unstructured {
10821159
obj := make(map[string]interface{})
10831160
err := yaml.Unmarshal([]byte(yamlStr), &obj)

pkg/diff/internal/fieldmanager/README

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Please check the doc.go file for more details about
2+
how to use and maintain the code in this package.

0 commit comments

Comments
 (0)