mirror of
https://github.com/lukaszraczylo/kubemirror.git
synced 2026-06-08 22:59:22 +00:00
75f7c18f3c
M7: extractUnstructuredContent only hashed 'spec' when present, dropping all other top-level content fields. Resources with both spec and data (or any non-spec content) silently drifted until the next 10m resync. Now hashes every non-Kubernetes-managed top-level field, matching the fields updateUnstructuredMirror copies. M6: when a source has a transform annotation, also hash the source's labels and annotations (filtered of kubemirror.raczylo.com/* keys to avoid the controller's own bookkeeping churning the hash). Templates read these via TransformContext; without this a label change wouldn't re-render the transformed mirror. H3: text/template.Execute is not context-aware, so applyTemplateRule's timeout cancels the select but leaks the executor goroutine. Added a process-wide semaphore (cap 64) so a runaway template can't spawn an unbounded number of stuck goroutines on every reconcile. M4: zap dev mode (DPanic-on-error, console output, stacktraces on warning) was hardcoded on. Defaulted to production; --zap-devel flag remains for opt-in. M5: WaitForInitialDiscovery was anchored on context.Background() with its own WithTimeout, so SIGTERM during startup couldn't abort the wait. Now anchors on signalCtx.
572 lines
16 KiB
Go
572 lines
16 KiB
Go
package transformer
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"strings"
|
|
"text/template"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
|
"k8s.io/apimachinery/pkg/runtime"
|
|
|
|
"github.com/lukaszraczylo/kubemirror/pkg/constants"
|
|
)
|
|
|
|
// maxConcurrentTemplateExecutions caps the number of in-flight template
|
|
// executions across the process. text/template.Execute is not context-aware,
|
|
// so when applyTemplateRule times out the executor goroutine continues to
|
|
// run until the template returns on its own. This semaphore bounds the
|
|
// damage from a pathological template (e.g. {{ range }} that never
|
|
// terminates): once the cap is hit, applyTemplateRule fails fast instead
|
|
// of leaking another runaway goroutine. The cap is intentionally generous
|
|
// — normal workloads should never approach it.
|
|
const maxConcurrentTemplateExecutions = 64
|
|
|
|
var templateExecSemaphore = make(chan struct{}, maxConcurrentTemplateExecutions)
|
|
|
|
// Transformer applies transformation rules to Kubernetes resources.
|
|
type Transformer struct {
|
|
options TransformOptions
|
|
}
|
|
|
|
// NewTransformer creates a new transformer with the given options.
|
|
func NewTransformer(options TransformOptions) *Transformer {
|
|
return &Transformer{
|
|
options: options,
|
|
}
|
|
}
|
|
|
|
// NewDefaultTransformer creates a transformer with default options.
|
|
func NewDefaultTransformer() *Transformer {
|
|
return NewTransformer(DefaultTransformOptions())
|
|
}
|
|
|
|
// Transform applies transformation rules to a resource.
|
|
// It returns the transformed resource and any errors encountered.
|
|
func (t *Transformer) Transform(source runtime.Object, ctx TransformContext) (runtime.Object, error) {
|
|
// Convert to unstructured for easier manipulation
|
|
unstructuredObj, err := runtime.DefaultUnstructuredConverter.ToUnstructured(source)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to convert to unstructured: %w", err)
|
|
}
|
|
|
|
u := &unstructured.Unstructured{Object: unstructuredObj}
|
|
|
|
// Get transformation rules from annotations
|
|
rules, err := t.parseTransformRules(u)
|
|
if err != nil {
|
|
if t.isStrictMode(u) {
|
|
return nil, fmt.Errorf("failed to parse transformation rules: %w", err)
|
|
}
|
|
// Non-strict mode: log warning and return original
|
|
return source, nil
|
|
}
|
|
|
|
if len(rules.Rules) == 0 {
|
|
// No transformation rules
|
|
return source, nil
|
|
}
|
|
|
|
// Validate rules
|
|
if err := t.validateRules(rules); err != nil {
|
|
if t.isStrictMode(u) {
|
|
return nil, fmt.Errorf("invalid transformation rules: %w", err)
|
|
}
|
|
return source, nil
|
|
}
|
|
|
|
// Apply each rule
|
|
for i, rule := range rules.Rules {
|
|
if err := t.applyRule(u, rule, ctx); err != nil {
|
|
if t.isStrictMode(u) {
|
|
return nil, fmt.Errorf("failed to apply rule %d (%s): %w", i+1, rule.Path, err)
|
|
}
|
|
// Non-strict mode: continue with next rule
|
|
continue
|
|
}
|
|
}
|
|
|
|
return u, nil
|
|
}
|
|
|
|
// parseTransformRules extracts and parses transformation rules from resource annotations.
|
|
func (t *Transformer) parseTransformRules(u *unstructured.Unstructured) (*TransformRules, error) {
|
|
annotations := u.GetAnnotations()
|
|
if annotations == nil {
|
|
return &TransformRules{}, nil
|
|
}
|
|
|
|
rulesYAML, exists := annotations[constants.AnnotationTransform]
|
|
if !exists || rulesYAML == "" {
|
|
return &TransformRules{}, nil
|
|
}
|
|
|
|
// Check size limit
|
|
if len(rulesYAML) > t.options.MaxRuleSize {
|
|
return nil, fmt.Errorf("transformation rules exceed maximum size of %d bytes", t.options.MaxRuleSize)
|
|
}
|
|
|
|
var rules TransformRules
|
|
if err := yaml.Unmarshal([]byte(rulesYAML), &rules); err != nil {
|
|
return nil, fmt.Errorf("failed to parse YAML: %w", err)
|
|
}
|
|
|
|
return &rules, nil
|
|
}
|
|
|
|
// validateRules validates all transformation rules.
|
|
func (t *Transformer) validateRules(rules *TransformRules) error {
|
|
if len(rules.Rules) > t.options.MaxRules {
|
|
return fmt.Errorf("too many rules (%d), maximum is %d", len(rules.Rules), t.options.MaxRules)
|
|
}
|
|
|
|
for i, rule := range rules.Rules {
|
|
if err := rule.Validate(); err != nil {
|
|
return fmt.Errorf("rule %d: %w", i+1, err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// applyRule applies a single transformation rule to the resource.
|
|
func (t *Transformer) applyRule(u *unstructured.Unstructured, rule Rule, ctx TransformContext) error {
|
|
// Check if rule should apply to this target namespace
|
|
if !matchesNamespacePattern(rule, ctx.TargetNamespace) {
|
|
// Rule doesn't apply to this namespace - skip silently
|
|
return nil
|
|
}
|
|
|
|
switch rule.Type() {
|
|
case RuleTypeValue:
|
|
return t.applyValueRule(u, rule, ctx)
|
|
case RuleTypeTemplate:
|
|
return t.applyTemplateRule(u, rule, ctx)
|
|
case RuleTypeMerge:
|
|
return t.applyMergeRule(u, rule, ctx)
|
|
case RuleTypeDelete:
|
|
return t.applyDeleteRule(u, rule, ctx)
|
|
default:
|
|
return fmt.Errorf("unknown rule type: %s", rule.Type())
|
|
}
|
|
}
|
|
|
|
// applyValueRule sets a field to a static value.
|
|
func (t *Transformer) applyValueRule(u *unstructured.Unstructured, rule Rule, ctx TransformContext) error {
|
|
if rule.Value == nil {
|
|
return fmt.Errorf("value rule has nil value")
|
|
}
|
|
|
|
pathParts := parsePath(rule.Path)
|
|
if len(pathParts) == 0 {
|
|
return fmt.Errorf("empty path")
|
|
}
|
|
|
|
return setNestedField(u.Object, pathParts, *rule.Value)
|
|
}
|
|
|
|
// applyTemplateRule uses Go templates to generate the value.
|
|
func (t *Transformer) applyTemplateRule(u *unstructured.Unstructured, rule Rule, ctx TransformContext) error {
|
|
if rule.Template == nil {
|
|
return fmt.Errorf("template rule has nil template")
|
|
}
|
|
|
|
// Create template with timeout
|
|
tmpl, err := template.New("transform").Funcs(templateFuncs()).Parse(*rule.Template)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to parse template: %w", err)
|
|
}
|
|
|
|
// Acquire a slot in the global template-execution semaphore. If saturated,
|
|
// fail fast rather than spawning yet another goroutine that may leak when
|
|
// it times out (text/template is not context-aware so timed-out goroutines
|
|
// continue running until the template returns).
|
|
select {
|
|
case templateExecSemaphore <- struct{}{}:
|
|
defer func() { <-templateExecSemaphore }()
|
|
default:
|
|
return fmt.Errorf("template execution rejected: %d concurrent executions in flight, "+
|
|
"likely indicates one or more runaway templates leaking goroutines",
|
|
maxConcurrentTemplateExecutions)
|
|
}
|
|
|
|
// Execute template with timeout
|
|
ctxWithTimeout, cancel := context.WithTimeout(context.Background(), t.options.TemplateTimeout)
|
|
defer cancel()
|
|
|
|
resultChan := make(chan string, 1)
|
|
errChan := make(chan error, 1)
|
|
|
|
go func() {
|
|
var buf bytes.Buffer
|
|
if err := tmpl.Execute(&buf, ctx); err != nil {
|
|
errChan <- err
|
|
return
|
|
}
|
|
resultChan <- buf.String()
|
|
}()
|
|
|
|
select {
|
|
case <-ctxWithTimeout.Done():
|
|
return fmt.Errorf("template execution timeout")
|
|
case err := <-errChan:
|
|
return fmt.Errorf("template execution failed: %w", err)
|
|
case result := <-resultChan:
|
|
pathParts := parsePath(rule.Path)
|
|
return setNestedField(u.Object, pathParts, result)
|
|
}
|
|
}
|
|
|
|
// applyMergeRule merges a map into the target field.
|
|
func (t *Transformer) applyMergeRule(u *unstructured.Unstructured, rule Rule, ctx TransformContext) error {
|
|
if rule.Merge == nil {
|
|
return fmt.Errorf("merge rule has nil merge map")
|
|
}
|
|
|
|
pathParts := parsePath(rule.Path)
|
|
if len(pathParts) == 0 {
|
|
return fmt.Errorf("empty path")
|
|
}
|
|
|
|
// Get existing value (if any)
|
|
existing, found, err := unstructured.NestedMap(u.Object, pathParts...)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get existing value: %w", err)
|
|
}
|
|
|
|
// Create or merge map
|
|
merged := make(map[string]interface{})
|
|
if found {
|
|
for k, v := range existing {
|
|
merged[k] = v
|
|
}
|
|
}
|
|
|
|
// Merge new values
|
|
for k, v := range rule.Merge {
|
|
merged[k] = v
|
|
}
|
|
|
|
return unstructured.SetNestedMap(u.Object, merged, pathParts...)
|
|
}
|
|
|
|
// applyDeleteRule removes a field from the resource.
|
|
func (t *Transformer) applyDeleteRule(u *unstructured.Unstructured, rule Rule, ctx TransformContext) error {
|
|
pathParts := parsePath(rule.Path)
|
|
if len(pathParts) == 0 {
|
|
return fmt.Errorf("empty path")
|
|
}
|
|
|
|
unstructured.RemoveNestedField(u.Object, pathParts...)
|
|
return nil
|
|
}
|
|
|
|
// isStrictMode checks if strict mode is enabled for this resource.
|
|
func (t *Transformer) isStrictMode(u *unstructured.Unstructured) bool {
|
|
if t.options.Strict {
|
|
return true
|
|
}
|
|
|
|
annotations := u.GetAnnotations()
|
|
if annotations == nil {
|
|
return false
|
|
}
|
|
|
|
strictValue, exists := annotations[constants.AnnotationTransformStrict]
|
|
return exists && (strictValue == "true" || strictValue == "1")
|
|
}
|
|
|
|
// parsePath splits a dot-notation path into parts.
|
|
// Handles array indexing notation like "spec.containers[0].image".
|
|
// Returns path segments where array indexes are represented as "[N]" strings.
|
|
func parsePath(path string) []string {
|
|
if path == "" {
|
|
return nil
|
|
}
|
|
|
|
var parts []string
|
|
var current strings.Builder
|
|
inBracket := false
|
|
|
|
for i := 0; i < len(path); i++ {
|
|
ch := path[i]
|
|
|
|
switch ch {
|
|
case '.':
|
|
// End current segment if not in brackets
|
|
if !inBracket && current.Len() > 0 {
|
|
parts = append(parts, current.String())
|
|
current.Reset()
|
|
} else if !inBracket {
|
|
// Skip empty segments from consecutive dots
|
|
continue
|
|
} else {
|
|
// Inside brackets, keep the dot
|
|
current.WriteByte(ch)
|
|
}
|
|
|
|
case '[':
|
|
// Start of array index - save current segment if any
|
|
if current.Len() > 0 {
|
|
parts = append(parts, current.String())
|
|
current.Reset()
|
|
}
|
|
inBracket = true
|
|
current.WriteByte(ch)
|
|
|
|
case ']':
|
|
// End of array index
|
|
if inBracket {
|
|
current.WriteByte(ch)
|
|
parts = append(parts, current.String())
|
|
current.Reset()
|
|
inBracket = false
|
|
} else {
|
|
// Unmatched ], just include it
|
|
current.WriteByte(ch)
|
|
}
|
|
|
|
default:
|
|
current.WriteByte(ch)
|
|
}
|
|
}
|
|
|
|
// Add final segment
|
|
if current.Len() > 0 {
|
|
parts = append(parts, current.String())
|
|
}
|
|
|
|
return parts
|
|
}
|
|
|
|
// setNestedField sets a value at the given path in a nested map/array structure.
|
|
// Supports both map keys and array indexes (e.g., "containers[0]").
|
|
func setNestedField(obj map[string]interface{}, path []string, value interface{}) error {
|
|
if len(path) == 0 {
|
|
return fmt.Errorf("empty path")
|
|
}
|
|
|
|
// Navigate to the parent of the final element
|
|
var current interface{} = obj
|
|
for i := 0; i < len(path)-1; i++ {
|
|
segment := path[i]
|
|
|
|
// Check if this segment is an array index
|
|
if isArrayIndex(segment) {
|
|
index, err := parseArrayIndex(segment)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid array index %s: %w", segment, err)
|
|
}
|
|
|
|
arr, ok := current.([]interface{})
|
|
if !ok {
|
|
return fmt.Errorf("path segment %s requires an array, got %T", segment, current)
|
|
}
|
|
|
|
if index < 0 || index >= len(arr) {
|
|
return fmt.Errorf("array index %d out of bounds (length %d)", index, len(arr))
|
|
}
|
|
|
|
current = arr[index]
|
|
continue
|
|
}
|
|
|
|
// Regular map key
|
|
currentMap, ok := current.(map[string]interface{})
|
|
if !ok {
|
|
return fmt.Errorf("path segment %s requires a map, got %T", segment, current)
|
|
}
|
|
|
|
next, exists := currentMap[segment]
|
|
if !exists {
|
|
// Peek ahead to see if next segment is an array index
|
|
if i+1 < len(path) && isArrayIndex(path[i+1]) {
|
|
// Create an empty array
|
|
newArr := make([]interface{}, 0)
|
|
currentMap[segment] = newArr
|
|
current = newArr
|
|
} else {
|
|
// Create intermediate map
|
|
newMap := make(map[string]interface{})
|
|
currentMap[segment] = newMap
|
|
current = newMap
|
|
}
|
|
continue
|
|
}
|
|
|
|
current = next
|
|
}
|
|
|
|
// Set the final value
|
|
finalSegment := path[len(path)-1]
|
|
|
|
if isArrayIndex(finalSegment) {
|
|
// Setting a value in an array
|
|
index, err := parseArrayIndex(finalSegment)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid array index %s: %w", finalSegment, err)
|
|
}
|
|
|
|
arr, ok := current.([]interface{})
|
|
if !ok {
|
|
return fmt.Errorf("path segment %s requires an array, got %T", finalSegment, current)
|
|
}
|
|
|
|
if index < 0 || index >= len(arr) {
|
|
return fmt.Errorf("array index %d out of bounds (length %d)", index, len(arr))
|
|
}
|
|
|
|
arr[index] = value
|
|
return nil
|
|
}
|
|
|
|
// Setting a value in a map
|
|
currentMap, ok := current.(map[string]interface{})
|
|
if !ok {
|
|
return fmt.Errorf("cannot set key %s on non-map %T", finalSegment, current)
|
|
}
|
|
|
|
// Special handling for Secret data fields
|
|
// Secrets require base64-encoded values in .data field
|
|
finalValue := value
|
|
if isSecretDataField(obj, path) {
|
|
// Convert value to string and base64-encode it
|
|
strValue, ok := value.(string)
|
|
if !ok {
|
|
// Try to convert to string
|
|
strValue = fmt.Sprintf("%v", value)
|
|
}
|
|
finalValue = base64Encode(strValue)
|
|
}
|
|
|
|
currentMap[finalSegment] = finalValue
|
|
return nil
|
|
}
|
|
|
|
// isArrayIndex checks if a path segment is an array index (e.g., "[0]", "[123]").
|
|
func isArrayIndex(segment string) bool {
|
|
return len(segment) > 2 && segment[0] == '[' && segment[len(segment)-1] == ']'
|
|
}
|
|
|
|
// parseArrayIndex extracts the numeric index from an array segment like "[0]".
|
|
func parseArrayIndex(segment string) (int, error) {
|
|
if !isArrayIndex(segment) {
|
|
return 0, fmt.Errorf("not an array index: %s", segment)
|
|
}
|
|
|
|
// Extract the number between brackets
|
|
indexStr := segment[1 : len(segment)-1]
|
|
var index int
|
|
_, err := fmt.Sscanf(indexStr, "%d", &index)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("invalid array index format: %s", indexStr)
|
|
}
|
|
|
|
return index, nil
|
|
}
|
|
|
|
// matchesNamespacePattern checks if a target namespace matches the rule's namespace pattern.
|
|
// If no pattern is specified, the rule applies to all namespaces.
|
|
// Supports glob patterns with * (matches any characters) and ? (matches single character).
|
|
func matchesNamespacePattern(rule Rule, targetNamespace string) bool {
|
|
// If no pattern is specified, rule applies to all namespaces
|
|
if rule.NamespacePattern == nil || *rule.NamespacePattern == "" {
|
|
return true
|
|
}
|
|
|
|
pattern := *rule.NamespacePattern
|
|
return matchGlob(pattern, targetNamespace)
|
|
}
|
|
|
|
// matchGlob performs simple glob pattern matching with support for * and ?.
|
|
// * matches zero or more characters
|
|
// ? matches exactly one character
|
|
func matchGlob(pattern, text string) bool {
|
|
// Fast path for exact match or wildcard-only pattern
|
|
if pattern == text {
|
|
return true
|
|
}
|
|
if pattern == "*" {
|
|
return true
|
|
}
|
|
|
|
return matchGlobRecursive(pattern, text, 0, 0)
|
|
}
|
|
|
|
// matchGlobRecursive implements recursive glob matching.
|
|
func matchGlobRecursive(pattern, text string, pIdx, tIdx int) bool {
|
|
pLen := len(pattern)
|
|
tLen := len(text)
|
|
|
|
// Base cases
|
|
if pIdx == pLen {
|
|
return tIdx == tLen
|
|
}
|
|
|
|
// Check for wildcard
|
|
if pattern[pIdx] == '*' {
|
|
// Try matching zero characters (skip *)
|
|
if matchGlobRecursive(pattern, text, pIdx+1, tIdx) {
|
|
return true
|
|
}
|
|
// Try matching one or more characters
|
|
if tIdx < tLen && matchGlobRecursive(pattern, text, pIdx, tIdx+1) {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Check for single character wildcard or exact match
|
|
if tIdx < tLen && (pattern[pIdx] == '?' || pattern[pIdx] == text[tIdx]) {
|
|
return matchGlobRecursive(pattern, text, pIdx+1, tIdx+1)
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// templateFuncs returns custom template functions.
|
|
func templateFuncs() template.FuncMap {
|
|
return template.FuncMap{
|
|
"upper": strings.ToUpper,
|
|
"lower": strings.ToLower,
|
|
"trimPrefix": strings.TrimPrefix,
|
|
"trimSuffix": strings.TrimSuffix,
|
|
"replace": strings.ReplaceAll,
|
|
"hasPrefix": strings.HasPrefix,
|
|
"hasSuffix": strings.HasSuffix,
|
|
"default": func(defaultValue interface{}, value interface{}) interface{} {
|
|
if value == nil || value == "" {
|
|
return defaultValue
|
|
}
|
|
return value
|
|
},
|
|
}
|
|
}
|
|
|
|
// isSecretDataField checks if the path points to a Secret's .data field.
|
|
func isSecretDataField(obj map[string]interface{}, path []string) bool {
|
|
// Check if this is a Secret by looking at apiVersion and kind
|
|
kind, hasKind := obj["kind"]
|
|
apiVersion, hasAPI := obj["apiVersion"]
|
|
|
|
if !hasKind || !hasAPI {
|
|
return false
|
|
}
|
|
|
|
// Check if it's a Secret (kind=Secret, apiVersion=v1)
|
|
if kind != "Secret" || apiVersion != "v1" {
|
|
return false
|
|
}
|
|
|
|
// Check if path starts with "data"
|
|
return len(path) >= 1 && path[0] == "data"
|
|
}
|
|
|
|
// base64Encode encodes a string to base64.
|
|
func base64Encode(s string) string {
|
|
return base64.StdEncoding.EncodeToString([]byte(s))
|
|
}
|