Preparation for release.

This commit is contained in:
2025-12-25 23:11:32 +00:00
parent 8adb52608f
commit 3e872dfdeb
28 changed files with 5905 additions and 201 deletions
+135 -5
View File
@@ -13,10 +13,12 @@ import (
"github.com/lukaszraczylo/kubemirror/pkg/constants"
"github.com/lukaszraczylo/kubemirror/pkg/hash"
"github.com/lukaszraczylo/kubemirror/pkg/transformer"
)
// CreateMirror creates a mirror resource in the target namespace.
// It copies the source resource's spec/data and adds ownership annotations.
// If transformation rules are present, they are applied to the mirror.
func CreateMirror(source runtime.Object, targetNamespace string) (runtime.Object, error) {
// Compute content hash of source
sourceHash, err := hash.ComputeContentHash(source)
@@ -24,16 +26,29 @@ func CreateMirror(source runtime.Object, targetNamespace string) (runtime.Object
return nil, fmt.Errorf("failed to compute source hash: %w", err)
}
// Handle typed resources
// Create the mirror based on type
var mirror runtime.Object
switch src := source.(type) {
case *corev1.Secret:
return createSecretMirror(src, targetNamespace, sourceHash)
mirror, err = createSecretMirror(src, targetNamespace, sourceHash)
case *corev1.ConfigMap:
return createConfigMapMirror(src, targetNamespace, sourceHash)
mirror, err = createConfigMapMirror(src, targetNamespace, sourceHash)
default:
// For unstructured/CRD resources
return createUnstructuredMirror(source, targetNamespace, sourceHash)
mirror, err = createUnstructuredMirror(source, targetNamespace, sourceHash)
}
if err != nil {
return nil, err
}
// Apply transformations if rules are present
mirror, err = applyTransformations(source, mirror, targetNamespace)
if err != nil {
return nil, fmt.Errorf("transformation failed: %w", err)
}
return mirror, nil
}
// createSecretMirror creates a mirror of a Secret.
@@ -165,6 +180,7 @@ func buildMirrorAnnotations(source runtime.Object, sourceHash string) map[string
}
// UpdateMirror updates an existing mirror with new source content.
// It also applies transformations if transformation rules are present in the source.
func UpdateMirror(mirror, source runtime.Object) error {
// Compute new source hash
sourceHash, err := hash.ComputeContentHash(source)
@@ -186,12 +202,77 @@ func UpdateMirror(mirror, source runtime.Object) error {
updateMirrorAnnotations(m, source, sourceHash)
default:
// Unstructured
return updateUnstructuredMirror(mirror, source, sourceHash)
if err := updateUnstructuredMirror(mirror, source, sourceHash); err != nil {
return err
}
}
// Apply transformations after updating data (only if transformation rules exist)
mirrorObj, _ := mirror.(metav1.Object)
targetNamespace := mirrorObj.GetNamespace()
transformed, err := applyTransformations(source, mirror, targetNamespace)
if err != nil {
return fmt.Errorf("transformation failed: %w", err)
}
// Copy transformed data back to mirror if transformation was applied
// Transformer returns unstructured when transformations are applied, original type otherwise
if transformedU, ok := transformed.(*unstructured.Unstructured); ok {
// Transformation was applied, copy data back to typed mirror
switch m := mirror.(type) {
case *corev1.Secret:
if data, found, _ := unstructured.NestedMap(transformedU.Object, "data"); found {
m.Data = convertToByteMap(data)
}
// Copy potentially transformed labels and annotations
m.SetLabels(transformedU.GetLabels())
m.SetAnnotations(transformedU.GetAnnotations())
case *corev1.ConfigMap:
if data, found, _ := unstructured.NestedMap(transformedU.Object, "data"); found {
m.Data = convertToStringMap(data)
}
if binData, found, _ := unstructured.NestedMap(transformedU.Object, "binaryData"); found {
m.BinaryData = convertToByteMap(binData)
}
// Copy potentially transformed labels and annotations
m.SetLabels(transformedU.GetLabels())
m.SetAnnotations(transformedU.GetAnnotations())
case *unstructured.Unstructured:
// For unstructured, the transformation is already applied in-place
m.Object = transformedU.Object
}
}
// If transformed is not unstructured, no transformation was applied (no rules)
// and we can just use the mirror as-is
return nil
}
// convertToStringMap converts map[string]interface{} to map[string]string.
func convertToStringMap(data map[string]interface{}) map[string]string {
result := make(map[string]string)
for k, v := range data {
if s, ok := v.(string); ok {
result[k] = s
}
}
return result
}
// convertToByteMap converts map[string]interface{} to map[string][]byte.
func convertToByteMap(data map[string]interface{}) map[string][]byte {
result := make(map[string][]byte)
for k, v := range data {
switch val := v.(type) {
case string:
result[k] = []byte(val)
case []byte:
result[k] = val
}
}
return result
}
// updateMirrorAnnotations updates the ownership annotations on a mirror.
func updateMirrorAnnotations(mirror metav1.Object, source runtime.Object, sourceHash string) {
sourceObj, _ := source.(metav1.Object)
@@ -275,3 +356,52 @@ func GetSourceReference(mirror metav1.Object) (namespace, name, uid string, foun
return namespace, name, uid, true
}
// applyTransformations applies transformation rules from the source to the mirror.
// Returns the transformed mirror, or the original mirror if no rules are present.
func applyTransformations(source, mirror runtime.Object, targetNamespace string) (runtime.Object, error) {
// Build transformation context
ctx := buildTransformContext(source, mirror, targetNamespace)
// Create transformer with default options
t := transformer.NewDefaultTransformer()
// Apply transformations (transformer handles case of no rules gracefully)
transformed, err := t.Transform(mirror, ctx)
if err != nil {
return nil, err
}
return transformed, nil
}
// buildTransformContext creates a transformation context from source and mirror metadata.
func buildTransformContext(source, mirror runtime.Object, targetNamespace string) transformer.TransformContext {
sourceObj, _ := source.(metav1.Object)
mirrorObj, _ := mirror.(metav1.Object)
ctx := transformer.TransformContext{
TargetNamespace: targetNamespace,
SourceNamespace: sourceObj.GetNamespace(),
SourceName: sourceObj.GetName(),
TargetName: mirrorObj.GetName(),
}
// Copy labels (if any)
if labels := sourceObj.GetLabels(); labels != nil {
ctx.Labels = make(map[string]string)
for k, v := range labels {
ctx.Labels[k] = v
}
}
// Copy annotations (if any)
if annotations := sourceObj.GetAnnotations(); annotations != nil {
ctx.Annotations = make(map[string]string)
for k, v := range annotations {
ctx.Annotations[k] = v
}
}
return ctx
}
+183
View File
@@ -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
+514
View File
@@ -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
+165
View File
@@ -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"
}
}
+224
View File
@@ -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
}