Skip to content

Commit 36eb8e1

Browse files
committed
add functional testing framework
Signed-off-by: Pavel Okhlopkov <pavel.okhlopkov@flant.com>
1 parent 406106a commit 36eb8e1

15 files changed

Lines changed: 2188 additions & 0 deletions

File tree

examples/example-module/hooks/go.mod

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ require (
3939
github.com/google/go-containerregistry v0.20.6 // indirect
4040
github.com/google/uuid v1.6.0 // indirect
4141
github.com/inconshreveable/mousetrap v1.1.0 // indirect
42+
github.com/itchyny/gojq v0.12.17 // indirect
43+
github.com/itchyny/timefmt-go v0.1.6 // indirect
4244
github.com/jonboulle/clockwork v0.4.0 // indirect
4345
github.com/josharian/intern v1.0.0 // indirect
4446
github.com/json-iterator/go v1.1.12 // indirect
@@ -60,6 +62,7 @@ require (
6062
github.com/sirupsen/logrus v1.9.3 // indirect
6163
github.com/spf13/cobra v1.9.1 // indirect
6264
github.com/spf13/pflag v1.0.6 // indirect
65+
github.com/stretchr/testify v1.10.0 // indirect
6366
github.com/sylabs/oci-tools v0.7.0 // indirect
6467
github.com/tidwall/match v1.1.1 // indirect
6568
github.com/tidwall/pretty v1.2.0 // indirect

examples/example-module/hooks/go.sum

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,10 @@ github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+
8585
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
8686
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
8787
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
88+
github.com/itchyny/gojq v0.12.17 h1:8av8eGduDb5+rvEdaOO+zQUjA04MS0m3Ps8HiD+fceg=
89+
github.com/itchyny/gojq v0.12.17/go.mod h1:WBrEMkgAfAGO1LUcGOckBl5O726KPp+OlkKug0I/FEY=
90+
github.com/itchyny/timefmt-go v0.1.6 h1:ia3s54iciXDdzWzwaVKXZPbiXzxxnv1SPGFfM/myJ5Q=
91+
github.com/itchyny/timefmt-go v0.1.6/go.mod h1:RRDZYC5s9ErkjQvTvvU7keJjxUYzIISJGxm9/mAERQg=
8892
github.com/jonboulle/clockwork v0.4.0 h1:p4Cf1aMWXnXAUh8lVfewRBx1zaTSYKrKMF2g3ST4RZ4=
8993
github.com/jonboulle/clockwork v0.4.0/go.mod h1:xgRqUGwRcjKCO1vbZUEtSLrqKoPSsUpK7fnezOII0kc=
9094
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=

testing/framework/apply.go

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
package framework
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
8+
apierrors "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+
14+
"github.com/deckhouse/module-sdk/pkg"
15+
sdkjq "github.com/deckhouse/module-sdk/pkg/jq"
16+
)
17+
18+
// applyPatchesToCluster applies the records collected from the hook to the
19+
// fake cluster, mutating it in-place. It is called by RunHook after the
20+
// hook handler finishes.
21+
func (h *HookExecutionConfig) applyPatchesToCluster() error {
22+
if h.patchCollector == nil {
23+
return nil
24+
}
25+
26+
ctx := context.Background()
27+
for _, p := range h.patchCollector.Records() {
28+
if err := h.applyPatch(ctx, p); err != nil {
29+
return fmt.Errorf("apply %s patch %s/%s: %w", p.Type, p.Namespace, p.Name, err)
30+
}
31+
}
32+
return nil
33+
}
34+
35+
func (h *HookExecutionConfig) applyPatch(ctx context.Context, p RecordedPatch) error {
36+
switch p.Type {
37+
case PatchTypeCreate, PatchTypeCreateOrUpdate, PatchTypeCreateIfNotExists:
38+
return h.applyCreate(ctx, p)
39+
case PatchTypeDelete, PatchTypeDeleteInBackground, PatchTypeDeleteNonCascading:
40+
return h.applyDelete(ctx, p)
41+
case PatchTypeJSONPatch:
42+
return h.applyJSONPatch(ctx, p)
43+
case PatchTypeMergePatch:
44+
return h.applyMergePatch(ctx, p)
45+
case PatchTypeJQFilter:
46+
return h.applyJQFilter(ctx, p)
47+
}
48+
return fmt.Errorf("unknown patch type %q", p.Type)
49+
}
50+
51+
func (h *HookExecutionConfig) applyCreate(ctx context.Context, p RecordedPatch) error {
52+
u, err := toUnstructured(p.Object)
53+
if err != nil {
54+
return fmt.Errorf("convert object: %w", err)
55+
}
56+
57+
gvr, err := h.gvrFor(u.GetAPIVersion(), u.GetKind())
58+
if err != nil {
59+
return err
60+
}
61+
ri := h.resourceInterface(gvr, u.GetNamespace())
62+
63+
switch p.Type {
64+
case PatchTypeCreate:
65+
_, err := ri.Create(ctx, u, metav1.CreateOptions{})
66+
return err
67+
case PatchTypeCreateIfNotExists:
68+
_, err := ri.Create(ctx, u, metav1.CreateOptions{})
69+
if err != nil && apierrors.IsAlreadyExists(err) {
70+
return nil
71+
}
72+
return err
73+
case PatchTypeCreateOrUpdate:
74+
_, err := ri.Create(ctx, u, metav1.CreateOptions{})
75+
if err == nil {
76+
return nil
77+
}
78+
if !apierrors.IsAlreadyExists(err) {
79+
return err
80+
}
81+
// Pull current resourceVersion to allow Update.
82+
current, err := ri.Get(ctx, u.GetName(), metav1.GetOptions{})
83+
if err != nil {
84+
return err
85+
}
86+
u.SetResourceVersion(current.GetResourceVersion())
87+
_, err = ri.Update(ctx, u, metav1.UpdateOptions{})
88+
return err
89+
}
90+
return nil
91+
}
92+
93+
func (h *HookExecutionConfig) applyDelete(ctx context.Context, p RecordedPatch) error {
94+
gvr, err := h.gvrFor(p.APIVersion, p.Kind)
95+
if err != nil {
96+
return err
97+
}
98+
err = h.resourceInterface(gvr, p.Namespace).Delete(ctx, p.Name, metav1.DeleteOptions{})
99+
if err != nil && apierrors.IsNotFound(err) {
100+
return nil
101+
}
102+
return err
103+
}
104+
105+
func (h *HookExecutionConfig) applyJSONPatch(ctx context.Context, p RecordedPatch) error {
106+
gvr, err := h.gvrFor(p.APIVersion, p.Kind)
107+
if err != nil {
108+
return err
109+
}
110+
data, err := patchPayloadAsJSON(p.JSONPatch)
111+
if err != nil {
112+
return fmt.Errorf("marshal json patch: %w", err)
113+
}
114+
_, err = h.resourceInterface(gvr, p.Namespace).Patch(ctx, p.Name, types.JSONPatchType, data, metav1.PatchOptions{})
115+
if err != nil && apierrors.IsNotFound(err) && shouldIgnoreMissing(p.Options) {
116+
return nil
117+
}
118+
return err
119+
}
120+
121+
func (h *HookExecutionConfig) applyMergePatch(ctx context.Context, p RecordedPatch) error {
122+
gvr, err := h.gvrFor(p.APIVersion, p.Kind)
123+
if err != nil {
124+
return err
125+
}
126+
data, err := patchPayloadAsJSON(p.MergePatch)
127+
if err != nil {
128+
return fmt.Errorf("marshal merge patch: %w", err)
129+
}
130+
_, err = h.resourceInterface(gvr, p.Namespace).Patch(ctx, p.Name, types.MergePatchType, data, metav1.PatchOptions{})
131+
if err != nil && apierrors.IsNotFound(err) && shouldIgnoreMissing(p.Options) {
132+
return nil
133+
}
134+
return err
135+
}
136+
137+
func (h *HookExecutionConfig) applyJQFilter(ctx context.Context, p RecordedPatch) error {
138+
gvr, err := h.gvrFor(p.APIVersion, p.Kind)
139+
if err != nil {
140+
return err
141+
}
142+
ri := h.resourceInterface(gvr, p.Namespace)
143+
current, err := ri.Get(ctx, p.Name, metav1.GetOptions{})
144+
if err != nil {
145+
if apierrors.IsNotFound(err) && shouldIgnoreMissing(p.Options) {
146+
return nil
147+
}
148+
return err
149+
}
150+
q, err := sdkjq.NewQuery(p.JQFilter)
151+
if err != nil {
152+
return fmt.Errorf("compile jq: %w", err)
153+
}
154+
res, err := q.FilterObject(ctx, current.UnstructuredContent())
155+
if err != nil {
156+
return fmt.Errorf("apply jq: %w", err)
157+
}
158+
var patched map[string]any
159+
if err := json.Unmarshal([]byte(res.String()), &patched); err != nil {
160+
return fmt.Errorf("decode jq result: %w", err)
161+
}
162+
current.Object = patched
163+
_, err = ri.Update(ctx, current, metav1.UpdateOptions{})
164+
return err
165+
}
166+
167+
// patchPayloadAsJSON normalizes the patch payload to JSON bytes. The hook may
168+
// pass a string, []byte, or any JSON-serializable value.
169+
func patchPayloadAsJSON(payload any) ([]byte, error) {
170+
switch v := payload.(type) {
171+
case nil:
172+
return nil, fmt.Errorf("nil patch payload")
173+
case []byte:
174+
return v, nil
175+
case string:
176+
return []byte(v), nil
177+
default:
178+
return json.Marshal(v)
179+
}
180+
}
181+
182+
func toUnstructured(obj any) (*unstructured.Unstructured, error) {
183+
switch v := obj.(type) {
184+
case *unstructured.Unstructured:
185+
return v, nil
186+
case unstructured.Unstructured:
187+
return &v, nil
188+
case map[string]any:
189+
return &unstructured.Unstructured{Object: v}, nil
190+
case runtime.Object:
191+
content, err := runtime.DefaultUnstructuredConverter.ToUnstructured(v)
192+
if err != nil {
193+
return nil, err
194+
}
195+
return &unstructured.Unstructured{Object: content}, nil
196+
}
197+
// Fall back to round-tripping via JSON.
198+
data, err := json.Marshal(obj)
199+
if err != nil {
200+
return nil, fmt.Errorf("marshal: %w", err)
201+
}
202+
out := map[string]any{}
203+
if err := json.Unmarshal(data, &out); err != nil {
204+
return nil, fmt.Errorf("unmarshal: %w", err)
205+
}
206+
return &unstructured.Unstructured{Object: out}, nil
207+
}
208+
209+
// shouldIgnoreMissing inspects PatchCollectorOptions to detect WithIgnoreMissingObject(true).
210+
// Because the option is opaque (an applier interface), we use a small helper applier to capture it.
211+
func shouldIgnoreMissing(opts []pkg.PatchCollectorOption) bool {
212+
flag := &flagApplier{}
213+
for _, o := range opts {
214+
o.Apply(flag)
215+
}
216+
return flag.ignoreMissing
217+
}
218+
219+
type flagApplier struct {
220+
subresource string
221+
ignoreMissing bool
222+
ignoreHookErr bool
223+
}
224+
225+
func (f *flagApplier) WithSubresource(s string) { f.subresource = s }
226+
func (f *flagApplier) WithIgnoreMissingObject(b bool) { f.ignoreMissing = b }
227+
func (f *flagApplier) WithIgnoreHookError(b bool) { f.ignoreHookErr = b }
228+
229+
// pkg import below is used for the flagApplier interface assertion.
230+
// Keep this import here so the file is self-contained.
231+
//
232+
233+
var _ = func() any { var _ pkg.PatchCollectorOptionApplier = (*flagApplier)(nil); return nil }()

0 commit comments

Comments
 (0)