From 6a13cbdb7f90dee1c68e196d9731fa3d4f41ce00 Mon Sep 17 00:00:00 2001 From: Guilherme Cassolato Date: Tue, 2 Jul 2024 13:35:09 +0200 Subject: [PATCH] New example JSON patch merge strategy --- examples/json_patch/color_policy.go | 143 ++++++++++++++ examples/json_patch/color_policy_test.go | 180 +++++++++++++++++ examples/json_patch/integration_test.go | 236 +++++++++++++++++++++++ go.mod | 1 + go.sum | 2 + 5 files changed, 562 insertions(+) create mode 100644 examples/json_patch/color_policy.go create mode 100644 examples/json_patch/color_policy_test.go create mode 100644 examples/json_patch/integration_test.go diff --git a/examples/json_patch/color_policy.go b/examples/json_patch/color_policy.go new file mode 100644 index 0000000..ab324ae --- /dev/null +++ b/examples/json_patch/color_policy.go @@ -0,0 +1,143 @@ +package json_patch + +import ( + "encoding/json" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + gwapi "sigs.k8s.io/gateway-api/apis/v1alpha2" + + jsonpatch "github.com/evanphx/json-patch" + "github.com/kuadrant/policy-machinery/machinery" +) + +type ColorPolicy struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec ColorSpec `json:"spec"` +} + +var _ machinery.Policy = &ColorPolicy{} + +func (p *ColorPolicy) GetURL() string { + return machinery.UrlFromObject(p) +} + +func (p *ColorPolicy) GetTargetRefs() []machinery.PolicyTargetReference { + return []machinery.PolicyTargetReference{ + machinery.LocalPolicyTargetReferenceWithSectionName{ + LocalPolicyTargetReferenceWithSectionName: p.Spec.TargetRef, + PolicyNamespace: p.Namespace, + }, + } +} + +func (p *ColorPolicy) GetMergeStrategy() machinery.MergeStrategy { + return func(parent, child machinery.Policy) machinery.Policy { + colorParent, okSource := parent.(*ColorPolicy) + colorChild, okTarget := child.(*ColorPolicy) + + if !okSource || !okTarget { + return nil + } + + parentSpecJSON, err := json.Marshal(colorParent.Spec.Proper()) + if err != nil { + return nil + } + + childSpecJSON, err := json.Marshal(colorChild.Spec.Proper()) + if err != nil { + return nil + } + + var resultSpecJSON []byte + + if overrides := colorParent.Spec.Overrides; overrides != nil { + resultSpecJSON, err = jsonpatch.MergePatch(childSpecJSON, parentSpecJSON) + } else { + resultSpecJSON, err = jsonpatch.MergePatch(parentSpecJSON, childSpecJSON) + } + + if err != nil { + return nil + } + + result := ColorSpecProper{} + if err := json.Unmarshal(resultSpecJSON, &result); err != nil { + return nil + } + + return &ColorPolicy{ + TypeMeta: colorChild.TypeMeta, + ObjectMeta: colorChild.ObjectMeta, + Spec: ColorSpec{ + TargetRef: colorChild.Spec.TargetRef, + ColorSpecProper: result, + }, + } + } +} + +func (p *ColorPolicy) Merge(policy machinery.Policy) machinery.Policy { + source := policy.(*ColorPolicy) + return source.GetMergeStrategy()(source, p) +} + +func (p *ColorPolicy) DeepCopy() *ColorPolicy { + spec := p.Spec.DeepCopy() + return &ColorPolicy{ + TypeMeta: p.TypeMeta, + ObjectMeta: p.ObjectMeta, + Spec: *spec, + } +} + +type ColorSpec struct { + TargetRef gwapi.LocalPolicyTargetReferenceWithSectionName `json:"targetRef"` + + Defaults *ColorSpecProper `json:"defaults,omitempty"` + Overrides *ColorSpecProper `json:"overrides,omitempty"` + + ColorSpecProper `json:""` +} + +func (s *ColorSpec) Proper() *ColorSpecProper { + if s.Defaults != nil { + return s.Defaults + } + if s.Overrides != nil { + return s.Overrides + } + return &s.ColorSpecProper +} + +func (s *ColorSpec) DeepCopy() *ColorSpec { + rules := make(map[string]ColorValue, len(s.Proper().Rules)) + for k, v := range s.Proper().Rules { + rules[k] = v + } + return &ColorSpec{ + TargetRef: s.TargetRef, + ColorSpecProper: ColorSpecProper{ + Rules: rules, + }, + } +} + +type ColorSpecProper struct { + Rules map[string]ColorValue `json:"rules,omitempty"` +} + +type ColorValue string + +const ( + Black ColorValue = "black" + Blue ColorValue = "blue" + Green ColorValue = "green" + Orange ColorValue = "orange" + Purple ColorValue = "purple" + Red ColorValue = "red" + White ColorValue = "white" + Yellow ColorValue = "yellow" +) diff --git a/examples/json_patch/color_policy_test.go b/examples/json_patch/color_policy_test.go new file mode 100644 index 0000000..6490d93 --- /dev/null +++ b/examples/json_patch/color_policy_test.go @@ -0,0 +1,180 @@ +//go:build unit + +package json_patch + +import ( + "testing" + + "github.com/kuadrant/policy-machinery/machinery" +) + +func TestMerge(t *testing.T) { + testCases := []struct { + name string + source machinery.Policy + target machinery.Policy + expected map[string]ColorValue + }{ + { + name: "JSON patch defaults into empty policy", + source: &ColorPolicy{ + Spec: ColorSpec{ + Defaults: &ColorSpecProper{ + Rules: map[string]ColorValue{ + "rule-1": Blue, + "rule-2": Red, + }, + }, + }, + }, + target: &ColorPolicy{}, + expected: map[string]ColorValue{ + "rule-1": Blue, + "rule-2": Red, + }, + }, + { + name: "JSON patch defaults into policy without conflicting rules", + source: &ColorPolicy{ + Spec: ColorSpec{ + Defaults: &ColorSpecProper{ + Rules: map[string]ColorValue{ + "rule-1": Blue, + "rule-2": Red, + }, + }, + }, + }, + target: &ColorPolicy{ + Spec: ColorSpec{ + ColorSpecProper: ColorSpecProper{ + Rules: map[string]ColorValue{ + "rule-3": Green, + }, + }, + }, + }, + expected: map[string]ColorValue{ + "rule-1": Blue, + "rule-2": Red, + "rule-3": Green, + }, + }, + { + name: "JSON patch defaults into policy with conflicting rules", + source: &ColorPolicy{ + Spec: ColorSpec{ + Defaults: &ColorSpecProper{ + Rules: map[string]ColorValue{ + "rule-1": Blue, + "rule-2": Red, + }, + }, + }, + }, + target: &ColorPolicy{ + Spec: ColorSpec{ + ColorSpecProper: ColorSpecProper{ + Rules: map[string]ColorValue{ + "rule-1": Yellow, + "rule-3": Green, + }, + }, + }, + }, + expected: map[string]ColorValue{ + "rule-1": Yellow, + "rule-2": Red, + "rule-3": Green, + }, + }, + { + name: "JSON patch overrides into empty policy", + source: &ColorPolicy{ + Spec: ColorSpec{ + Overrides: &ColorSpecProper{ + Rules: map[string]ColorValue{ + "rule-1": Blue, + "rule-2": Red, + }, + }, + }, + }, + target: &ColorPolicy{}, + expected: map[string]ColorValue{ + "rule-1": Blue, + "rule-2": Red, + }, + }, + { + name: "JSON patch overrides into policy without conflicting rules", + source: &ColorPolicy{ + Spec: ColorSpec{ + Overrides: &ColorSpecProper{ + Rules: map[string]ColorValue{ + "rule-1": Blue, + "rule-2": Red, + }, + }, + }, + }, + target: &ColorPolicy{ + Spec: ColorSpec{ + ColorSpecProper: ColorSpecProper{ + Rules: map[string]ColorValue{ + "rule-3": Green, + }, + }, + }, + }, + expected: map[string]ColorValue{ + "rule-1": Blue, + "rule-2": Red, + "rule-3": Green, + }, + }, + { + name: "JSON patch overrides into policy with conflicting rules", + source: &ColorPolicy{ + Spec: ColorSpec{ + Overrides: &ColorSpecProper{ + Rules: map[string]ColorValue{ + "rule-1": Blue, + "rule-2": Red, + }, + }, + }, + }, + target: &ColorPolicy{ + Spec: ColorSpec{ + ColorSpecProper: ColorSpecProper{ + Rules: map[string]ColorValue{ + "rule-1": Yellow, + "rule-3": Green, + }, + }, + }, + }, + expected: map[string]ColorValue{ + "rule-1": Blue, + "rule-2": Red, + "rule-3": Green, + }, + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + merged := tc.target.Merge(tc.source) + mergedColorPolicy := merged.(*ColorPolicy) + mergedRules := mergedColorPolicy.Spec.Proper().Rules + if len(mergedRules) != len(tc.expected) { + t.Errorf("Expected %d rules, but got %d", len(tc.expected), len(mergedRules)) + } + for id, color := range mergedRules { + if tc.expected[id] != color { + t.Errorf("Expected rule %s to have color %s, but got %s", id, tc.expected[id], color) + } + } + }) + } +} diff --git a/examples/json_patch/integration_test.go b/examples/json_patch/integration_test.go new file mode 100644 index 0000000..88796d3 --- /dev/null +++ b/examples/json_patch/integration_test.go @@ -0,0 +1,236 @@ +//go:build integration + +package json_patch + +import ( + "encoding/json" + "fmt" + "strings" + "testing" + + "github.com/samber/lo" + core "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/ptr" + gwapiv1 "sigs.k8s.io/gateway-api/apis/v1" + gwapiv1alpha2 "sigs.k8s.io/gateway-api/apis/v1alpha2" + + "github.com/kuadrant/policy-machinery/machinery" +) + +// TestJSONPatchMergeBasedOnTopology tests ColorPolicy's merge strategies for painting a house, based on network traffic +// flowing through the following topology of Gateway API resources: +// +// ┌────────────┐ +// house-colors-gw ────────────────────────────────►│ my-gateway │ +// └────────────┘ +// ▲ +// │ +// ┌────────────┴─────────────┐ +// │ │ +// ┌───────────┴───────────┐ ┌───────────┴───────────┐ +// house-colors-route-1 ────────►│ my-route-1 │ │ my-route-2 │ +// │ │ │ │ +// │ ┌────────┐ ┌────────┐ │ │ ┌────────┐ ┌────────┐ │ +// house-colors-route-1-rule-1 ──┤►│ rule-1 │ │ rule-2 │ │ │ │ rule-2 │ │ rule-1 │◄├──── house-colors-route-2-rule-1 +// │ └───┬────┘ └────┬───┘ │ │ └────┬───┘ └────┬───┘ │ +// │ │ │ │ │ │ │ │ +// └─────┼───────────┼─────┘ └──────┼──────────┼─────┘ +// │ │ │ │ +// └───────────┴───────┬───────┴──────────┘ +// │ +// ▼ +// ┌────────────┐ +// │ my-service │ +// └────────────┘ +func TestJSONPatchMergeBasedOnTopology(t *testing.T) { + gateway := machinery.BuildGateway() + httpRoutes := []*gwapiv1.HTTPRoute{ + machinery.BuildHTTPRoute(func(r *gwapiv1.HTTPRoute) { + r.Name = "my-route-1" + r.Spec.Rules = append(r.Spec.Rules, gwapiv1.HTTPRouteRule{ + BackendRefs: []gwapiv1.HTTPBackendRef{machinery.BuildHTTPBackendRef()}, + }) + }), + machinery.BuildHTTPRoute(func(r *gwapiv1.HTTPRoute) { + r.Name = "my-route-2" + r.Spec.Rules = append(r.Spec.Rules, gwapiv1.HTTPRouteRule{ + BackendRefs: []gwapiv1.HTTPBackendRef{machinery.BuildHTTPBackendRef()}, + }) + }), + } + services := []*core.Service{machinery.BuildService()} + policies := []machinery.Policy{ + buildPolicy(func(p *ColorPolicy) { // atomic defaults + p.Name = "house-colors-gw" + p.Spec.TargetRef.Group = gwapiv1.GroupName + p.Spec.TargetRef.Kind = "Gateway" + p.Spec.TargetRef.Name = "my-gateway" + p.Spec.ColorSpecProper = ColorSpecProper{} + p.Spec.Defaults = &ColorSpecProper{ + Rules: map[string]ColorValue{ + "walls": Black, + "doors": Blue, + }, + } + }), + buildPolicy(func(p *ColorPolicy) { // policy rule overrides + p.Name = "house-colors-route-1" + p.Spec.TargetRef.Group = gwapiv1.GroupName + p.Spec.TargetRef.Kind = "HTTPRoute" + p.Spec.TargetRef.Name = "my-route-1" + p.Spec.ColorSpecProper = ColorSpecProper{} + p.Spec.Overrides = &ColorSpecProper{ + Rules: map[string]ColorValue{ + "walls": Green, + "roof": Orange, + }, + } + }), + buildPolicy(func(p *ColorPolicy) { // default: atomic defaults + p.Name = "house-colors-route-1-rule-1" + p.Spec.TargetRef.Group = gwapiv1.GroupName + p.Spec.TargetRef.Kind = "HTTPRoute" + p.Spec.TargetRef.Name = "my-route-1" + p.Spec.TargetRef.SectionName = ptr.To(gwapiv1.SectionName("rule-1")) + p.Spec.Rules = map[string]ColorValue{ + "roof": Purple, + "floor": Red, + } + }), + buildPolicy(func(p *ColorPolicy) { // default: atomic defaults + p.Name = "house-colors-route-2-rule-1" + p.Spec.TargetRef.Group = gwapiv1.GroupName + p.Spec.TargetRef.Kind = "HTTPRoute" + p.Spec.TargetRef.Name = "my-route-2" + p.Spec.TargetRef.SectionName = ptr.To(gwapiv1.SectionName("rule-1")) + p.Spec.Rules = map[string]ColorValue{ + "walls": White, + "floor": Yellow, + } + }), + } + + topology := machinery.NewGatewayAPITopology( + machinery.WithGateways(gateway), + machinery.WithHTTPRoutes(httpRoutes...), + machinery.ExpandHTTPRouteRules(), + machinery.WithServices(services...), + machinery.WithGatewayAPITopologyPolicies(policies...), + ) + + machinery.SaveToOutputDir(t, topology.ToDot(), "../../tests/out", ".dot") + + gateways := topology.Targetables(func(o machinery.Object) bool { + _, ok := o.(*machinery.Gateway) + return ok + }) + httpRouteRules := topology.Targetables(func(o machinery.Object) bool { + _, ok := o.(*machinery.HTTPRouteRule) + return ok + }) + + effectivePoliciesByPath := make(map[string]ColorPolicy) + + for _, httpRouteRule := range httpRouteRules { + for _, path := range topology.Paths(gateways[0], httpRouteRule) { + // Gather all policies in the path sorted from the least specific (gateway) to the most specific (httprouterule) + // Since in this example there are no targetables with more than one policy attached to it, we can safely just + // flat the slices of policies; otherwise we would need to ensure that the policies at the same level are sorted + // by creationTimeStamp. + policies := lo.FlatMap(path, func(targetable machinery.Targetable, _ int) []machinery.Policy { + return targetable.Policies() + }) + + // Map reduces the policies from most specific to least specific, merging them into one effective policy for + // each path + var emptyPolicy machinery.Policy = buildPolicy() + effectivePolicy := lo.ReduceRight(policies, func(effectivePolicy machinery.Policy, policy machinery.Policy, _ int) machinery.Policy { + return effectivePolicy.Merge(policy) + }, emptyPolicy) + + pathStr := strings.Join(lo.Map(path, func(t machinery.Targetable, _ int) string { return t.GetName() }), " → ") + effectiveColorPolicy := effectivePolicy.(*ColorPolicy) + effectivePoliciesByPath[pathStr] = *effectiveColorPolicy.DeepCopy() + + jsonPolicy, _ := json.MarshalIndent(effectivePolicy, "", " ") + fmt.Printf("Effective policy for path %s:\n%s\n", pathStr, jsonPolicy) + } + } + + expectedPolicyRulesByPath := map[string]map[string]ColorValue{ + "my-gateway → my-route-1 → my-route-1#rule-1": { + // from house-colors-gw + "doors": Blue, + // from house-colors-route-1 + "walls": Green, + "roof": Orange, + // from house-colors-route-1-rule-1 + "floor": Red, + }, + "my-gateway → my-route-1 → my-route-1#rule-2": { + // from house-colors-gw + "doors": Blue, + // from house-colors-route-1 + "walls": Green, + "roof": Orange, + }, + "my-gateway → my-route-2 → my-route-2#rule-1": { + // from house-colors-gw + "doors": Blue, + // from house-colors-route-2-rule-1 + "walls": White, + "floor": Yellow, + }, + "my-gateway → my-route-2 → my-route-2#rule-2": { + // from house-colors-gw + "walls": Black, + "doors": Blue, + }, + } + + if len(effectivePoliciesByPath) != len(expectedPolicyRulesByPath) { + t.Fatalf("expected %d paths, got %d", len(expectedPolicyRulesByPath), len(effectivePoliciesByPath)) + } + + for path, expectedRules := range expectedPolicyRulesByPath { + effectivePolicy := effectivePoliciesByPath[path] + effectiveRules := effectivePolicy.Spec.Proper().Rules + if len(effectiveRules) != len(expectedRules) { + t.Fatalf("expected %d rules for path %s, got %d", len(expectedRules), path, len(effectiveRules)) + } + for id, color := range effectiveRules { + if color != expectedRules[id] { + t.Errorf("expected rule %s to have color %s for path %s, got %s", id, expectedRules[id], path, color) + } + } + } +} + +func buildPolicy(f ...func(*ColorPolicy)) *ColorPolicy { + policy := &ColorPolicy{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "kuadrant.io/v1", + Kind: "ColorPolicy", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "my-color-policy", + Namespace: "my-namespace", + }, + Spec: ColorSpec{ + TargetRef: gwapiv1alpha2.LocalPolicyTargetReferenceWithSectionName{ + LocalPolicyTargetReference: gwapiv1alpha2.LocalPolicyTargetReference{ + Kind: "Service", + Name: "my-service", + }, + }, + ColorSpecProper: ColorSpecProper{ + Rules: map[string]ColorValue{}, + }, + }, + } + for _, fn := range f { + fn(policy) + } + return policy +} diff --git a/go.mod b/go.mod index fc7f9a5..f3e2ef1 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/kuadrant/policy-machinery go 1.22.2 require ( + github.com/evanphx/json-patch v5.9.0+incompatible github.com/goccy/go-graphviz v0.1.3 github.com/samber/lo v1.39.0 k8s.io/api v0.30.0 diff --git a/go.sum b/go.sum index f0b19eb..e193f4e 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/evanphx/json-patch v5.9.0+incompatible h1:fBXyNpNMuTTDdquAq/uisOr2lShz4oaXpDTX2bLe7ls= +github.com/evanphx/json-patch v5.9.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8= github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ=