mirror of
https://github.com/lukaszraczylo/kubemirror.git
synced 2026-06-06 22:49:23 +00:00
547 lines
14 KiB
Go
547 lines
14 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"
|
|
)
|
|
|
|
// 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)
|
|
}
|
|
|
|
// 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))
|
|
}
|