mirror of
https://github.com/lukaszraczylo/kubemirror.git
synced 2026-06-14 03:02:20 +00:00
Preparation for release.
This commit is contained in:
+135
-5
@@ -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
|
||||
}
|
||||
|
||||
@@ -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