|
| 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