// Copyright 2020-2026 Project Capsule Authors // SPDX-License-Identifier: Apache-2.0 package client import ( "fmt" "strconv" "strings" "github.com/fluxcd/pkg/apis/kustomize" "github.com/fluxcd/pkg/ssa/jsondiff" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" ) // +kubebuilder:object:generate=true type IgnoreRule struct { // Paths is a list of JSON Pointer (RFC 6901) paths to be excluded from // consideration in a Kubernetes object. // +required Paths []string `json:"paths"` // Target is a selector for specifying Kubernetes objects to which this // rule applies. // If Target is not set, the Paths will be ignored for all Kubernetes // objects within the manifest of the Helm release. // +optional Target *kustomize.Selector `json:"target,omitempty"` } func (i *IgnoreRule) Matches(obj *unstructured.Unstructured) bool { if i == nil || i.Target == nil { return true } sr, err := jsondiff.NewSelectorRegex(&jsondiff.Selector{ Group: i.Target.Group, Version: i.Target.Version, Kind: i.Target.Kind, Namespace: i.Target.Namespace, Name: i.Target.Name, LabelSelector: i.Target.LabelSelector, AnnotationSelector: i.Target.AnnotationSelector, }) if err != nil { return false } return sr.MatchUnstructured(obj) } // jsonPointerGet returns (value, true) if JSON pointer p exists. func JsonPointerGet(obj map[string]any, p string) (any, bool) { if p == "" || p == "/" { return obj, true } parts := strings.Split(p, "/")[1:] cur := any(obj) for _, raw := range parts { key := strings.ReplaceAll(strings.ReplaceAll(raw, "~1", "/"), "~0", "~") switch node := cur.(type) { case map[string]any: next, ok := node[key] if !ok { return nil, false } cur = next case []any: idx, err := strconv.Atoi(key) if err != nil || idx < 0 || idx >= len(node) { return nil, false } cur = node[idx] default: return nil, false } } return cur, true } func JsonPointerSet(obj map[string]any, p string, val any) error { if p == "" || p == "/" { return fmt.Errorf("cannot set root with pointer") } parts := strings.Split(p, "/")[1:] cur := obj for i, raw := range parts { key := strings.ReplaceAll(strings.ReplaceAll(raw, "~1", "/"), "~0", "~") last := i == len(parts)-1 if last { cur[key] = val return nil } nxt, ok := cur[key] if !ok { n := map[string]any{} cur[key] = n cur = n continue } switch m := nxt.(type) { case map[string]any: cur = m default: n := map[string]any{} cur[key] = n cur = n } } return nil } func JsonPointerDelete(obj map[string]any, p string) error { if p == "" || p == "/" { return fmt.Errorf("cannot delete root with pointer") } parts := strings.Split(p, "/")[1:] cur := obj for i, raw := range parts { key := strings.ReplaceAll(strings.ReplaceAll(raw, "~1", "/"), "~0", "~") last := i == len(parts)-1 if last { delete(cur, key) return nil } nxt, ok := cur[key] if !ok { return nil } m, ok := nxt.(map[string]any) if !ok { return nil } cur = m } return nil } func PreserveIgnoredPaths(desired, live map[string]any, ptrs []string) { for _, p := range ptrs { if v, ok := JsonPointerGet(live, p); ok { _ = JsonPointerSet(desired, p, v) } else { _ = JsonPointerDelete(desired, p) } } } func MatchIgnorePaths(rules []IgnoreRule, obj *unstructured.Unstructured) []string { var out []string for _, r := range rules { if !r.Matches(obj) { continue } out = append(out, r.Paths...) } return out }