From f9a5da4a1e0f4e835299c16ae18fb0d2daa02fc3 Mon Sep 17 00:00:00 2001 From: Matias Frank Jensen Date: Fri, 26 Apr 2024 14:14:28 +0200 Subject: [PATCH 1/2] :sparkles: Add merging functions for platform CRDs --- go.mod | 2 +- go.sum | 4 +- pkg/api/platform/v1/types.go | 8 +- pkg/api/v1alpha2/capsule_types.go | 2 +- pkg/obj/merge.go | 43 +++++ pkg/obj/merge_test.go | 282 ++++++++++++++++++++++++++++++ 6 files changed, 333 insertions(+), 8 deletions(-) diff --git a/go.mod b/go.mod index 04a881ab1..e54c4bd5a 100644 --- a/go.mod +++ b/go.mod @@ -30,7 +30,7 @@ require ( github.com/nyaruka/phonenumbers v1.1.7 github.com/pkg/errors v0.9.1 github.com/prometheus-operator/prometheus-operator/pkg/apis/monitoring v0.70.0 - github.com/rigdev/rig-go-api v0.0.0-20240419130241-0d1e7be1236b + github.com/rigdev/rig-go-api v0.0.0-20240426160443-55ccf311c714 github.com/rigdev/rig-go-sdk v0.0.0-20240417114812-ecae62a90e9b github.com/rivo/tview v0.0.0-20240225120200-5605142ca62e github.com/robfig/cron v1.2.0 diff --git a/go.sum b/go.sum index 0d2f048ad..2daa55a2a 100644 --- a/go.sum +++ b/go.sum @@ -274,8 +274,8 @@ github.com/prometheus/common v0.45.0 h1:2BGz0eBc2hdMDLnO/8n0jeB3oPrt2D08CekT0lne github.com/prometheus/common v0.45.0/go.mod h1:YJmSTw9BoKxJplESWWxlbyttQR4uaEcGyv9MZjVOJsY= github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo= github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= -github.com/rigdev/rig-go-api v0.0.0-20240419130241-0d1e7be1236b h1:PGy7QXThVTINJaDLdS9yzgeIIxOmDCN/xATkOFHNzug= -github.com/rigdev/rig-go-api v0.0.0-20240419130241-0d1e7be1236b/go.mod h1:0GWZXcPZXpGDoB7FhQAj1CWizEIUjELZenmeGpCnuU4= +github.com/rigdev/rig-go-api v0.0.0-20240426160443-55ccf311c714 h1:xSb0X+taXeO25y4tUuYaiF2bia3WDMDpes9ajUkibzg= +github.com/rigdev/rig-go-api v0.0.0-20240426160443-55ccf311c714/go.mod h1:0GWZXcPZXpGDoB7FhQAj1CWizEIUjELZenmeGpCnuU4= github.com/rigdev/rig-go-sdk v0.0.0-20240417114812-ecae62a90e9b h1:owpf6cgWMmO31ReecaGvqlmtgj8ozyhxIK1lhTCDUCw= github.com/rigdev/rig-go-sdk v0.0.0-20240417114812-ecae62a90e9b/go.mod h1:RSzrgNlAsBiUgcSORU0YKw3e97G7GFfQCcM3Sj1pznc= github.com/rivo/tview v0.0.0-20240225120200-5605142ca62e h1:7ubTieBkl4KCz5ABZzh0zPkBYWPguSOHUundUsorIzQ= diff --git a/pkg/api/platform/v1/types.go b/pkg/api/platform/v1/types.go index 871c583eb..449be5bc1 100644 --- a/pkg/api/platform/v1/types.go +++ b/pkg/api/platform/v1/types.go @@ -99,16 +99,16 @@ type CapsuleSpecExtension struct { // Args is a list of arguments either passed to the Command or if Command // is left empty the arguments will be passed to the ENTRYPOINT of the // docker image. - Args []string `json:"args,omitempty" protobuf:"5"` + Args []string `json:"args,omitempty" protobuf:"5"patchStrategy:"replace"` // Interfaces specifies the list of interfaces the the container should // have. Specifying interfaces will create the corresponding kubernetes // Services and Ingresses depending on how the interface is configured. - Interfaces []v1alpha2.CapsuleInterface `json:"interfaces,omitempty" protobuf:"6"` + Interfaces []v1alpha2.CapsuleInterface `json:"interfaces,omitempty" protobuf:"6" patchMergeKey:"port" patchStrategy:"merge"` // Files is a list of files to mount in the container. These can either be // based on ConfigMaps or Secrets. - ConfigFiles []ConfigFile `json:"configFiles" protobuf:"7"` + ConfigFiles []ConfigFile `json:"configFiles" protobuf:"7" patchMergeKey:"path" patchStrategy:"merge"` EnvironmentVariables map[string]string `json:"environmentVariables,omitempty" protobuf:"12"` @@ -118,7 +118,7 @@ type CapsuleSpecExtension struct { // NodeSelector is a selector for what nodes the Capsule should live on. NodeSelector map[string]string `json:"nodeSelector,omitempty" protobuf:"9"` - CronJobs []v1alpha2.CronJob `json:"cronJobs,omitempty" protobuf:"10"` + CronJobs []v1alpha2.CronJob `json:"cronJobs,omitempty" protobuf:"10" patchMergeKey:"name" patchStrategy:"replace"` Annotations map[string]string `json:"annotations" protobuf:"11"` } diff --git a/pkg/api/v1alpha2/capsule_types.go b/pkg/api/v1alpha2/capsule_types.go index d5924fea3..37f680ad0 100644 --- a/pkg/api/v1alpha2/capsule_types.go +++ b/pkg/api/v1alpha2/capsule_types.go @@ -114,7 +114,7 @@ type HorizontalScale struct { CPUTarget *CPUTarget `json:"cpuTarget,omitempty" protobuf:"2"` // CustomMetrics specifies custom metrics emitted by the custom.metrics.k8s.io API // which the autoscaler should scale on - CustomMetrics []CustomMetric `json:"customMetrics,omitempty" protobuf:"3"` + CustomMetrics []CustomMetric `json:"customMetrics,omitempty" protobuf:"3" patchStrategy:"replace"` } // Instances specifies the minimum and maximum amount of capsule diff --git a/pkg/obj/merge.go b/pkg/obj/merge.go index 443e0db90..f7fbbcbad 100644 --- a/pkg/obj/merge.go +++ b/pkg/obj/merge.go @@ -2,8 +2,11 @@ package obj import ( "bytes" + "encoding/json" "fmt" + platformv1 "github.com/rigdev/rig-go-api/platform/v1" + v1 "github.com/rigdev/rig/pkg/api/platform/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/serializer" "k8s.io/apimachinery/pkg/util/strategicpatch" @@ -97,3 +100,43 @@ func convert[T any](obj any) (T, error) { } return objT, nil } + +// MergeProjectEnv merges a ProjEnvCapsuleBase into a CapsuleSpecExtension and returns a new object with the merged result +// It uses StrategicMergePatch (https://kubernetes.io/docs/tasks/manage-kubernetes-objects/update-api-object-kubectl-patch/) +func MergeProjectEnv(patch *platformv1.ProjEnvCapsuleBase, into *platformv1.CapsuleSpecExtension) (*platformv1.CapsuleSpecExtension, error) { + return mergeCapsuleSpec(patch, into) +} + +// MergeCapsuleSpecExtension merges a CapsuleSpecExtension into another CapsuleSpecExtension and returns a new object with the merged result +// It uses StrategicMergePatch (https://kubernetes.io/docs/tasks/manage-kubernetes-objects/update-api-object-kubectl-patch/) +func MergeCapsuleSpecExtensions(patch, into *platformv1.CapsuleSpecExtension) (*platformv1.CapsuleSpecExtension, error) { + return mergeCapsuleSpec(patch, into) +} + +func mergeCapsuleSpec(patch any, into *platformv1.CapsuleSpecExtension) (*platformv1.CapsuleSpecExtension, error) { + // It would be possible to do much faster merging by manualling overwriting protobuf fields. + // This is tedius to maintain so until it becomes an issue, we use json marshalling to leverage StrategicMergePatch + patchBytes, err := json.Marshal(patch) + if err != nil { + return nil, err + } + + intoBytes, err := json.Marshal(into) + if err != nil { + return nil, err + } + + outBytes, err := strategicpatch.StrategicMergePatch(intoBytes, patchBytes, &v1.CapsuleSpecExtension{}) + if err != nil { + return nil, err + } + + out := &platformv1.CapsuleSpecExtension{} + if err := json.Unmarshal(outBytes, out); err != nil { + return nil, err + } + out.Kind = into.GetKind() + out.ApiVersion = into.GetApiVersion() + + return out, nil +} diff --git a/pkg/obj/merge_test.go b/pkg/obj/merge_test.go index c9dc4036f..f2ddb4076 100644 --- a/pkg/obj/merge_test.go +++ b/pkg/obj/merge_test.go @@ -3,6 +3,9 @@ package obj import ( "testing" + "github.com/rigdev/rig-go-api/k8s.io/apimachinery/pkg/api/resource" + platformv1 "github.com/rigdev/rig-go-api/platform/v1" + v1alpha2 "github.com/rigdev/rig-go-api/v1alpha2" "github.com/rigdev/rig/pkg/api/config/v1alpha1" "github.com/rigdev/rig/pkg/ptr" "github.com/rigdev/rig/pkg/scheme" @@ -345,3 +348,282 @@ func testMerge[T runtime.Object](t *testing.T, name string, src, dst, expected, assert.Equal(t, expected, res) }) } + +func Test_mergeCapsuleSpec(t *testing.T) { + tests := []struct { + name string + patch any + into *platformv1.CapsuleSpecExtension + expected *platformv1.CapsuleSpecExtension + }{ + { + name: "empty projEnv base", + patch: &platformv1.ProjEnvCapsuleBase{}, + into: &platformv1.CapsuleSpecExtension{ + Kind: "CapsuleSpecExtension", + ApiVersion: "v1", + }, + expected: &platformv1.CapsuleSpecExtension{ + Kind: "CapsuleSpecExtension", + ApiVersion: "v1", + }, + }, + { + name: "projEnv config files", + patch: &platformv1.ProjEnvCapsuleBase{ + ConfigFiles: []*platformv1.ConfigFile{{ + Path: "some-path", + Content: []byte{1, 2, 3}, + }}, + }, + into: &platformv1.CapsuleSpecExtension{ + Kind: "CapsuleSpecExtension", + ApiVersion: "v1", + ConfigFiles: []*platformv1.ConfigFile{ + { + Path: "some-path", + Content: []byte{5, 6, 7}, + }, + { + Path: "some-path2", + Content: []byte{1, 2, 3, 4}, + }, + }, + }, + expected: &platformv1.CapsuleSpecExtension{ + Kind: "CapsuleSpecExtension", + ApiVersion: "v1", + ConfigFiles: []*platformv1.ConfigFile{ + { + Path: "some-path", + Content: []byte{1, 2, 3}, + }, + { + Path: "some-path2", + Content: []byte{1, 2, 3, 4}, + }, + }, + }, + }, + { + name: "projEnv has env vars", + patch: &platformv1.ProjEnvCapsuleBase{ + ConfigFiles: []*platformv1.ConfigFile{}, + EnvironmentVariables: map[string]string{ + "key1": "value1", + "key2": "value2", + }, + }, + into: &platformv1.CapsuleSpecExtension{ + Kind: "CapsuleSpecExtension", + ApiVersion: "v1", + EnvironmentVariables: map[string]string{ + "key1": "other-value", + "key3": "value3", + }, + }, + expected: &platformv1.CapsuleSpecExtension{ + Kind: "CapsuleSpecExtension", + ApiVersion: "v1", + EnvironmentVariables: map[string]string{ + "key1": "value1", + "key2": "value2", + "key3": "value3", + }, + }, + }, + { + name: "empty capsule patch", + patch: &platformv1.CapsuleSpecExtension{}, + into: &platformv1.CapsuleSpecExtension{ + Kind: "CapsuleSpecExtension", + ApiVersion: "v1", + Image: "image", + Args: []string{"arg"}, + }, + expected: &platformv1.CapsuleSpecExtension{ + Kind: "CapsuleSpecExtension", + ApiVersion: "v1", + Image: "image", + Args: []string{"arg"}, + }, + }, + { + name: "capsule patch with simple values", + patch: &platformv1.CapsuleSpecExtension{ + Image: "image", + Command: "command", + Args: []string{"arg1", "arg2"}, + NodeSelector: map[string]string{"key1": "value1"}, + Annotations: map[string]string{"key2": "value2"}, + }, + into: &platformv1.CapsuleSpecExtension{ + Kind: "CapsuleSpecExtension", + ApiVersion: "v1", + Image: "otherimage", + Command: "othercommand", + Args: []string{"otherarg"}, + Annotations: map[string]string{"key3": "value3"}, + }, + expected: &platformv1.CapsuleSpecExtension{ + Kind: "CapsuleSpecExtension", + ApiVersion: "v1", + Image: "image", + Command: "command", + Args: []string{"arg1", "arg2"}, + NodeSelector: map[string]string{"key1": "value1"}, + Annotations: map[string]string{"key2": "value2", "key3": "value3"}, + }, + }, + { + name: "interface patch", + patch: &platformv1.CapsuleSpecExtension{ + Interfaces: []*v1alpha2.CapsuleInterface{ + { + Name: "interface1", + Port: 1001, + Liveness: &v1alpha2.InterfaceProbe{ + Path: "some-path", + Tcp: true, + }, + }, + { + Name: "interface2", + Port: 1002, + }, + }, + }, + into: &platformv1.CapsuleSpecExtension{ + Interfaces: []*v1alpha2.CapsuleInterface{ + { + Name: "interface1", + Port: 1001, + Readiness: &v1alpha2.InterfaceProbe{ + Path: "other-path", + Tcp: true, + }, + }, + { + Name: "interface3", + Port: 1003, + }, + }, + }, + expected: &platformv1.CapsuleSpecExtension{ + Interfaces: []*v1alpha2.CapsuleInterface{ + { + Name: "interface1", + Port: 1001, + Liveness: &v1alpha2.InterfaceProbe{ + Path: "some-path", + Tcp: true, + }, + Readiness: &v1alpha2.InterfaceProbe{ + Path: "other-path", + Tcp: true, + }, + }, + { + Name: "interface2", + Port: 1002, + }, + { + Name: "interface3", + Port: 1003, + }, + }, + }, + }, + { + name: "scale patch", + patch: &platformv1.CapsuleSpecExtension{ + Scale: &v1alpha2.CapsuleScale{ + Horizontal: &v1alpha2.HorizontalScale{ + Instances: &v1alpha2.Instances{ + Min: 2, + Max: 4, + }, + CustomMetrics: []*v1alpha2.CustomMetric{ + { + InstanceMetric: &v1alpha2.InstanceMetric{ + MetricName: "some-metric", + AverageValue: "1", + }, + }, + }, + }, + Vertical: &v1alpha2.VerticalScale{ + Cpu: &v1alpha2.ResourceLimits{ + Request: &resource.Quantity{ + String_: "1", + }, + }, + }, + }, + }, + into: &platformv1.CapsuleSpecExtension{ + Scale: &v1alpha2.CapsuleScale{ + Horizontal: &v1alpha2.HorizontalScale{ + Instances: &v1alpha2.Instances{ + Min: 1, + Max: 1, + }, + CustomMetrics: []*v1alpha2.CustomMetric{ + { + InstanceMetric: &v1alpha2.InstanceMetric{ + MetricName: "some-other-metric", + AverageValue: "2", + }, + }, + }, + }, + Vertical: &v1alpha2.VerticalScale{ + Memory: &v1alpha2.ResourceLimits{ + Request: &resource.Quantity{ + String_: "100M", + }, + }, + }, + }, + }, + expected: &platformv1.CapsuleSpecExtension{ + Scale: &v1alpha2.CapsuleScale{ + Horizontal: &v1alpha2.HorizontalScale{ + Instances: &v1alpha2.Instances{ + Min: 2, + Max: 4, + }, + CustomMetrics: []*v1alpha2.CustomMetric{ + { + InstanceMetric: &v1alpha2.InstanceMetric{ + MetricName: "some-metric", + AverageValue: "1", + }, + }, + }, + }, + Vertical: &v1alpha2.VerticalScale{ + Cpu: &v1alpha2.ResourceLimits{ + Request: &resource.Quantity{ + String_: "1", + }, + }, + Memory: &v1alpha2.ResourceLimits{ + Request: &resource.Quantity{ + String_: "100M", + }, + }, + }, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + res, err := mergeCapsuleSpec(tt.patch, tt.into) + assert.NoError(t, err) + assert.Equal(t, tt.expected, res) + }) + } +} From efed24d0d5e645ac7c0d7cf2f6988eae92530a9e Mon Sep 17 00:00:00 2001 From: Matias Frank Jensen Date: Sun, 28 Apr 2024 13:27:40 +0200 Subject: [PATCH 2/2] fix --- pkg/api/platform/v1/types.go | 3 ++- pkg/obj/merge.go | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/pkg/api/platform/v1/types.go b/pkg/api/platform/v1/types.go index 449be5bc1..65a75a77d 100644 --- a/pkg/api/platform/v1/types.go +++ b/pkg/api/platform/v1/types.go @@ -99,11 +99,12 @@ type CapsuleSpecExtension struct { // Args is a list of arguments either passed to the Command or if Command // is left empty the arguments will be passed to the ENTRYPOINT of the // docker image. - Args []string `json:"args,omitempty" protobuf:"5"patchStrategy:"replace"` + Args []string `json:"args,omitempty" protobuf:"5" patchStrategy:"replace"` // Interfaces specifies the list of interfaces the the container should // have. Specifying interfaces will create the corresponding kubernetes // Services and Ingresses depending on how the interface is configured. + // nolint:lll Interfaces []v1alpha2.CapsuleInterface `json:"interfaces,omitempty" protobuf:"6" patchMergeKey:"port" patchStrategy:"merge"` // Files is a list of files to mount in the container. These can either be diff --git a/pkg/obj/merge.go b/pkg/obj/merge.go index f7fbbcbad..82fe438a0 100644 --- a/pkg/obj/merge.go +++ b/pkg/obj/merge.go @@ -101,12 +101,14 @@ func convert[T any](obj any) (T, error) { return objT, nil } +// nolint:lll // MergeProjectEnv merges a ProjEnvCapsuleBase into a CapsuleSpecExtension and returns a new object with the merged result // It uses StrategicMergePatch (https://kubernetes.io/docs/tasks/manage-kubernetes-objects/update-api-object-kubectl-patch/) func MergeProjectEnv(patch *platformv1.ProjEnvCapsuleBase, into *platformv1.CapsuleSpecExtension) (*platformv1.CapsuleSpecExtension, error) { return mergeCapsuleSpec(patch, into) } +// nolint:lll // MergeCapsuleSpecExtension merges a CapsuleSpecExtension into another CapsuleSpecExtension and returns a new object with the merged result // It uses StrategicMergePatch (https://kubernetes.io/docs/tasks/manage-kubernetes-objects/update-api-object-kubectl-patch/) func MergeCapsuleSpecExtensions(patch, into *platformv1.CapsuleSpecExtension) (*platformv1.CapsuleSpecExtension, error) {