mirror of
https://github.com/lukaszraczylo/kubemirror.git
synced 2026-07-01 12:35:14 +00:00
Preparation for release.
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
# Transformation Rules Design
|
||||
|
||||
## Overview
|
||||
|
||||
Transformation rules allow users to modify resources during mirroring. Rules are specified in the `kubemirror.raczylo.com/transform` annotation as YAML.
|
||||
|
||||
## Annotation Format
|
||||
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: app-config
|
||||
annotations:
|
||||
kubemirror.raczylo.com/sync: "true"
|
||||
kubemirror.raczylo.com/target-namespaces: "prod-*"
|
||||
kubemirror.raczylo.com/transform: |
|
||||
rules:
|
||||
- path: data.LOG_LEVEL
|
||||
value: "error"
|
||||
- path: data.API_URL
|
||||
template: "https://{{.TargetNamespace}}.api.example.com"
|
||||
- path: metadata.labels
|
||||
merge:
|
||||
environment: "production"
|
||||
managed-by: "kubemirror"
|
||||
- path: data.DEBUG_MODE
|
||||
delete: true
|
||||
```
|
||||
|
||||
## Rule Types
|
||||
|
||||
### 1. Static Value (`value`)
|
||||
Set a field to a static value, replacing existing content.
|
||||
|
||||
```yaml
|
||||
- path: data.LOG_LEVEL
|
||||
value: "error"
|
||||
```
|
||||
|
||||
### 2. Template Value (`template`)
|
||||
Use Go templates with context variables.
|
||||
|
||||
**Available template variables:**
|
||||
- `.TargetNamespace` - Target namespace name
|
||||
- `.SourceNamespace` - Source namespace name
|
||||
- `.SourceName` - Source resource name
|
||||
- `.TargetName` - Target resource name (usually same as source)
|
||||
- `.Labels` - Map of source labels
|
||||
- `.Annotations` - Map of source annotations
|
||||
|
||||
```yaml
|
||||
- path: data.API_URL
|
||||
template: "https://{{.TargetNamespace}}.api.example.com"
|
||||
|
||||
- path: metadata.annotations.namespace-specific
|
||||
template: "Mirrored from {{.SourceNamespace}}/{{.SourceName}}"
|
||||
```
|
||||
|
||||
### 3. Map Merge (`merge`)
|
||||
Merge additional key-value pairs into an existing map. If the map doesn't exist, it's created.
|
||||
|
||||
```yaml
|
||||
- path: metadata.labels
|
||||
merge:
|
||||
environment: "production"
|
||||
tier: "frontend"
|
||||
```
|
||||
|
||||
### 4. Field Deletion (`delete`)
|
||||
Remove a field from the resource.
|
||||
|
||||
```yaml
|
||||
- path: data.DEBUG_MODE
|
||||
delete: true
|
||||
|
||||
- path: metadata.annotations.internal-only
|
||||
delete: true
|
||||
```
|
||||
|
||||
## Path Syntax
|
||||
|
||||
Paths use dot notation to traverse the resource structure:
|
||||
- `data.KEY` - Data field in ConfigMap/Secret
|
||||
- `metadata.labels.LABEL_KEY` - Specific label
|
||||
- `metadata.annotations.ANNOTATION_KEY` - Specific annotation
|
||||
- `spec.replicas` - Spec field
|
||||
- `spec.template.spec.containers[0].image` - Array indexing
|
||||
|
||||
## Template Functions
|
||||
|
||||
Custom template functions available:
|
||||
|
||||
- `{{ upper .TargetNamespace }}` - Uppercase
|
||||
- `{{ lower .TargetNamespace }}` - Lowercase
|
||||
- `{{ replace .TargetNamespace "-" "_" }}` - String replacement
|
||||
- `{{ trimPrefix .TargetNamespace "prod-" }}` - Remove prefix
|
||||
- `{{ trimSuffix .TargetNamespace "-app" }}` - Remove suffix
|
||||
- `{{ default "fallback" .Labels.optional }}` - Default value
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **Template Sandboxing**: Templates are executed in a sandboxed environment
|
||||
2. **Path Validation**: Paths must be valid JSONPath expressions
|
||||
3. **No External Access**: Templates cannot access files, network, or execute commands
|
||||
4. **Resource Limits**: Maximum template execution time: 100ms
|
||||
5. **Size Limits**: Maximum transformation rule size: 10KB
|
||||
|
||||
## Examples
|
||||
|
||||
### Environment-Specific Configuration
|
||||
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: app-config
|
||||
namespace: default
|
||||
annotations:
|
||||
kubemirror.raczylo.com/sync: "true"
|
||||
kubemirror.raczylo.com/target-namespaces: "dev-*,staging-*,prod-*"
|
||||
kubemirror.raczylo.com/transform: |
|
||||
rules:
|
||||
# Set log level based on namespace prefix
|
||||
- path: data.LOG_LEVEL
|
||||
template: |
|
||||
{{- if hasPrefix .TargetNamespace "prod-" -}}
|
||||
error
|
||||
{{- else if hasPrefix .TargetNamespace "staging-" -}}
|
||||
warn
|
||||
{{- else -}}
|
||||
debug
|
||||
{{- end }}
|
||||
|
||||
# Namespace-specific API URL
|
||||
- path: data.API_URL
|
||||
template: "https://{{.TargetNamespace}}.api.example.com"
|
||||
|
||||
# Add environment label
|
||||
- path: metadata.labels
|
||||
merge:
|
||||
environment: "{{ trimPrefix .TargetNamespace (regexFind `^[^-]+` .TargetNamespace) }}"
|
||||
```
|
||||
|
||||
### Secret with Dynamic Values
|
||||
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: database-config
|
||||
annotations:
|
||||
kubemirror.raczylo.com/sync: "true"
|
||||
kubemirror.raczylo.com/target-namespaces: "app-*"
|
||||
kubemirror.raczylo.com/transform: |
|
||||
rules:
|
||||
# Database host varies by namespace
|
||||
- path: data.DB_HOST
|
||||
template: "{{ .TargetNamespace }}.postgres.svc.cluster.local"
|
||||
|
||||
# Remove admin password in non-admin namespaces
|
||||
- path: data.ADMIN_PASSWORD
|
||||
delete: true
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
Transformation errors are non-fatal by default:
|
||||
- Invalid path: Log warning, skip transformation
|
||||
- Template error: Log warning, skip transformation
|
||||
- Type mismatch: Log warning, skip transformation
|
||||
|
||||
To make errors fatal (block mirroring):
|
||||
```yaml
|
||||
kubemirror.raczylo.com/transform-strict: "true"
|
||||
```
|
||||
|
||||
## Performance
|
||||
|
||||
- Rules are parsed once and cached
|
||||
- Template compilation is cached
|
||||
- Average overhead: <1ms per mirror creation
|
||||
- Maximum rules per resource: 50
|
||||
@@ -0,0 +1,514 @@
|
||||
package transformer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
)
|
||||
|
||||
const (
|
||||
// AnnotationTransform is the annotation key for transformation rules
|
||||
AnnotationTransform = "kubemirror.raczylo.com/transform"
|
||||
|
||||
// AnnotationTransformStrict enables strict mode (errors block mirroring)
|
||||
AnnotationTransformStrict = "kubemirror.raczylo.com/transform-strict"
|
||||
)
|
||||
|
||||
// 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[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[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)
|
||||
}
|
||||
|
||||
currentMap[finalSegment] = value
|
||||
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
|
||||
},
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,165 @@
|
||||
// Package transformer provides resource transformation capabilities for kubemirror.
|
||||
package transformer
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TransformRules represents a collection of transformation rules.
|
||||
type TransformRules struct {
|
||||
Rules []Rule `yaml:"rules"`
|
||||
}
|
||||
|
||||
// Rule represents a single transformation rule.
|
||||
type Rule struct {
|
||||
// Path is the JSONPath to the field to transform (e.g., "data.LOG_LEVEL", "metadata.labels.env")
|
||||
Path string `yaml:"path"`
|
||||
|
||||
// Value sets a static value (mutually exclusive with Template, Merge, Delete)
|
||||
Value *string `yaml:"value,omitempty"`
|
||||
|
||||
// Template uses Go templates to generate the value (mutually exclusive with Value, Merge, Delete)
|
||||
Template *string `yaml:"template,omitempty"`
|
||||
|
||||
// Merge merges a map into the target field (mutually exclusive with Value, Template, Delete)
|
||||
Merge map[string]interface{} `yaml:"merge,omitempty"`
|
||||
|
||||
// Delete removes the field (mutually exclusive with Value, Template, Merge)
|
||||
Delete bool `yaml:"delete,omitempty"`
|
||||
|
||||
// NamespacePattern is an optional glob pattern that limits this rule to specific target namespaces
|
||||
// Examples: "prod-*", "*-staging", "preprod-*"
|
||||
// If not specified, the rule applies to all namespaces
|
||||
NamespacePattern *string `yaml:"namespacePattern,omitempty"`
|
||||
}
|
||||
|
||||
// TransformContext provides context variables for template evaluation.
|
||||
type TransformContext struct {
|
||||
// TargetNamespace is the namespace where the mirror is being created
|
||||
TargetNamespace string
|
||||
|
||||
// SourceNamespace is the namespace of the source resource
|
||||
SourceNamespace string
|
||||
|
||||
// SourceName is the name of the source resource
|
||||
SourceName string
|
||||
|
||||
// TargetName is the name of the target resource (usually same as source)
|
||||
TargetName string
|
||||
|
||||
// Labels is a copy of the source resource's labels
|
||||
Labels map[string]string
|
||||
|
||||
// Annotations is a copy of the source resource's annotations
|
||||
Annotations map[string]string
|
||||
}
|
||||
|
||||
// TransformOptions configures the transformation behavior.
|
||||
type TransformOptions struct {
|
||||
// Strict mode causes transformation errors to be fatal (blocks mirroring)
|
||||
Strict bool
|
||||
|
||||
// MaxRules limits the number of transformation rules per resource
|
||||
MaxRules int
|
||||
|
||||
// MaxRuleSize limits the size of each rule in bytes
|
||||
MaxRuleSize int
|
||||
|
||||
// TemplateTimeout limits template execution time
|
||||
TemplateTimeout time.Duration
|
||||
}
|
||||
|
||||
// DefaultTransformOptions returns default transformation options.
|
||||
func DefaultTransformOptions() TransformOptions {
|
||||
return TransformOptions{
|
||||
Strict: false,
|
||||
MaxRules: 50,
|
||||
MaxRuleSize: 10 * 1024, // 10KB
|
||||
TemplateTimeout: 100 * time.Millisecond,
|
||||
}
|
||||
}
|
||||
|
||||
// Validate checks if the rule is valid.
|
||||
func (r *Rule) Validate() error {
|
||||
if r.Path == "" {
|
||||
return fmt.Errorf("rule path cannot be empty")
|
||||
}
|
||||
|
||||
// Count how many actions are set
|
||||
actionCount := 0
|
||||
if r.Value != nil {
|
||||
actionCount++
|
||||
}
|
||||
if r.Template != nil {
|
||||
actionCount++
|
||||
}
|
||||
if r.Merge != nil {
|
||||
actionCount++
|
||||
}
|
||||
if r.Delete {
|
||||
actionCount++
|
||||
}
|
||||
|
||||
if actionCount == 0 {
|
||||
return fmt.Errorf("rule must specify one of: value, template, merge, or delete")
|
||||
}
|
||||
|
||||
if actionCount > 1 {
|
||||
return fmt.Errorf("rule cannot specify multiple actions (value, template, merge, delete are mutually exclusive)")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Type returns the type of transformation this rule performs.
|
||||
func (r *Rule) Type() RuleType {
|
||||
switch {
|
||||
case r.Value != nil:
|
||||
return RuleTypeValue
|
||||
case r.Template != nil:
|
||||
return RuleTypeTemplate
|
||||
case r.Merge != nil:
|
||||
return RuleTypeMerge
|
||||
case r.Delete:
|
||||
return RuleTypeDelete
|
||||
default:
|
||||
return RuleTypeUnknown
|
||||
}
|
||||
}
|
||||
|
||||
// RuleType represents the type of transformation.
|
||||
type RuleType int
|
||||
|
||||
const (
|
||||
// RuleTypeUnknown represents an unknown or invalid rule type
|
||||
RuleTypeUnknown RuleType = iota
|
||||
|
||||
// RuleTypeValue sets a static value
|
||||
RuleTypeValue
|
||||
|
||||
// RuleTypeTemplate uses Go templates to generate a value
|
||||
RuleTypeTemplate
|
||||
|
||||
// RuleTypeMerge merges a map into the target field
|
||||
RuleTypeMerge
|
||||
|
||||
// RuleTypeDelete removes a field
|
||||
RuleTypeDelete
|
||||
)
|
||||
|
||||
// String returns the string representation of the rule type.
|
||||
func (rt RuleType) String() string {
|
||||
switch rt {
|
||||
case RuleTypeValue:
|
||||
return "value"
|
||||
case RuleTypeTemplate:
|
||||
return "template"
|
||||
case RuleTypeMerge:
|
||||
return "merge"
|
||||
case RuleTypeDelete:
|
||||
return "delete"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
package transformer
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestRule_Validate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
rule Rule
|
||||
wantErr bool
|
||||
errMsg string
|
||||
}{
|
||||
// Good cases
|
||||
{
|
||||
name: "valid value rule",
|
||||
rule: Rule{
|
||||
Path: "data.KEY",
|
||||
Value: stringPtr("value"),
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid template rule",
|
||||
rule: Rule{
|
||||
Path: "data.KEY",
|
||||
Template: stringPtr("{{.TargetNamespace}}"),
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid merge rule",
|
||||
rule: Rule{
|
||||
Path: "metadata.labels",
|
||||
Merge: map[string]interface{}{
|
||||
"key": "value",
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid delete rule",
|
||||
rule: Rule{
|
||||
Path: "data.KEY",
|
||||
Delete: true,
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
|
||||
// Bad cases
|
||||
{
|
||||
name: "empty path",
|
||||
rule: Rule{
|
||||
Value: stringPtr("value"),
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "path cannot be empty",
|
||||
},
|
||||
{
|
||||
name: "no action specified",
|
||||
rule: Rule{
|
||||
Path: "data.KEY",
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "must specify one of",
|
||||
},
|
||||
{
|
||||
name: "multiple actions - value and template",
|
||||
rule: Rule{
|
||||
Path: "data.KEY",
|
||||
Value: stringPtr("value"),
|
||||
Template: stringPtr("{{.TargetNamespace}}"),
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "cannot specify multiple actions",
|
||||
},
|
||||
{
|
||||
name: "multiple actions - value and merge",
|
||||
rule: Rule{
|
||||
Path: "data.KEY",
|
||||
Value: stringPtr("value"),
|
||||
Merge: map[string]interface{}{"key": "value"},
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "cannot specify multiple actions",
|
||||
},
|
||||
{
|
||||
name: "multiple actions - template and delete",
|
||||
rule: Rule{
|
||||
Path: "data.KEY",
|
||||
Template: stringPtr("{{.TargetNamespace}}"),
|
||||
Delete: true,
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "cannot specify multiple actions",
|
||||
},
|
||||
|
||||
// Edge cases
|
||||
{
|
||||
name: "path with special characters",
|
||||
rule: Rule{
|
||||
Path: "data.my-key.sub-key",
|
||||
Value: stringPtr("value"),
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "merge with empty map",
|
||||
rule: Rule{
|
||||
Path: "metadata.labels",
|
||||
Merge: map[string]interface{}{},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := tt.rule.Validate()
|
||||
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
if tt.errMsg != "" {
|
||||
assert.Contains(t, err.Error(), tt.errMsg)
|
||||
}
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRule_Type(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
rule Rule
|
||||
wantType RuleType
|
||||
}{
|
||||
{
|
||||
name: "value rule",
|
||||
rule: Rule{
|
||||
Path: "data.KEY",
|
||||
Value: stringPtr("value"),
|
||||
},
|
||||
wantType: RuleTypeValue,
|
||||
},
|
||||
{
|
||||
name: "template rule",
|
||||
rule: Rule{
|
||||
Path: "data.KEY",
|
||||
Template: stringPtr("{{.TargetNamespace}}"),
|
||||
},
|
||||
wantType: RuleTypeTemplate,
|
||||
},
|
||||
{
|
||||
name: "merge rule",
|
||||
rule: Rule{
|
||||
Path: "metadata.labels",
|
||||
Merge: map[string]interface{}{"key": "value"},
|
||||
},
|
||||
wantType: RuleTypeMerge,
|
||||
},
|
||||
{
|
||||
name: "delete rule",
|
||||
rule: Rule{
|
||||
Path: "data.KEY",
|
||||
Delete: true,
|
||||
},
|
||||
wantType: RuleTypeDelete,
|
||||
},
|
||||
{
|
||||
name: "unknown rule (no action)",
|
||||
rule: Rule{
|
||||
Path: "data.KEY",
|
||||
},
|
||||
wantType: RuleTypeUnknown,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
gotType := tt.rule.Type()
|
||||
assert.Equal(t, tt.wantType, gotType)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuleType_String(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
ruleType RuleType
|
||||
want string
|
||||
}{
|
||||
{name: "value", ruleType: RuleTypeValue, want: "value"},
|
||||
{name: "template", ruleType: RuleTypeTemplate, want: "template"},
|
||||
{name: "merge", ruleType: RuleTypeMerge, want: "merge"},
|
||||
{name: "delete", ruleType: RuleTypeDelete, want: "delete"},
|
||||
{name: "unknown", ruleType: RuleTypeUnknown, want: "unknown"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := tt.ruleType.String()
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultTransformOptions(t *testing.T) {
|
||||
opts := DefaultTransformOptions()
|
||||
|
||||
assert.False(t, opts.Strict, "default should not be strict mode")
|
||||
assert.Equal(t, 50, opts.MaxRules, "default max rules should be 50")
|
||||
assert.Equal(t, 10*1024, opts.MaxRuleSize, "default max rule size should be 10KB")
|
||||
assert.Equal(t, 100*time.Millisecond, opts.TemplateTimeout, "default timeout should be 100ms")
|
||||
}
|
||||
|
||||
// stringPtr is a helper to create string pointers
|
||||
func stringPtr(s string) *string {
|
||||
return &s
|
||||
}
|
||||
Reference in New Issue
Block a user