diff --git a/common-hooks/tls-certificate/internal_tls_test.go b/common-hooks/tls-certificate/internal_tls_test.go index 9c1d18f3..c192329c 100644 --- a/common-hooks/tls-certificate/internal_tls_test.go +++ b/common-hooks/tls-certificate/internal_tls_test.go @@ -32,6 +32,7 @@ import ( "github.com/deckhouse/module-sdk/pkg" "github.com/deckhouse/module-sdk/pkg/certificate" "github.com/deckhouse/module-sdk/pkg/jq" + "github.com/deckhouse/module-sdk/testing/helpers" mock "github.com/deckhouse/module-sdk/testing/mock" ) @@ -430,3 +431,92 @@ func Test_GenSelfSignedTLS(t *testing.T) { assert.NoError(t, err) }) } + +func Test_GenSelfSignedTLS_NewFramework(t *testing.T) { + t.Run("outdated certificate in snapshot", func(t *testing.T) { + tlsConfig := tlscertificate.GenSelfSignedTLSHookConf{ + CN: "cert-name", + TLSSecretName: "secret-webhook-cert", + Namespace: "some-namespace", + BeforeHookCheck: func(_ *pkg.HookInput) bool { + return true + }, + SANs: tlscertificate.DefaultSANs([]string{ + "example-webhook", + "example-webhook.d8-example-module", + "example-webhook.d8-example-module.svc", + "%CLUSTER_DOMAIN%://example-webhook.d8-example-module.svc", + "%PUBLIC_DOMAIN%://example-webhook.d8-example-module.svc", + }), + FullValuesPathPrefix: "d8-example-module.internal.webhookCert", + } + + hookConfig := tlscertificate.GenSelfSignedTLSConfig(tlsConfig) + + snaps := helpers.PrepareHookSnapshots(t, hookConfig, map[string][]string{ + tlscertificate.InternalTLSSnapshotKey: { + ` +apiVersion: v1 +data: + ca.crt: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUJpRENDQVM2Z0F3SUJBZ0lVV3VXcVhMQ1ZnWXVSaVNmZVZvT3RHMG9vU3pZd0NnWUlLb1pJemowRUF3SXcKSWpFZ01CNEdBMVVFQXhNWFpHVmphMmh2ZFhObExtUTRMWE41YzNSbGJTNXpkbU13SGhjTk1qVXdOakU0TVRnMApOREF3V2hjTk16VXdOakUyTVRnME5EQXdXakFpTVNBd0hnWURWUVFERXhka1pXTnJhRzkxYzJVdVpEZ3RjM2x6CmRHVnRMbk4yWXpCWk1CTUdCeXFHU000OUFnRUdDQ3FHU000OUF3RUhBMElBQkM0N3h1WCs2VkhvVVVpaG9VSUsKbzY1QzR2OVU5UjV5dXZLQUN3SlJ3bFoxUGs1MGR2aXFFNHJjbXRsdTRsZkRPSW9qaFlJN3ZUS1piMVByVTY3MgpTSHVqUWpCQU1BNEdBMVVkRHdFQi93UUVBd0lCQmpBUEJnTlZIUk1CQWY4RUJUQURBUUgvTUIwR0ExVWREZ1FXCkJCVDF1U3JvYjNJeHpaNlJOc042dEFjTGlyUGt3REFLQmdncWhrak9QUVFEQWdOSUFEQkZBaUJ6YTVSS3p0RDYKRmJuT2NOTm5ncjhQazhrME4vcGtzTGNiemZXd3NCN0lVQUloQU5tMjNMSzczNVJ0c3F4TGhGNmtyTCtlZmJicgpBbU9jSmpWdGwvNWc5aEhhCi0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K + tls.crt: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUIwakNDQVhpZ0F3SUJBZ0lVVk5uZTJHaE1vV2k2RUdSVlh3bW1Kak1OdU40d0NnWUlLb1pJemowRUF3SXcKSWpFZ01CNEdBMVVFQXhNWFpHVmphMmh2ZFhObExtUTRMWE41YzNSbGJTNXpkbU13SGhjTk1qVXdOakU0TVRnMApOREF3V2hjTk16VXdOakUyTVRnME5EQXdXakFpTVNBd0hnWURWUVFERXhka1pXTnJhRzkxYzJVdVpEZ3RjM2x6CmRHVnRMbk4yWXpCWk1CTUdCeXFHU000OUFnRUdDQ3FHU000OUF3RUhBMElBQlBSdWduRk1yMlFZM0lKSFhvNlAKSEN3ZnYxRVJyS0dQdXZMYVovNHI1QWNmUkhJT3AzYUNvR1pwT0JRbFRUejBSaTE3VDRVeStHdmZxRWg0MHVCNQowYldqZ1lzd2dZZ3dEZ1lEVlIwUEFRSC9CQVFEQWdXZ01Bd0dBMVVkRXdFQi93UUNNQUF3SFFZRFZSME9CQllFCkZPMmpYOXE5MDc0WHdkNU90RFVhOE9vaXJiNEtNRWtHQTFVZEVRUkNNRUNDRjJSbFkydG9iM1Z6WlM1a09DMXoKZVhOMFpXMHVjM1pqZ2lWa1pXTnJhRzkxYzJVdVpEZ3RjM2x6ZEdWdExuTjJZeTVqYkhWemRHVnlMbXh2WTJGcwpNQW9HQ0NxR1NNNDlCQU1DQTBnQU1FVUNJUUN6dEJGbEY2eEpueHYyS3hrNHNqam5mQjQ1YjRmdjNsTFJYVkp6CmZmL2lsZ0lnTXQvM3pHSXRqVndlV3B1eDdyZnN0RkxxalhtZmkwRk4xL3ZwWGtOTEljZz0KLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQo= + tls.key: LS0tLS1CRUdJTiBFQyBQUklWQVRFIEtFWS0tLS0tCk1IY0NBUUVFSUdIUUdieWlDdlV2WDdiUUhBbmZ2YkExbVdBLy9ESjlUdC83WW94akMvZ2dvQW9HQ0NxR1NNNDkKQXdFSG9VUURRZ0FFOUc2Q2NVeXZaQmpjZ2tkZWpvOGNMQisvVVJHc29ZKzY4dHBuL2l2a0J4OUVjZzZuZG9LZwpabWs0RkNWTlBQUkdMWHRQaFRMNGE5K29TSGpTNEhuUnRRPT0KLS0tLS1FTkQgRUMgUFJJVkFURSBLRVktLS0tLQo= +kind: Secret +metadata: + creationTimestamp: "2025-06-18T18:48:49Z" + labels: + app: deckhouse + heritage: deckhouse + module: deckhouse + name: admission-webhook-certs + namespace: d8-system +--- +apiVersion: v1 +data: + ca.crt: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUJpRENDQVM2Z0F3SUJBZ0lVV3VXcVhMQ1ZnWXVSaVNmZVZvT3RHMG9vU3pZd0NnWUlLb1pJemowRUF3SXcKSWpFZ01CNEdBMVVFQXhNWFpHVmphMmh2ZFhObExtUTRMWE41YzNSbGJTNXpkbU13SGhjTk1qVXdOakU0TVRnMApOREF3V2hjTk16VXdOakUyTVRnME5EQXdXakFpTVNBd0hnWURWUVFERXhka1pXTnJhRzkxYzJVdVpEZ3RjM2x6CmRHVnRMbk4yWXpCWk1CTUdCeXFHU000OUFnRUdDQ3FHU000OUF3RUhBMElBQkM0N3h1WCs2VkhvVVVpaG9VSUsKbzY1QzR2OVU5UjV5dXZLQUN3SlJ3bFoxUGs1MGR2aXFFNHJjbXRsdTRsZkRPSW9qaFlJN3ZUS1piMVByVTY3MgpTSHVqUWpCQU1BNEdBMVVkRHdFQi93UUVBd0lCQmpBUEJnTlZIUk1CQWY4RUJUQURBUUgvTUIwR0ExVWREZ1FXCkJCVDF1U3JvYjNJeHpaNlJOc042dEFjTGlyUGt3REFLQmdncWhrak9QUVFEQWdOSUFEQkZBaUJ6YTVSS3p0RDYKRmJuT2NOTm5ncjhQazhrME4vcGtzTGNiemZXd3NCN0lVQUloQU5tMjNMSzczNVJ0c3F4TGhGNmtyTCtlZmJicgpBbU9jSmpWdGwvNWc5aEhhCi0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K + tls.crt: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUIwakNDQVhpZ0F3SUJBZ0lVVk5uZTJHaE1vV2k2RUdSVlh3bW1Kak1OdU40d0NnWUlLb1pJemowRUF3SXcKSWpFZ01CNEdBMVVFQXhNWFpHVmphMmh2ZFhObExtUTRMWE41YzNSbGJTNXpkbU13SGhjTk1qVXdOakU0TVRnMApOREF3V2hjTk16VXdOakUyTVRnME5EQXdXakFpTVNBd0hnWURWUVFERXhka1pXTnJhRzkxYzJVdVpEZ3RjM2x6CmRHVnRMbk4yWXpCWk1CTUdCeXFHU000OUFnRUdDQ3FHU000OUF3RUhBMElBQlBSdWduRk1yMlFZM0lKSFhvNlAKSEN3ZnYxRVJyS0dQdXZMYVovNHI1QWNmUkhJT3AzYUNvR1pwT0JRbFRUejBSaTE3VDRVeStHdmZxRWg0MHVCNQowYldqZ1lzd2dZZ3dEZ1lEVlIwUEFRSC9CQVFEQWdXZ01Bd0dBMVVkRXdFQi93UUNNQUF3SFFZRFZSME9CQllFCkZPMmpYOXE5MDc0WHdkNU90RFVhOE9vaXJiNEtNRWtHQTFVZEVRUkNNRUNDRjJSbFkydG9iM1Z6WlM1a09DMXoKZVhOMFpXMHVjM1pqZ2lWa1pXTnJhRzkxYzJVdVpEZ3RjM2x6ZEdWdExuTjJZeTVqYkhWemRHVnlMbXh2WTJGcwpNQW9HQ0NxR1NNNDlCQU1DQTBnQU1FVUNJUUN6dEJGbEY2eEpueHYyS3hrNHNqam5mQjQ1YjRmdjNsTFJYVkp6CmZmL2lsZ0lnTXQvM3pHSXRqVndlV3B1eDdyZnN0RkxxalhtZmkwRk4xL3ZwWGtOTEljZz0KLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQo= + tls.key: LS0tLS1CRUdJTiBFQyBQUklWQVRFIEtFWS0tLS0tCk1IY0NBUUVFSUdIUUdieWlDdlV2WDdiUUhBbmZ2YkExbVdBLy9ESjlUdC83WW94akMvZ2dvQW9HQ0NxR1NNNDkKQXdFSG9VUURRZ0FFOUc2Q2NVeXZaQmpjZ2tkZWpvOGNMQisvVVJHc29ZKzY4dHBuL2l2a0J4OUVjZzZuZG9LZwpabWs0RkNWTlBQUkdMWHRQaFRMNGE5K29TSGpTNEhuUnRRPT0KLS0tLS1FTkQgRUMgUFJJVkFURSBLRVktLS0tLQo= +kind: Secret +metadata: + creationTimestamp: "2025-06-18T18:48:49Z" + labels: + app: deckhouse + heritage: deckhouse + module: deckhouse + name: admission-webhook-certs + namespace: d8-system +`, + }}) + + values := mock.NewOutputPatchableValuesCollectorMock(t) + + values.GetMock.When("global.discovery.clusterDomain").Then(gjson.Result{Type: gjson.String, Str: "cluster.local"}) + values.GetMock.When("global.modules.publicDomainTemplate").Then(gjson.Result{Type: gjson.String, Str: "%s.127.0.0.1.sslip.io"}) + + values.SetMock.Set(func(path string, value any) { + assert.Equal(t, "d8-example-module.internal.webhookCert", path) + assert.NotEmpty(t, value) + }) + + input := helpers.NewHookInput(t) + input.Snapshots = snaps + input.Values = values + + config := tlscertificate.GenSelfSignedTLSHookConf{ + CN: "cert-name", + TLSSecretName: "secret-webhook-cert", + Namespace: "some-namespace", + SANs: tlscertificate.DefaultSANs([]string{ + "example-webhook", + "example-webhook.d8-example-module", + "example-webhook.d8-example-module.svc", + "%CLUSTER_DOMAIN%://example-webhook.d8-example-module.svc", + "%PUBLIC_DOMAIN%://example-webhook.d8-example-module.svc", + }), + FullValuesPathPrefix: "d8-example-module.internal.webhookCert", + } + + err := tlscertificate.GenSelfSignedTLS(config)(context.Background(), input) + assert.NoError(t, err) + }) +} diff --git a/examples/example-module/hooks/go.mod b/examples/example-module/hooks/go.mod index 2adde6f2..358a278a 100644 --- a/examples/example-module/hooks/go.mod +++ b/examples/example-module/hooks/go.mod @@ -41,6 +41,8 @@ require ( github.com/google/gofuzz v1.2.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/itchyny/gojq v0.12.17 // indirect + github.com/itchyny/timefmt-go v0.1.6 // indirect github.com/jonboulle/clockwork v0.4.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect @@ -62,6 +64,7 @@ require ( github.com/sirupsen/logrus v1.9.3 // indirect github.com/spf13/cobra v1.9.1 // indirect github.com/spf13/pflag v1.0.6 // indirect + github.com/stretchr/testify v1.10.0 // indirect github.com/sylabs/oci-tools v0.7.0 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.0 // indirect diff --git a/examples/example-module/hooks/go.sum b/examples/example-module/hooks/go.sum index 74d92810..0c366cbb 100644 --- a/examples/example-module/hooks/go.sum +++ b/examples/example-module/hooks/go.sum @@ -87,6 +87,10 @@ github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+ github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/itchyny/gojq v0.12.17 h1:8av8eGduDb5+rvEdaOO+zQUjA04MS0m3Ps8HiD+fceg= +github.com/itchyny/gojq v0.12.17/go.mod h1:WBrEMkgAfAGO1LUcGOckBl5O726KPp+OlkKug0I/FEY= +github.com/itchyny/timefmt-go v0.1.6 h1:ia3s54iciXDdzWzwaVKXZPbiXzxxnv1SPGFfM/myJ5Q= +github.com/itchyny/timefmt-go v0.1.6/go.mod h1:RRDZYC5s9ErkjQvTvvU7keJjxUYzIISJGxm9/mAERQg= github.com/jonboulle/clockwork v0.4.0 h1:p4Cf1aMWXnXAUh8lVfewRBx1zaTSYKrKMF2g3ST4RZ4= github.com/jonboulle/clockwork v0.4.0/go.mod h1:xgRqUGwRcjKCO1vbZUEtSLrqKoPSsUpK7fnezOII0kc= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= diff --git a/examples/example-module/hooks/subfolder/patch_hook_test.go b/examples/example-module/hooks/subfolder/patch_hook_test.go index 364f4758..8a81a5cf 100644 --- a/examples/example-module/hooks/subfolder/patch_hook_test.go +++ b/examples/example-module/hooks/subfolder/patch_hook_test.go @@ -4,16 +4,18 @@ import ( "bytes" "context" "strings" + "testing" "time" . "github.com/onsi/ginkgo" . "github.com/onsi/gomega" corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "github.com/deckhouse/deckhouse/pkg/log" "github.com/deckhouse/module-sdk/pkg" - "github.com/deckhouse/module-sdk/testing/mock" + testinghelpers "github.com/deckhouse/module-sdk/testing/helpers" subfolder "example-module/subfolder" ) @@ -21,13 +23,13 @@ import ( var _ = Describe("patch hook", func() { Context("HandlerHookPatch function", func() { var ( - patchCollector *mock.PatchCollectorMock - buf *bytes.Buffer - input *pkg.HookInput + patchCollector pkg.OutputPatchCollector + + buf *bytes.Buffer + input *pkg.HookInput ) BeforeEach(func() { - patchCollector = mock.NewPatchCollectorMock(GinkgoT()) buf = bytes.NewBuffer([]byte{}) input = &pkg.HookInput{ @@ -45,68 +47,53 @@ var _ = Describe("patch hook", func() { }) It("logs hello message and executes patch collector operations", func() { - // Set expectations for Create - patchCollector.CreateMock.Set(func(obj any) { - pod, ok := obj.(*corev1.Pod) - Expect(ok).To(BeTrue()) - Expect(pod.Name).To(Equal("my-first-pod")) - Expect(pod.Namespace).To(Equal("default")) - Expect(pod.Status.Phase).To(Equal(corev1.PodRunning)) - }) - - // Set expectations for CreateOrUpdate - patchCollector.CreateOrUpdateMock.Set(func(obj any) { - pod, ok := obj.(*corev1.Pod) - Expect(ok).To(BeTrue()) - Expect(pod.Name).To(Equal("my-second-pod")) - Expect(pod.Namespace).To(Equal("default")) - Expect(pod.Status.Phase).To(Equal(corev1.PodRunning)) - }) - - // Set expectations for CreateIfNotExists - patchCollector.CreateIfNotExistsMock.Set(func(obj any) { - pod, ok := obj.(*corev1.Pod) - Expect(ok).To(BeTrue()) - Expect(pod.Name).To(Equal("my-third-pod")) - Expect(pod.Namespace).To(Equal("default")) - Expect(pod.Status.Phase).To(Equal(corev1.PodRunning)) - }) - - // Set expectations for Delete - patchCollector.DeleteMock.Set(func(apiVersion, kind, namespace, name string) { - Expect(apiVersion).To(Equal("v1")) - Expect(kind).To(Equal("Pod")) - Expect(namespace).To(Equal("default")) - Expect(name).To(Equal("my-first-pod")) - }) - - // Set expectations for DeleteInBackground - patchCollector.DeleteInBackgroundMock.Set(func(apiVersion, kind, namespace, name string) { - Expect(apiVersion).To(Equal("v1")) - Expect(kind).To(Equal("Pod")) - Expect(namespace).To(Equal("default")) - Expect(name).To(Equal("my-second-pod")) - }) - - // Set expectations for DeleteNonCascading - patchCollector.DeleteNonCascadingMock.Set(func(apiVersion, kind, namespace, name string) { - Expect(apiVersion).To(Equal("v1")) - Expect(kind).To(Equal("Pod")) - Expect(namespace).To(Equal("default")) - Expect(name).To(Equal("my-third-pod")) - }) - - // Set expectations for PatchWithMerge - patchCollector.PatchWithMergeMock.Set(func(mergePatch any, apiVersion, kind, namespace, name string, opts ...pkg.PatchCollectorOption) { - patchMap, ok := mergePatch.(map[string]any) - Expect(ok).To(BeTrue()) - Expect(patchMap).To(HaveKeyWithValue("/status", "newStatus")) - Expect(apiVersion).To(Equal("v1")) - Expect(kind).To(Equal("Pod")) - Expect(namespace).To(Equal("default")) - Expect(name).To(Equal("my-third-pod")) - Expect(len(opts)).To(Equal(2)) - }) + input.PatchCollector = testinghelpers.PreparePatchCollector(&testing.T{}, + testinghelpers.NewCreate( + &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-first-pod", + Namespace: "default", + }, + Status: corev1.PodStatus{ + Phase: corev1.PodRunning, + }, + }), + testinghelpers.NewCreateOrUpdate( + &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-second-pod", + Namespace: "default", + }, + Status: corev1.PodStatus{ + Phase: corev1.PodRunning, + }, + }, + ), + testinghelpers.NewCreateIfNotExists( + &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-third-pod", + Namespace: "default", + }, + Status: corev1.PodStatus{ + Phase: corev1.PodRunning, + }, + }, + ), + testinghelpers.NewDelete( + "v1", "Pod", "default", "my-first-pod", + ), + testinghelpers.NewDeleteInBackground( + "v1", "Pod", "default", "my-second-pod", + ), + testinghelpers.NewDeleteNonCascading( + "v1", "Pod", "default", "my-third-pod", + ), + testinghelpers.NewPatchWithMerge( + map[string]any{"/status": "newStatus"}, + "v1", "Pod", "default", "my-third-pod", + ), + ) // Execute the handler function err := subfolder.HandlerHookPatch(context.Background(), input) diff --git a/testing/framework/init.go b/testing/framework/init.go new file mode 100644 index 00000000..844fb822 --- /dev/null +++ b/testing/framework/init.go @@ -0,0 +1,279 @@ +package framework + +import ( + "context" + "encoding/json" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "gopkg.in/yaml.v3" + + "github.com/deckhouse/deckhouse/pkg/log" + + objectpatch "github.com/deckhouse/module-sdk/internal/object-patch" + "github.com/deckhouse/module-sdk/pkg" + "github.com/deckhouse/module-sdk/pkg/jq" + "github.com/deckhouse/module-sdk/testing/mock" +) + +type HookFramework struct { + t *testing.T + Config *pkg.HookConfig + ReconcileFunc pkg.ReconcileFunc + HookInput *pkg.HookInput +} + +func NewHookFramework(t *testing.T, config *pkg.HookConfig, f pkg.ReconcileFunc) *HookFramework { + return &HookFramework{ + t: t, + Config: config, + ReconcileFunc: f, + HookInput: &pkg.HookInput{ + Snapshots: mock.NewSnapshotsMock(t), + Values: mock.NewOutputPatchableValuesCollectorMock(t), + ConfigValues: mock.NewOutputPatchableValuesCollectorMock(t), + PatchCollector: mock.NewPatchCollectorMock(t), + MetricsCollector: mock.NewMetricsCollectorMock(t), + DC: mock.NewDependencyContainerMock(t), + Logger: log.NewNop(), + }, + } +} + +func (f *HookFramework) GetInput() *pkg.HookInput { + if f.HookInput == nil { + f.t.Fatal("HookInput is not initialized") + } + return f.HookInput +} + +type InputSnapshots map[string][]string + +func (f *HookFramework) PrepareHookSnapshots(config *pkg.HookConfig, inputSnapshots InputSnapshots) { + formattedSnapshots := make(objectpatch.Snapshots, len(inputSnapshots)) + for snapBindingName, snaps := range inputSnapshots { + var ( + err error + query *jq.Query + ) + + for _, v := range config.Kubernetes { + if v.Name == snapBindingName { + fmt.Println("Using JQ filter:", v.JqFilter) + query, err = jq.NewQuery(v.JqFilter) + assert.NoError(f.t, err, "Failed to create JQ query from filter: %s", v.JqFilter) + } + } + + for _, snap := range snaps { + var yml map[string]interface{} + + err := yaml.Unmarshal([]byte(snap), &yml) + assert.NoError(f.t, err, "Failed to unmarshal snapshot YAML: %s", snap) + + jsonSnap, err := json.Marshal(yml) + assert.NoError(f.t, err, "Failed to marshal snapshot to JSON: %s", snap) + + fmt.Println("JSON Snapshot:", string(jsonSnap)) + + res, err := query.FilterStringObject(context.TODO(), string(jsonSnap)) + assert.NoError(f.t, err, "Failed to filter snapshot with JQ query: %s", jsonSnap) + fmt.Println("JSON Snapshot:", res.String()) + + formattedSnapshots[snapBindingName] = append(formattedSnapshots[snapBindingName], objectpatch.Snapshot(res.String())) + } + } + + f.HookInput.Snapshots = formattedSnapshots +} + +func (f *HookFramework) PreparePatchCollector(patches ...any) { + pc := mock.NewPatchCollectorMock(f.t) + + for _, patch := range patches { + switch p := patch.(type) { + case Create: + pc.CreateMock.Set(func(obj any) { + assert.Equal(f.t, p.Object, obj, "Create object mismatch") + }) + case CreateIfNotExists: + pc.CreateIfNotExistsMock.Set(func(obj any) { + assert.Equal(f.t, p.Object, obj, "CreateIfNotExists object mismatch") + }) + case CreateOrUpdate: + pc.CreateOrUpdateMock.Set(func(obj any) { + assert.Equal(f.t, p.Object, obj, "CreateOrUpdate object mismatch") + }) + case Delete: + pc.DeleteMock.Set(func(apiVersion, kind, namespace, name string) { + assert.Equal(f.t, p.APIVersion, apiVersion, "API version mismatch") + assert.Equal(f.t, p.Kind, kind, "Kind mismatch") + assert.Equal(f.t, p.Namespace, namespace, "Namespace mismatch") + assert.Equal(f.t, p.Name, name, "Name mismatch") + }) + case DeleteInBackground: + pc.DeleteInBackgroundMock.Set(func(apiVersion, kind, namespace, name string) { + assert.Equal(f.t, p.APIVersion, apiVersion, "API version mismatch") + assert.Equal(f.t, p.Kind, kind, "Kind mismatch") + assert.Equal(f.t, p.Namespace, namespace, "Namespace mismatch") + assert.Equal(f.t, p.Name, name, "Name mismatch") + }) + case DeleteNonCascading: + pc.DeleteNonCascadingMock.Set(func(apiVersion, kind, namespace, name string) { + assert.Equal(f.t, p.APIVersion, apiVersion, "API version mismatch") + assert.Equal(f.t, p.Kind, kind, "Kind mismatch") + assert.Equal(f.t, p.Namespace, namespace, "Namespace mismatch") + assert.Equal(f.t, p.Name, name, "Name mismatch") + }) + case JSONPatch: + pc.JSONPatchMock.Set(func(jsonPatch any, apiVersion, kind, namespace, name string, _ ...pkg.PatchCollectorOption) { + assert.Equal(f.t, p.JSONPatch, jsonPatch, "JSON patch mismatch") + assert.Equal(f.t, p.APIVersion, apiVersion, "API version mismatch") + assert.Equal(f.t, p.Kind, kind, "Kind mismatch") + assert.Equal(f.t, p.Namespace, namespace, "Namespace mismatch") + assert.Equal(f.t, p.Name, name, "Name mismatch") + }) + case MergePatch: + pc.MergePatchMock.Set(func(mergePatch any, apiVersion, kind, namespace, name string, _ ...pkg.PatchCollectorOption) { + assert.Equal(f.t, p.MergePatch, mergePatch, "Merge patch mismatch") + assert.Equal(f.t, p.APIVersion, apiVersion, "API version mismatch") + assert.Equal(f.t, p.Kind, kind, "Kind mismatch") + assert.Equal(f.t, p.Namespace, namespace, "Namespace mismatch") + assert.Equal(f.t, p.Name, name, "Name mismatch") + }) + case JQFilter: + pc.JQFilterMock.Set(func(jqFilter string, apiVersion, kind, namespace, name string, _ ...pkg.PatchCollectorOption) { + assert.Equal(f.t, p.JQFilter, jqFilter, "JQ filter mismatch") + assert.Equal(f.t, p.APIVersion, apiVersion, "API version mismatch") + assert.Equal(f.t, p.Kind, kind, "Kind mismatch") + assert.Equal(f.t, p.Namespace, namespace, "Namespace mismatch") + assert.Equal(f.t, p.Name, name, "Name mismatch") + }) + case PatchWithJQ: + pc.PatchWithJQMock.Set(func(jqfilter, apiVersion, kind, namespace, name string, _ ...pkg.PatchCollectorOption) { + assert.Equal(f.t, p.JQFilter, jqfilter, "JQ filter mismatch") + assert.Equal(f.t, p.APIVersion, apiVersion, "API version mismatch") + assert.Equal(f.t, p.Kind, kind, "Kind mismatch") + assert.Equal(f.t, p.Namespace, namespace, "Namespace mismatch") + assert.Equal(f.t, p.Name, name, "Name mismatch") + }) + case PatchWithJSON: + pc.PatchWithJSONMock.Set(func(jsonPatch any, apiVersion, kind, namespace, name string, _ ...pkg.PatchCollectorOption) { + assert.Equal(f.t, p.JSONPatch, jsonPatch, "JSON patch mismatch") + assert.Equal(f.t, p.APIVersion, apiVersion, "API version mismatch") + assert.Equal(f.t, p.Kind, kind, "Kind mismatch") + assert.Equal(f.t, p.Namespace, namespace, "Namespace mismatch") + assert.Equal(f.t, p.Name, name, "Name mismatch") + }) + case PatchWithMerge: + pc.PatchWithMergeMock.Set(func(mergePatch any, apiVersion, kind, namespace, name string, _ ...pkg.PatchCollectorOption) { + assert.Equal(f.t, p.MergePatch, mergePatch, "Merge patch mismatch") + assert.Equal(f.t, p.APIVersion, apiVersion, "API version mismatch") + assert.Equal(f.t, p.Kind, kind, "Kind mismatch") + assert.Equal(f.t, p.Namespace, namespace, "Namespace mismatch") + assert.Equal(f.t, p.Name, name, "Name mismatch") + }) + default: + f.t.Fatalf("Unsupported patch type: %T", p) + } + } + + f.HookInput.PatchCollector = pc +} + +// Patch type definitions for different patching operations +type Create struct { + Object any +} + +type CreateIfNotExists struct { + Object any +} + +type CreateOrUpdate struct { + Object any +} + +type Delete struct { + APIVersion string + Kind string + Namespace string + Name string +} + +type DeleteInBackground struct { + APIVersion string + Kind string + Namespace string + Name string +} + +type DeleteNonCascading struct { + APIVersion string + Kind string + Namespace string + Name string +} + +type JSONPatch struct { + JSONPatch any + APIVersion string + Kind string + Namespace string + Name string + Options []pkg.PatchCollectorOption +} + +type MergePatch struct { + MergePatch any + APIVersion string + Kind string + Namespace string + Name string + Options []pkg.PatchCollectorOption +} + +type JQFilter struct { + JQFilter string + APIVersion string + Kind string + Namespace string + Name string + Options []pkg.PatchCollectorOption +} + +type PatchWithJQ struct { + JQFilter string + APIVersion string + Kind string + Namespace string + Name string +} + +type PatchWithJSON struct { + JSONPatch any + APIVersion string + Kind string + Namespace string + Name string + Options []pkg.PatchCollectorOption +} + +type PatchWithMerge struct { + MergePatch any + APIVersion string + Kind string + Namespace string + Name string + Options []pkg.PatchCollectorOption +} + +func (f *HookFramework) Execute(ctx context.Context) error { + err := f.ReconcileFunc(ctx, f.HookInput) + if err != nil { + return fmt.Errorf("execute: %w", err) + } + + return nil +} diff --git a/testing/framework/init_test.go b/testing/framework/init_test.go new file mode 100644 index 00000000..3c4d1f83 --- /dev/null +++ b/testing/framework/init_test.go @@ -0,0 +1,106 @@ +package framework_test + +import ( + "context" + "testing" + + "github.com/deckhouse/module-sdk/pkg" + "github.com/deckhouse/module-sdk/testing/framework" +) + +const node1YAML = ` +--- +apiVersion: v1 +kind: Node +metadata: + name: kube-worker-1 + labels: + node-role: "testrole1" +` +const node2YAML = ` +--- +apiVersion: v1 +kind: Node +metadata: + name: kube-worker-2 + labels: + node-role: "testrole1" +` + +func Test_PrepareHookInput(t *testing.T) { + config := &pkg.HookConfig{ + Metadata: pkg.HookMetadata{ + Name: "test-hook", + }, + Kubernetes: []pkg.KubernetesConfig{ + { + Name: "nodes", + APIVersion: "v1", + Kind: "Node", + JqFilter: ".metadata.name", + }, + }, + } + + f := framework.NewHookFramework(t, config, func(_ context.Context, _ *pkg.HookInput) error { + return nil + }) + + // Test with custom context and snapshots + f.PrepareHookSnapshots(config, framework.InputSnapshots{ + "nodes": { + node1YAML, + node2YAML, + }, + }) + + // Test snapshots are correctly set + if len(f.GetInput().Snapshots.Get("nodes")) != 2 { + t.Errorf("Expected 2 node snapshots, got %d", len(f.GetInput().Snapshots.Get("nodes"))) + } + + // Test with multiple binding types + multiConfig := &pkg.HookConfig{ + Metadata: pkg.HookMetadata{ + Name: "multi-test-hook", + }, + Kubernetes: []pkg.KubernetesConfig{ + { + Name: "nodes", + APIVersion: "v1", + Kind: "Node", + JqFilter: ".metadata.name", + }, + { + Name: "node_roles", + APIVersion: "v1", + Kind: "Node", + JqFilter: ".metadata.labels", + }, + }, + } + + f = framework.NewHookFramework(t, multiConfig, func(_ context.Context, _ *pkg.HookInput) error { + return nil + }) + + f.PrepareHookSnapshots(multiConfig, framework.InputSnapshots{ + "nodes": { + node1YAML, + }, + "node_roles": { + node2YAML, + }, + }) + + // Verify collectors are initialized + if f.GetInput().Values == nil || f.GetInput().ConfigValues == nil || + f.GetInput().PatchCollector == nil || f.GetInput().MetricsCollector == nil { + t.Error("One or more collectors were not initialized") + } + + // Verify logger is initialized + if f.GetInput().Logger == nil { + t.Error("Logger not initialized") + } +} diff --git a/testing/helpers/hook_input.go b/testing/helpers/hook_input.go new file mode 100644 index 00000000..9b85bb54 --- /dev/null +++ b/testing/helpers/hook_input.go @@ -0,0 +1,22 @@ +package helpers + +import ( + "testing" + + "github.com/deckhouse/deckhouse/pkg/log" + + "github.com/deckhouse/module-sdk/pkg" + "github.com/deckhouse/module-sdk/testing/mock" +) + +func NewHookInput(t *testing.T) *pkg.HookInput { + return &pkg.HookInput{ + Snapshots: mock.NewSnapshotsMock(t), + Values: mock.NewOutputPatchableValuesCollectorMock(t), + ConfigValues: mock.NewOutputPatchableValuesCollectorMock(t), + PatchCollector: mock.NewPatchCollectorMock(t), + MetricsCollector: mock.NewMetricsCollectorMock(t), + DC: mock.NewDependencyContainerMock(t), + Logger: log.NewNop(), + } +} diff --git a/testing/helpers/patch_collector.go b/testing/helpers/patch_collector.go new file mode 100644 index 00000000..86b5e696 --- /dev/null +++ b/testing/helpers/patch_collector.go @@ -0,0 +1,237 @@ +// nolint: revive +package helpers + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/deckhouse/module-sdk/pkg" + "github.com/deckhouse/module-sdk/testing/mock" +) + +type typedPatch struct { + wrapped any +} + +func PreparePatchCollector(t *testing.T, patches ...*typedPatch) pkg.OutputPatchCollector { + pc := mock.NewPatchCollectorMock(t) + + for _, patch := range patches { + switch p := patch.wrapped.(type) { + case *create: + pc.CreateMock.Set(func(obj any) { + assert.Equal(t, p.Object, obj, "Create object mismatch") + }) + case *createIfNotExists: + pc.CreateIfNotExistsMock.Set(func(obj any) { + assert.Equal(t, p.Object, obj, "CreateIfNotExists object mismatch") + }) + case *createOrUpdate: + pc.CreateOrUpdateMock.Set(func(obj any) { + assert.Equal(t, p.Object, obj, "CreateOrUpdate object mismatch") + }) + case *delete: + pc.DeleteMock.Set(func(apiVersion, kind, namespace, name string) { + assert.Equal(t, p.APIVersion, apiVersion, "API version mismatch") + assert.Equal(t, p.Kind, kind, "Kind mismatch") + assert.Equal(t, p.Namespace, namespace, "Namespace mismatch") + assert.Equal(t, p.Name, name, "Name mismatch") + }) + case *deleteInBackground: + pc.DeleteInBackgroundMock.Set(func(apiVersion, kind, namespace, name string) { + assert.Equal(t, p.APIVersion, apiVersion, "API version mismatch") + assert.Equal(t, p.Kind, kind, "Kind mismatch") + assert.Equal(t, p.Namespace, namespace, "Namespace mismatch") + assert.Equal(t, p.Name, name, "Name mismatch") + }) + case *deleteNonCascading: + pc.DeleteNonCascadingMock.Set(func(apiVersion, kind, namespace, name string) { + assert.Equal(t, p.APIVersion, apiVersion, "API version mismatch") + assert.Equal(t, p.Kind, kind, "Kind mismatch") + assert.Equal(t, p.Namespace, namespace, "Namespace mismatch") + assert.Equal(t, p.Name, name, "Name mismatch") + }) + case *jsonPatch: + pc.JSONPatchMock.Set(func(jsonPatch any, apiVersion, kind, namespace, name string, opts ...pkg.PatchCollectorOption) { + assert.Equal(t, p.JSONPatch, jsonPatch, "JSON patch mismatch") + assert.Equal(t, p.APIVersion, apiVersion, "API version mismatch") + assert.Equal(t, p.Kind, kind, "Kind mismatch") + assert.Equal(t, p.Namespace, namespace, "Namespace mismatch") + assert.Equal(t, p.Name, name, "Name mismatch") + }) + case *mergePatch: + pc.MergePatchMock.Set(func(mergePatch any, apiVersion, kind, namespace, name string, opts ...pkg.PatchCollectorOption) { + assert.Equal(t, p.MergePatch, mergePatch, "Merge patch mismatch") + assert.Equal(t, p.APIVersion, apiVersion, "API version mismatch") + assert.Equal(t, p.Kind, kind, "Kind mismatch") + assert.Equal(t, p.Namespace, namespace, "Namespace mismatch") + assert.Equal(t, p.Name, name, "Name mismatch") + }) + case *jqFilter: + pc.JQFilterMock.Set(func(jqFilter string, apiVersion, kind, namespace, name string, opts ...pkg.PatchCollectorOption) { + assert.Equal(t, p.JQFilter, jqFilter, "JQ filter mismatch") + assert.Equal(t, p.APIVersion, apiVersion, "API version mismatch") + assert.Equal(t, p.Kind, kind, "Kind mismatch") + assert.Equal(t, p.Namespace, namespace, "Namespace mismatch") + assert.Equal(t, p.Name, name, "Name mismatch") + }) + case *patchWithJQ: + pc.PatchWithJQMock.Set(func(jqfilter, apiVersion, kind, namespace, name string, opts ...pkg.PatchCollectorOption) { + assert.Equal(t, p.JQFilter, jqfilter, "JQ filter mismatch") + assert.Equal(t, p.APIVersion, apiVersion, "API version mismatch") + assert.Equal(t, p.Kind, kind, "Kind mismatch") + assert.Equal(t, p.Namespace, namespace, "Namespace mismatch") + assert.Equal(t, p.Name, name, "Name mismatch") + }) + case *patchWithJSON: + pc.PatchWithJSONMock.Set(func(jsonPatch any, apiVersion, kind, namespace, name string, opts ...pkg.PatchCollectorOption) { + assert.Equal(t, p.JSONPatch, jsonPatch, "JSON patch mismatch") + assert.Equal(t, p.APIVersion, apiVersion, "API version mismatch") + assert.Equal(t, p.Kind, kind, "Kind mismatch") + assert.Equal(t, p.Namespace, namespace, "Namespace mismatch") + assert.Equal(t, p.Name, name, "Name mismatch") + }) + case *patchWithMerge: + pc.PatchWithMergeMock.Set(func(mergePatch any, apiVersion, kind, namespace, name string, opts ...pkg.PatchCollectorOption) { + assert.Equal(t, p.MergePatch, mergePatch, "Merge patch mismatch") + assert.Equal(t, p.APIVersion, apiVersion, "API version mismatch") + assert.Equal(t, p.Kind, kind, "Kind mismatch") + assert.Equal(t, p.Namespace, namespace, "Namespace mismatch") + assert.Equal(t, p.Name, name, "Name mismatch") + }) + default: + t.Fatalf("Unsupported patch type: %T", p) + } + } + + return pc +} +func NewCreate(obj any) *typedPatch { + return &typedPatch{wrapped: &create{Object: obj}} +} + +type create struct { + Object any +} + +func NewCreateIfNotExists(obj any) *typedPatch { + return &typedPatch{wrapped: &createIfNotExists{Object: obj}} +} + +type createIfNotExists struct { + Object any +} + +func NewCreateOrUpdate(obj any) *typedPatch { + return &typedPatch{wrapped: &createOrUpdate{Object: obj}} +} + +type createOrUpdate struct { + Object any +} + +func NewDelete(apiVersion, kind, namespace, name string) *typedPatch { + return &typedPatch{wrapped: &delete{APIVersion: apiVersion, Kind: kind, Namespace: namespace, Name: name}} +} + +type delete struct { + APIVersion string + Kind string + Namespace string + Name string +} + +func NewDeleteInBackground(apiVersion, kind, namespace, name string) *typedPatch { + return &typedPatch{wrapped: &deleteInBackground{APIVersion: apiVersion, Kind: kind, Namespace: namespace, Name: name}} +} + +type deleteInBackground struct { + APIVersion string + Kind string + Namespace string + Name string +} + +func NewDeleteNonCascading(apiVersion, kind, namespace, name string) *typedPatch { + return &typedPatch{wrapped: &deleteNonCascading{APIVersion: apiVersion, Kind: kind, Namespace: namespace, Name: name}} +} + +type deleteNonCascading struct { + APIVersion string + Kind string + Namespace string + Name string +} + +func NewJSONPatch(jsonPatchRaw any, apiVersion, kind, namespace, name string) *typedPatch { + return &typedPatch{wrapped: &jsonPatch{JSONPatch: jsonPatchRaw, APIVersion: apiVersion, Kind: kind, Namespace: namespace, Name: name}} +} + +type jsonPatch struct { + JSONPatch any + APIVersion string + Kind string + Namespace string + Name string +} + +func NewMergePatch(mergePatchRaw any, apiVersion, kind, namespace, name string) *typedPatch { + return &typedPatch{wrapped: &mergePatch{MergePatch: mergePatchRaw, APIVersion: apiVersion, Kind: kind, Namespace: namespace, Name: name}} +} + +type mergePatch struct { + MergePatch any + APIVersion string + Kind string + Namespace string + Name string +} + +func NewJQFilter(jqFilterStr, apiVersion, kind, namespace, name string) *typedPatch { + return &typedPatch{wrapped: &jqFilter{JQFilter: jqFilterStr, APIVersion: apiVersion, Kind: kind, Namespace: namespace, Name: name}} +} + +type jqFilter struct { + JQFilter string + APIVersion string + Kind string + Namespace string + Name string +} + +func NewPatchWithJQ(jqFilter, apiVersion, kind, namespace, name string) *typedPatch { + return &typedPatch{wrapped: &patchWithJQ{JQFilter: jqFilter, APIVersion: apiVersion, Kind: kind, Namespace: namespace, Name: name}} +} + +type patchWithJQ struct { + JQFilter string + APIVersion string + Kind string + Namespace string + Name string +} + +func NewPatchWithJSON(jsonPatch any, apiVersion, kind, namespace, name string) *typedPatch { + return &typedPatch{wrapped: &patchWithJSON{JSONPatch: jsonPatch, APIVersion: apiVersion, Kind: kind, Namespace: namespace, Name: name}} +} + +type patchWithJSON struct { + JSONPatch any + APIVersion string + Kind string + Namespace string + Name string +} + +func NewPatchWithMerge(mergePatch any, apiVersion, kind, namespace, name string) *typedPatch { + return &typedPatch{wrapped: &patchWithMerge{MergePatch: mergePatch, APIVersion: apiVersion, Kind: kind, Namespace: namespace, Name: name}} +} + +type patchWithMerge struct { + MergePatch any + APIVersion string + Kind string + Namespace string + Name string +} diff --git a/testing/helpers/snapshots.go b/testing/helpers/snapshots.go new file mode 100644 index 00000000..fdb6aa4f --- /dev/null +++ b/testing/helpers/snapshots.go @@ -0,0 +1,62 @@ +package helpers + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "gopkg.in/yaml.v3" + + objectpatch "github.com/deckhouse/module-sdk/internal/object-patch" + "github.com/deckhouse/module-sdk/pkg" + "github.com/deckhouse/module-sdk/pkg/jq" +) + +// input snapshots must be in the format: snapshotName: ["yaml1", "yaml2", ...] +// it can handle multiple YAML documents in a single string, separated by "---" +// it will apply the JQ filter from the hook config to each snapshot +// and return a map of snapshot names to their filtered JSON representations +func PrepareHookSnapshots(t *testing.T, config *pkg.HookConfig, inputSnapshots map[string][]string) pkg.Snapshots { + formattedSnapshots := make(objectpatch.Snapshots, len(inputSnapshots)) + for snapBindingName, rawSnaps := range inputSnapshots { + var ( + err error + query *jq.Query + ) + + for _, v := range config.Kubernetes { + if v.Name == snapBindingName { + fmt.Println("Using JQ filter:", v.JqFilter) + query, err = jq.NewQuery(v.JqFilter) + assert.NoError(t, err, "Failed to create JQ query from filter: %s", v.JqFilter) + } + } + + for _, rawSnap := range rawSnaps { + snaps := strings.Split(rawSnap, "---") + + for _, snap := range snaps { + var yml map[string]interface{} + + err := yaml.Unmarshal([]byte(snap), &yml) + assert.NoError(t, err, "Failed to unmarshal snapshot YAML: %s", snap) + + jsonSnap, err := json.Marshal(yml) + assert.NoError(t, err, "Failed to marshal snapshot to JSON: %s", snap) + + fmt.Println("JSON Snapshot:", string(jsonSnap)) + + res, err := query.FilterStringObject(context.TODO(), string(jsonSnap)) + assert.NoError(t, err, "Failed to filter snapshot with JQ query: %s", jsonSnap) + fmt.Println("JSON Snapshot:", res.String()) + + formattedSnapshots[snapBindingName] = append(formattedSnapshots[snapBindingName], objectpatch.Snapshot(res.String())) + } + } + } + + return formattedSnapshots +}